Compare commits

...

11 Commits

Author SHA1 Message Date
claude c5e993fc55 docs: record the two stage 0 shapes and what they measured (V-498)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:46:12 +04:00
claude a2081d8227 router: stage 0 claims "что дальше" and "расскажи про X" (V-498)
Neither utterance carries a question mark or an interrogative, so nothing at
stage 0 claimed them and the model called both facts. The write is contained —
actions_fact refuses a question-shaped fact and re-runs the turn as a query —
but every one of these paid a full model round trip to reach a decision two
regexes can make, and the fixture scored the routing as wrong.

rest-of-day-query joins the agenda grammars: the predicate for the utterance
already existed as IsRestOfDayQuery, one layer down in the query chain, and
this is what gets the turn there. NarrativeQueryGrammar reads the same
narrativeRequests lexicon IsQuestionShaped reads, and declines the topics that
are chat rather than world questions — a joke, a bedtime story, herself. It is
wired last, so an explicit capture marker still wins.

Fixture: ru-query-024 and ru-query-025, both passing. Classifier + ONNX
baseline 56/80 (70.0%) → 58/82 (70.7%), no case regressed and no new false
clarify. The LLM arm is unmeasured here — no llama-server in this run.

The mavweb auth test posted its instant as "Z", which the #482 fix now reads in
the daemon's zone, making the clock inside the text stale by the test box's own
offset. It carries the local offset now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:45:02 +04:00
claude 82bd160c0d phraser: the startup timeout is a config field, and the arm has a test (V-323)
The 60s wait for llama-server's listen line was hardcoded, so the last arm of
the startup race could not be tested without waiting a real minute, and a box
where a cold 1.7B loads off spinning disk had no way to raise it.

Config.StartupTimeout, defaulted to 60s. The test drives the arm at 200ms
against a fake server that never listens, and asserts the child is killed and
reaped — that arm leaks a llama-server still loading a model otherwise.

startLlamaProc 90.9% → 96.0%, package 76.9% → 77.6%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:38:51 +04:00
claude 1fe03f7a51 calendar: a notification's 14:30 is 14:30 here (V-482)
A relay that posts its instant as `2026-08-02T09:00:00Z` handed the wall clock
inside the text that same zone, so «созвон в 14:30» was built as 14:30 UTC and
read back as 18:30 on this UTC+4 box. Every ambient event landed late by the
deploy's own offset, and correct on a UTC box, which is why no test caught it.

Posted is an instant and carries a zone. The clock reading is a wall clock and
carries none, so it resolves against the daemon's zone now. The tests pin
time.Local to +04 in TestMain, so the four hours show up on a UTC runner too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:37:14 +04:00
claude 77206f298e router: answer the task-list ask at stage 0 too, and mirror it in the fixture (V-467)
The capture half landed with the grammar in 87d1761. This is the exposure
the task asked to check for: IsTaskListQuery is a deterministic lookup that
only runs once the turn is already a query, so a phrasing the model calls
system never reaches it. The eval fixture was also missing both grammars,
which is only worth having while it is the daemon's grammar set.
2026-08-04 04:29:37 +04:00
claude a1f811d4c8 mavend: he can pick one by position (V-448)
Read before routing and only when a list is bound: with nothing offered,
"второй" is an ordinary word and keeps routing. No verb reads it back
rather than guessing what to do with it.
2026-08-04 04:26:10 +04:00
claude 7ab38cd7f7 dialogue, tasks: carry the list she just read (V-448)
Session.Candidates holds what she offered, in the order she offered it, and
SetCandidates attaches it in place so the turn already remembered keeps its
slots. tasks.Spoken is the list FormatRU actually named, so an ordinal and
the spoken order cannot drift apart.
2026-08-04 04:26:10 +04:00
claude 6a85e71077 mavend: wire the correction into the turn, before routing (V-455)
Read next to the confirm and clarify turns, because a correction routed as
a fresh utterance files the correction itself. Only turns she acted on are
remembered: a clarify asked instead of acting.
2026-08-04 04:21:03 +04:00
claude bf6c2bf1a6 mavend: read a spoken correction of the previous turn (V-455)
CorrectMisroute has been in the router since it was written with no caller
outside a test. repair.go is the half that reads the words: a marker saying
she was wrong plus the intent it should have been, with the negated half
skipped, and it teaches the classifier and redoes the request under the
corrected intent.
2026-08-04 04:21:03 +04:00
claude 1558233665 build: make go mod tidy runnable, and drop two dead requirements (V-454)
The vendored toolchain lives inside the module tree, so `go mod tidy` walked
Go's own compiler-error fixtures and died on files that are malformed on
purpose ("unicode//utf8": double slash). A nested module is not part of its
parent, so deps/go.mod ends the walk in three lines. deps/ is gitignored, so
the sentinel is generated by `make deps-sentinel`, which deps-go and deps now
depend on.

The tidy it makes possible drops github.com/kami/praxis, which no file
imports — Praxis is reached over HTTP, by contract. Its replace directive and
the unused nexus one went with it, so a build no longer expects two sibling
checkouts that nothing reads. vendor/ is committed, so `make tidy` re-vendors
in the same breath: a tidy alone leaves the next build failing on
"inconsistent vendoring".

Not wired into `make test`. A build target that rewrites go.mod is a surprise.

Verified: `make build` produces all 9 binaries and `make test` is green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 04:14:07 +04:00
claude 5afa2dfb38 mavttsd: a pronunciation dictionary, so she says the names right (V-458)
piper reads a Russian sentence with a Russian voice, and a Latin service id
inside it comes out spelled, mangled or read as if it were a Russian word:
"Vikunja", "SearXNG", "homesrv". The lever available is the text, so the
dictionary maps a name to how it should be spelled for the voice to say it,
and mavttsd applies it at the last edge before piper — every caller's text
passes through that one point, and nothing upstream has to know how a name
sounds.

Data, not code. deploy/tts-lexicon.json ships 29 names; adding one needs a
restart of mavttsd and no rebuild of the daemon that produced the text. Off
unless -lexicon is set, like every other optional capability, and a path that
is set and unreadable stops startup — saying names wrong in silence is the
failure it exists to remove.

Two details worth keeping: the alternation is sorted longest-first, or "Home
Assistant" reads as "Хоум Assistant"; and the boundaries are written out
rather than left to \b, which is ASCII-only and never fires next to a
Cyrillic letter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 04:11:10 +04:00
33 changed files with 1380 additions and 27 deletions
+12
View File
@@ -189,6 +189,18 @@ fixture had said `query` since ru-query-019 was written. Measured: **full accura
Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an
explicit `(\s|[?!.]|$)`.
Two more shapes taken off the model, 04-08-2026 (V-498). `rest-of-day-query` inside
`AgendaQueryGrammars` claims "что дальше?" / "what's next", and `NarrativeQueryGrammar`
(`stage0.go`, wired **last** in `buildRouter`, after the capture marker) claims "расскажи про
X", "объясни X", "опиши X". Neither carries a question mark or an interrogative, so the model
called both `IntentFact`; the write was caught downstream by `IsQuestionShaped`, so this was a
latency and fixture defect, not a correctness one. The narrative rule reads the same
`narrativeRequests` lexicon `IsQuestionShaped` reads, and declines `chatNarrativeTopics` — a
joke, a bedtime story, herself — because the query chain has no source that answers those.
New fixture cases ru-query-024 and ru-query-025. Classifier + ONNX baseline **56/80 (70.0%) →
58/82 (70.7%)**, no case regressed, no new false clarify. The LLM arm was not measured (no
llama-server in that run), so judge it again before quoting a cascade number.
## LLM output contract
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
+22 -3
View File
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models build-gpud
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel tidy eval-router eval-recall eval-phrasing eval-models build-gpud
all: build
@@ -74,7 +74,7 @@ run-web: build-web
# base.Tool(), which only stats pkg/tool and exits. So build them in once here.
GO_TARBALL := go$(GO_VERSION).linux-amd64.tar.gz
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
deps-go:
deps-go: deps-sentinel
@mkdir -p deps/go
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
cd deps/go && echo '$(GO_SHA256) $(GO_TARBALL)' | sha256sum -c -
@@ -84,6 +84,25 @@ deps-go:
done
$(GO) version
# deps/go.mod — the sentinel that stops the module walk at deps/ (Vikunja #454).
# The vendored toolchain lives inside the module tree, so `go mod tidy` walked
# Go's own compiler-error fixtures and died on files that are malformed on
# purpose ("unicode//utf8": double slash). A nested module is not part of the
# parent, so one three-line file ends the walk. deps/ is gitignored, so it is
# generated here rather than committed, and every target that populates deps/
# writes it.
deps-sentinel:
@mkdir -p deps
@printf 'module github.com/kami/maven/deps\n\ngo 1.21\n' > deps/go.mod
# Run the tidy the sentinel makes possible. Not part of `test`: it rewrites
# go.mod, and a build target that edits the module file is a surprise.
# vendor/ is committed, so a tidy that drops a requirement must be followed by
# a re-vendor or the next build fails on "inconsistent vendoring".
tidy: deps-sentinel
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod tidy
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod vendor
# fmt-check fails if any file needs gofmt. docs/design.md has always said `make
# test` gates on gofmt and vet; it did not, so nine files quietly drifted.
# Run `gofmt -w` on whatever this prints.
@@ -178,7 +197,7 @@ run-tts: build-tts
./mavttsd -socket /tmp/maven/tts.sock \
-piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK)
deps: deps-whisper deps-piper
deps: deps-sentinel deps-whisper deps-piper
deps-whisper:
cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \
+11 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"log"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
@@ -70,7 +71,16 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
log.Printf("voice: list tasks: %v", err)
return "не получилось посмотреть задачи.", true
}
return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), true
ranked := tasks.Rank(taskItems(live), h.now())
// Bind what she is about to say, in the order she says it, so "второй"
// means the second task he heard (ordinal.go).
spoken := tasks.Spoken(ranked)
cands := make([]dialogue.Candidate, 0, len(spoken))
for _, r := range spoken {
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text})
}
h.offerCandidates(cands)
return tasks.FormatRU(ranked), true
}
// taskItems maps wire rows onto the ranker's input. Written here rather than in
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"context"
"fmt"
"log"
"strings"
"unicode"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/store"
)
// Ordinal selection over a list she just read (Vikunja #448).
//
// The dialogue session already carried the intent, the slots and the history.
// What it did not carry was the list: she recited five tasks, he said "второй",
// and there was nothing for that word to point at, so it routed as a fresh
// utterance and meant nothing.
//
// Candidates are bound when she speaks the list, in the order she spoke it (see
// tasks.Spoken). Binding afterwards would resolve "второй" against a fresh
// query, and the list can change between two turns.
//
// An ordinal with no verb is read back, not acted on: "второй" names a task, it
// does not say what to do with it. Acting on the bare word would guess, and a
// wrong guess here closes work he never finished.
// candidateOrdinals — the words that pick a position, by index. Prefix match,
// because Russian declines them: "первый", "первую", "первое".
var candidateOrdinals = []struct {
word string
nth int
}{
{"перв", 1}, {"втор", 2}, {"трет", 3}, {"четв", 4}, {"пят", 5},
{"first", 1}, {"second", 2}, {"third", 3},
}
// candidateDigits — "второй" said as a number. Matched whole, never by prefix:
// "15" starts with "1" and is a time, not a position.
var candidateDigits = map[string]int{"1": 1, "2": 2, "3": 3, "4": 4, "5": 5}
// candidateLast — "последний" picks the end of the list whatever its length.
var candidateLast = []string{"последн", "last"}
// parseOrdinal reads which position he named. 0 and false when he named none.
// A negative result means the last one.
func parseOrdinal(text string) (int, bool) {
// Token by token, not substring: " 1" would otherwise match inside
// "напомни в 15:00" and turn a reminder into a selection.
for _, tok := range strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
}) {
for _, w := range candidateLast {
if strings.HasPrefix(tok, w) {
return -1, true
}
}
if n, ok := candidateDigits[tok]; ok {
return n, true
}
for _, o := range candidateOrdinals {
// Prefix, because Russian declines them: "первый", "первую".
if strings.HasPrefix(tok, o.word) {
return o.nth, true
}
}
}
return 0, false
}
// candidateVerbs — what he wants done with the one he picked. Nothing here is
// destructive: a task moves forward or is dropped, and both are recorded with a
// provenance the /tasks page shows.
var candidateVerbs = []struct {
words []string
status string
say string
}{
{[]string{"готов", "сделал", "выполнил", "закрыл", "done"}, store.TaskDone, "закрыла"},
{[]string{"не надо", "убери", "отмени", "не буду", "drop"}, store.TaskDropped, "убрала"},
{[]string{"подтвержда", "беру", "да,", "буду делать"}, store.TaskOpen, "взяла в работу"},
}
func parseCandidateVerb(text string) (status, say string, ok bool) {
s := strings.ToLower(strings.TrimSpace(text))
for _, v := range candidateVerbs {
for _, w := range v.words {
if strings.Contains(s, w) {
return v.status, v.say, true
}
}
}
return "", "", false
}
// offerCandidates records the list she just read, so his next words can pick
// from it. Best effort: no session store, or a session that expired between the
// question and the answer, means the words route normally.
func (h *reactiveHandler) offerCandidates(cands []dialogue.Candidate) {
if h.dialogueSessions == nil || len(cands) == 0 {
return
}
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), cands)
}
// resolveCandidate handles "второй", "первую сделал", "последнюю убери" against
// the list she just read.
func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (string, bool) {
if h.dialogueSessions == nil {
return "", false
}
sess := h.dialogueSessions.Get(voiceDialogueID, h.now())
if sess == nil || len(sess.Candidates) == 0 {
return "", false
}
nth, ok := parseOrdinal(text)
if !ok {
return "", false
}
if nth < 0 {
nth = len(sess.Candidates)
}
if nth > len(sess.Candidates) {
// Claim the turn: he is picking from her list and named a position she
// did not read. Routing it fresh would answer something else entirely.
return fmt.Sprintf("я назвала только %d.", len(sess.Candidates)), true
}
pick := sess.Candidates[nth-1]
status, say, hasVerb := parseCandidateVerb(text)
if !hasVerb || pick.Kind != "task" {
// Read it back and keep the list: naming one is often the first half of
// a sentence, and the second half is the next turn.
return pick.Label, true
}
if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), "tap:voice"); err != nil {
log.Printf("voice: candidate %d → %s: %v", pick.Ref, status, err)
return "не получилось изменить задачу.", true
}
// Spent: the list she read is no longer the list, and a second ordinal
// against it would close the wrong task.
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), nil)
log.Printf("voice: candidate %d (%q) → %s", pick.Ref, pick.Label, status)
return say + ": " + pick.Label, true
}
+128
View File
@@ -0,0 +1,128 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/store"
)
func TestParseOrdinalReadsThePosition(t *testing.T) {
cases := []struct {
text string
want int
ok bool
}{
{"второй", 2, true},
{"вторую сделал", 2, true},
{"первую убери", 1, true},
{"последнюю не надо", -1, true},
{"3", 3, true},
{"the second one", 2, true},
// No position named.
{"какие у меня задачи", 0, false},
{"", 0, false},
// A digit inside a time is not a position.
{"напомни в 15:00", 0, false},
}
for _, c := range cases {
got, ok := parseOrdinal(c.text)
if ok != c.ok || (ok && got != c.want) {
t.Errorf("parseOrdinal(%q) = %d,%v; want %d,%v", c.text, got, ok, c.want, c.ok)
}
}
}
func TestOrdinalPassesWithNothingOffered(t *testing.T) {
h, _, _ := newClarifyHandler(t)
if _, handled := h.resolveCandidate(context.Background(), "второй"); handled {
t.Error("an ordinal with no list behind it was claimed")
}
}
func TestOrdinalReadsBackWithoutAVerb(t *testing.T) {
h, st, _ := newClarifyHandler(t)
ctx := context.Background()
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
putCandidates(h, ids, "купить хлеб", "позвонить маме")
reply, handled := h.resolveCandidate(ctx, "второй")
if !handled || !strings.Contains(reply, "позвонить маме") {
t.Fatalf("a bare ordinal did not read the task back: %q handled=%v", reply, handled)
}
// Still live: naming one is often the first half of a sentence.
if _, handled := h.resolveCandidate(ctx, "первый"); !handled {
t.Error("the list was spent by a read-back")
}
}
func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
h, st, _ := newClarifyHandler(t)
ctx := context.Background()
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
putCandidates(h, ids, "купить хлеб", "позвонить маме")
reply, handled := h.resolveCandidate(ctx, "первую сделал")
if !handled || !strings.Contains(reply, "купить хлеб") {
t.Fatalf("the pick was not acted on: %q handled=%v", reply, handled)
}
live, err := st.ListTasks(ctx, "live")
if err != nil {
t.Fatalf("list tasks: %v", err)
}
for _, task := range live {
if task.ID == ids[0] {
t.Fatalf("task %d is still live after he closed it", task.ID)
}
}
// Spent: a second ordinal against a list that no longer holds would close
// the wrong task.
if _, handled := h.resolveCandidate(ctx, "второй"); handled {
t.Error("the list survived the pick it was spent on")
}
}
func TestOrdinalPastTheEndSaysHowMany(t *testing.T) {
h, st, _ := newClarifyHandler(t)
ids := seedTasks(t, st, "купить хлеб")
putCandidates(h, ids, "купить хлеб")
reply, handled := h.resolveCandidate(context.Background(), "третий")
if !handled || !strings.Contains(reply, "1") {
t.Fatalf("a position she never read was not answered: %q handled=%v", reply, handled)
}
}
// ordinalNow — a fixed capture time; the ranker only needs the rows to exist.
var ordinalNow = time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
func seedTasks(t *testing.T, st *store.Store, texts ...string) []int64 {
t.Helper()
ctx := context.Background()
var ids []int64
for _, text := range texts {
res, err := st.CaptureTask(ctx, store.Task{
Text: text,
Source: "tap:voice",
Status: store.TaskOpen,
CreatedTs: ordinalNow,
})
if err != nil {
t.Fatalf("capture task: %v", err)
}
ids = append(ids, res.ID)
}
return ids
}
func putCandidates(h *reactiveHandler, ids []int64, labels ...string) {
cands := make([]dialogue.Candidate, 0, len(ids))
for i, id := range ids {
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: id, Label: labels[i]})
}
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{Timestamp: h.now()})
h.offerCandidates(cands)
}
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"context"
"log"
"strings"
"time"
"github.com/kami/maven/internal/router"
)
// Conversation repair (Vikunja #455).
//
// The classifier has been able to learn from a correction since it was
// written — CorrectMisroute appends the utterance as a new example for the
// intent he names, append-only, no retrain. Nothing in the daemon could reach
// it: the only caller was a test. So the mechanism existed and the behaviour
// did not.
//
// This is the reachable half. He says she got it wrong and names what it
// should have been, she redoes the previous utterance under that intent, and
// she says out loud that the correction landed — because a correction he
// cannot see is indistinguishable from one that was dropped.
//
// Taken before routing, like the confirm and clarify turns: "нет, это была
// заметка" is an answer to the previous turn, not a fresh command, and routing
// it as one files the correction itself as a note.
// routedTurn — the previous utterance and where it went, which is all a
// correction needs to point at.
type routedTurn struct {
utterance string
intent router.Intent
at time.Time
}
// repairWindow — how long a turn stays correctable. Long enough that he can
// hear the wrong answer, think, and say so; short enough that "это заметка"
// half an hour later is a fresh sentence and not a verdict on something he has
// forgotten.
const repairWindow = 5 * time.Minute
// repairMarkers — the ways he says she got it wrong. One of these must appear:
// naming an intent alone is an ordinary sentence ("напиши заметку"), and
// treating it as a correction would rewrite the last turn every time he used
// the word.
var repairMarkers = []string{
"не так поняла",
"неправильно поняла",
"ты не поняла",
"не поняла меня",
"ты ошиблась",
"это не",
"а не",
"не про то",
"got it wrong",
"not a ",
"that was wrong",
}
// repairIntents — the words he uses for each intent. Prefixes, because Russian
// declines them: "заметка", "заметку", "заметкой".
var repairIntents = []struct {
word string
intent router.Intent
say string
}{
{"заметк", router.IntentNote, "заметка"},
{"напоминани", router.IntentReminder, "напоминание"},
{"напомнить", router.IntentReminder, "напоминание"},
{"факт", router.IntentFact, "факт"},
{"вопрос", router.IntentQuery, "вопрос"},
{"команд", router.IntentAct, "команда"},
{"note", router.IntentNote, "заметка"},
{"reminder", router.IntentReminder, "напоминание"},
{"fact", router.IntentFact, "факт"},
{"question", router.IntentQuery, "вопрос"},
}
// parseRepair reads a spoken correction: a marker saying she was wrong, plus
// the intent it should have been.
//
// The negated half is skipped. "это заметка, а не напоминание" names both
// intents, and the one he is correcting TO is the one he did not put "не" in
// front of.
func parseRepair(utterance string) (router.Intent, string, bool) {
s := strings.ToLower(strings.TrimSpace(utterance))
if s == "" {
return "", "", false
}
// A leading "нет" is a marker on its own — "нет, это заметка" is the
// shortest correction he actually says. Only leading: "нет" in the middle
// of a sentence is an ordinary word.
marked := strings.HasPrefix(s, "нет") || strings.HasPrefix(s, "no,")
for _, m := range repairMarkers {
if marked || strings.Contains(s, m) {
marked = true
break
}
}
if !marked {
return "", "", false
}
best, say, at := router.Intent(""), "", -1
for _, w := range repairIntents {
i := strings.Index(s, w.word)
if i < 0 || negatedAt(s, i) {
continue
}
// Leftmost wins: "это заметка, а не напоминание" corrects to the first.
if at < 0 || i < at {
best, say, at = w.intent, w.say, i
}
}
if at < 0 {
return "", "", false
}
return best, say, true
}
// negatedAt reports whether the word at i is the one he is ruling out. Only
// the words immediately before it are read, so "не напоминание, а заметка"
// negates the first and leaves the second alone.
func negatedAt(s string, i int) bool {
before := strings.TrimSpace(s[:i])
return strings.HasSuffix(before, "не") || strings.HasSuffix(before, "not")
}
// recordTurn keeps the utterance a correction would point at. Only turns she
// acted on: a clarify asked instead of acting, so there is nothing yet to be
// wrong about.
func (h *reactiveHandler) recordTurn(utterance string, intent router.Intent) {
h.mu.Lock()
defer h.mu.Unlock()
h.lastRouted = &routedTurn{utterance: utterance, intent: intent, at: h.now()}
}
func (h *reactiveHandler) takeLastTurn() *routedTurn {
h.mu.Lock()
defer h.mu.Unlock()
last := h.lastRouted
// Taken, not read: one utterance is corrected once. Saying "нет, не так"
// twice would otherwise redo the same request twice.
h.lastRouted = nil
return last
}
// resolveRepair handles a spoken correction of the previous turn: teach the
// classifier, redo the request under the corrected intent, and say so.
func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (string, bool) {
corrected, say, ok := parseRepair(text)
if !ok || h.router == nil {
return "", false
}
last := h.takeLastTurn()
if last == nil || h.now().Sub(last.at) > repairWindow {
return "", false
}
if last.intent == corrected {
// She already did what he is asking for. Correcting the classifier
// here would teach it the label it produced, and redoing the request
// would file it twice.
return "", false
}
learned := true
if err := h.router.CorrectMisroute(ctx, last.utterance, corrected); err != nil {
// The redo is still worth doing: he asked for something and it did not
// happen. Only the learning half is lost, and he is told so.
log.Printf("voice: repair: could not learn %q as %s: %v", last.utterance, corrected, err)
learned = false
}
log.Printf("voice: repair — %q was %s, corrected to %s (learned=%v)", last.utterance, last.intent, corrected, learned)
dec := router.Decision{
Utterance: last.utterance,
Stage: 2,
Intent: corrected,
Slots: h.extractor.Extract(ctx, corrected, last.utterance, h.now()),
}
// A reminder's Text is what she says at the hour and stays empty when it
// was not spoken, so the gap is asked about rather than filled with the
// whole sentence. Everywhere else the utterance IS the payload.
if dec.Slots.Text == "" && corrected != router.IntentReminder {
dec.Slots.Text = last.utterance
}
return repairLine(say, learned) + " " + h.finishClarified(ctx, dec), true
}
// repairLine — what she says before redoing it, so the correction is visible
// and not just filed. Feminine, informal, no apology: he corrected a routing
// call, he did not complain about her.
func repairLine(say string, learned bool) string {
if !learned {
return "поняла, это " + say + " — переделываю, но запомнить поправку не вышло."
}
return "поняла, это " + say + " — запомнила."
}
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/router"
)
func TestParseRepairReadsTheCorrectedIntent(t *testing.T) {
cases := []struct {
utterance string
want router.Intent
ok bool
}{
{"нет, ты не поняла, это заметка", router.IntentNote, true},
{"нет, это заметка", router.IntentNote, true},
{"это не напоминание, а заметка", router.IntentNote, true},
{"это заметка, а не напоминание", router.IntentNote, true},
{"ты не так поняла — это факт", router.IntentFact, true},
{"неправильно поняла, это был вопрос", router.IntentQuery, true},
{"you got it wrong, that was a note", router.IntentNote, true},
// No marker: an ordinary request that happens to name an intent.
{"запиши заметку купить хлеб", "", false},
{"напомни мне про заметку", "", false},
// A marker with no intent named: nothing to correct to.
{"ты не так поняла", "", false},
{"", "", false},
}
for _, c := range cases {
got, _, ok := parseRepair(c.utterance)
if ok != c.ok || (ok && got != c.want) {
t.Errorf("parseRepair(%q) = %q,%v; want %q,%v", c.utterance, got, ok, c.want, c.ok)
}
}
}
// TestRepairTeachesTheClassifierAndRedoesTheTurn is the whole feature: the
// previous utterance is filed under the intent he named, the classifier keeps
// it as an example, and he hears that it landed.
func TestRepairTeachesTheClassifierAndRedoesTheTurn(t *testing.T) {
h, st, now := newClarifyHandler(t)
emb := router.NewHashEmbedder(256)
cls := router.NewClassifier(emb)
h.embedder = emb
h.router = router.New(router.Config{Classifier: cls, Extractor: h.extractor})
ctx := context.Background()
h.recordTurn("купить хлеб", router.IntentFact)
reply, handled := h.resolveRepair(ctx, "нет, ты не поняла, это заметка")
if !handled {
t.Fatal("a spoken correction was not handled")
}
if !strings.Contains(reply, "заметка") {
t.Errorf("the correction is not named out loud: %q", reply)
}
if strings.Contains(reply, "не вышло") {
t.Errorf("learning failed unexpectedly: %q", reply)
}
ex := cls.Examples(router.IntentNote)
if len(ex) != 1 || ex[0].Text != "купить хлеб" {
t.Fatalf("the classifier did not learn the correction: %+v", ex)
}
notes, err := st.RecentNotes(ctx, 5)
if err != nil {
t.Fatalf("recent notes: %v", err)
}
if len(notes) != 1 || !strings.Contains(notes[0].Text, "купить хлеб") {
t.Fatalf("the request was not redone as a note: %+v", notes)
}
_ = now
}
func TestRepairNeedsARecentTurnToPointAt(t *testing.T) {
h, _, now := newClarifyHandler(t)
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
ctx := context.Background()
// Nothing said yet.
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
t.Error("a correction with no previous turn was handled")
}
// Said, but long ago.
h.recordTurn("купить хлеб", router.IntentFact)
*now = now.Add(repairWindow + time.Minute)
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
t.Error("a correction outside the window was handled")
}
}
func TestRepairIsSpentOnce(t *testing.T) {
h, _, _ := newClarifyHandler(t)
emb := router.NewHashEmbedder(256)
h.embedder = emb
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
ctx := context.Background()
h.recordTurn("купить хлеб", router.IntentFact)
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled {
t.Fatal("the first correction was not handled")
}
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
t.Error("the same turn was corrected twice")
}
}
// TestRepairPassesWhenSheAlreadyDidThat — he names the intent she used. There
// is nothing to teach and redoing it would file the request a second time.
func TestRepairPassesWhenSheAlreadyDidThat(t *testing.T) {
h, _, _ := newClarifyHandler(t)
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
h.recordTurn("купить хлеб", router.IntentNote)
if _, handled := h.resolveRepair(context.Background(), "нет, это заметка"); handled {
t.Error("a correction to the intent she already used was handled")
}
}
+24
View File
@@ -161,6 +161,7 @@ type reactiveHandler struct {
// the y/n answer. ponytail: single slot, single-user box — a second act
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
mu sync.Mutex
lastRouted *routedTurn // the previous acted turn, for a spoken correction (repair.go)
pending *pendingAct
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
@@ -299,6 +300,22 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
return withNotice(expiredNotice, reply)
}
// 4d. spoken correction — "нет, это была заметка" points at the previous
// turn and names what it should have been (repair.go). Before routing,
// like the confirm and clarify turns: routing the correction as a fresh
// utterance files the correction itself instead of fixing anything.
if reply, handled := h.resolveRepair(ctx, text); handled {
return withNotice(expiredNotice, reply)
}
// 4e. ordinal selection — "второй", "первую сделал" pick from the list she
// just read (ordinal.go). Before routing, and only when a list is actually
// bound to the session: with nothing offered, "второй" is an ordinary word
// and keeps routing.
if reply, handled := h.resolveCandidate(ctx, text); handled {
return withNotice(expiredNotice, reply)
}
// 5. route. An elliptical follow-up — "а завтра?" — is answered from the
// previous turn instead (continuation.go): the intent is the part it is
// missing, so no amount of routing recovers it, and the model's guess
@@ -359,6 +376,13 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
}
}
// Remember what this turn was routed as, so the next utterance can correct
// it. Only turns she acts on: a clarify asked instead of acting, so there
// is nothing yet to be wrong about.
if !dec.Clarify {
h.recordTurn(text, dec.Intent)
}
// 8. action — execute the decision's intent. errors here surface as
// short reply text (the user wants to know the action didn't land);
// the round-trip stays alive.
+7
View File
@@ -385,12 +385,19 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
// routed system and answered "пока не умею" (Vikunja #474).
grammars = append(grammars, router.FeedQueryGrammar())
// The list side of the same exposure: a phrasing with no possessive in it
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
grammars = append(grammars, router.TaskListGrammar())
grammars = append(grammars, router.ReminderGrammar())
// Last, and it matches any utterance shape — its Build is the filter. An
// explicit capture marker beats the model, which called it an act and
// rewrote the task text (Vikunja #467). After the rules above because a
// marker never collides with a clock or agenda question.
grammars = append(grammars, router.TaskCaptureGrammar())
// After the capture marker, so "запиши" still wins over "расскажи", and
// last overall because it matches on the first word alone: "расскажи про
// X" is a world question the model called a fact (Vikunja #498).
grammars = append(grammars, router.NarrativeQueryGrammar())
return router.New(router.Config{
Grammars: grammars,
Classifier: cls,
+14 -1
View File
@@ -23,6 +23,7 @@ import (
"syscall"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/worker"
)
@@ -39,15 +40,27 @@ func run(args []string) error {
model := flag.String("model", "", "path to piper onnx model file")
espeakData := flag.String("espeak_data", "", "path to espeak-ng data directory")
tashkeelModel := flag.String("tashkeel_model", "", "path to libtashkeel onnx model")
lexiconPath := flag.String("lexicon", "", "path to the pronunciation dictionary (json, name to spelling)")
flag.CommandLine.Parse(args)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
defer stop()
// Read before the handler is built: a dictionary he asked for and that
// cannot be read is a startup failure, not a warning. Saying names wrong
// in silence is the thing it exists to stop.
lex, err := tts.LoadLexicon(*lexiconPath)
if err != nil {
return err
}
if lex.Size() > 0 {
log.Printf("mavttsd: pronunciation dictionary: %d names from %s", lex.Size(), *lexiconPath)
}
var s worker.Synthesizer
if *piperBin != "" && *model != "" {
s = newPiperHandler(*piperBin, *model, *espeakData, *tashkeelModel)
s = newPiperHandler(*piperBin, *model, *espeakData, *tashkeelModel, lex)
log.Printf("mavttsd: using piper tts (%s, model=%s)", *piperBin, *model)
} else {
log.Printf("mavttsd: no piper/model specified, using stub handler")
+16
View File
@@ -7,6 +7,7 @@ import (
"testing"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/worker"
)
@@ -117,3 +118,18 @@ func abs(n int) int {
}
return n
}
// The dictionary that ships in deploy/ must parse and must be non-empty. It is
// data, so nothing else would catch a trailing comma before the voice did.
func TestShippedLexiconLoads(t *testing.T) {
lex, err := tts.LoadLexicon(filepath.Join("..", "..", "deploy", "tts-lexicon.json"))
if err != nil {
t.Fatalf("deploy/tts-lexicon.json: %v", err)
}
if lex.Size() < 10 {
t.Errorf("shipped dictionary holds %d names, want the full list", lex.Size())
}
if got := lex.Apply("задача в Vikunja"); got == "задача в Vikunja" {
t.Error("the shipped dictionary did not rewrite a name it lists")
}
}
+15 -2
View File
@@ -9,6 +9,7 @@ import (
"os/exec"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/worker"
)
@@ -18,15 +19,20 @@ type piperHandler struct {
configPath string
espeakData string
tashkeelModel string
// lexicon rewrites service ids and Latin names into the spelling the
// Russian voice reads correctly (Vikunja #458). Nil-safe: an unconfigured
// dictionary rewrites nothing.
lexicon *tts.Lexicon
}
func newPiperHandler(piperPath, modelPath, espeakData, tashkeelModel string) *piperHandler {
func newPiperHandler(piperPath, modelPath, espeakData, tashkeelModel string, lexicon *tts.Lexicon) *piperHandler {
return &piperHandler{
piperPath: piperPath,
modelPath: modelPath,
configPath: modelPath + ".json",
espeakData: espeakData,
tashkeelModel: tashkeelModel,
lexicon: lexicon,
}
}
@@ -66,7 +72,14 @@ func (h *piperHandler) Synthesize(ctx context.Context, req worker.SynthesizeReq)
return worker.SynthesizeResp{}, fmt.Errorf("piper: start: %w", err)
}
if _, err := io.WriteString(stdin, req.Text); err != nil {
// The dictionary is applied here, at the last edge before the voice: every
// caller's text passes through this one point, and nothing upstream has to
// know how a name is spelled out loud.
text := req.Text
if h.lexicon != nil {
text = h.lexicon.Apply(text)
}
if _, err := io.WriteString(stdin, text); err != nil {
stdin.Close()
stdout.Close()
_ = cmd.Wait()
+7 -1
View File
@@ -139,7 +139,13 @@ func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
}
func TestHandleAmbientAuth(t *testing.T) {
body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}`
// posted_at carries the local offset, and the clock reading inside the text
// sits twenty minutes after it. A bare "Z" here would make the reading
// stale by the test machine's own offset and the handler would answer 202
// no-meeting, which says nothing about the auth this test is checking
// (Vikunja #482).
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local)
body := fmt.Sprintf(`{"title":"Планёрка 10:00","posted_at":%q}`, posted.Format(time.RFC3339))
newReq := func(hdr, val string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
+30
View File
@@ -0,0 +1,30 @@
{
"Maven": "Мэйвен",
"Nexus": "Нексус",
"Praxis": "Праксис",
"Hexis": "Хексис",
"Vikunja": "Викунья",
"SearXNG": "сёрчиксэнджи",
"Kiwix": "Кивикс",
"Gitea": "Гитея",
"Home Assistant": "Хоум Ассистент",
"Docker": "Докер",
"Telegram": "Телеграм",
"ntfy": "энтифай",
"homesrv": "хоумсёрв",
"workpc": "воркписи",
"whisper": "виспер",
"piper": "пайпер",
"llama-server": "лама сервер",
"Qwen": "Квен",
"CalDAV": "калдав",
"IMAP": "аймап",
"API": "эй-пи-ай",
"CPU": "си-пи-ю",
"GPU": "джи-пи-ю",
"RAM": "оперативная память",
"SSD": "эс-эс-ди",
"uptime": "аптайм",
"backup": "бэкап",
"deploy": "деплой"
}
+35
View File
@@ -223,6 +223,41 @@ Not alternatives — layers:
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
7 intents (`fact, reminder, note, query, act, chat, system`).
#### "второй" points at the list she just read
Landed 2026-08-04 (Vikunja #448). The dialogue session carried the intent, the
slots and the history, and not the list. She recited five tasks, he said
"второй", and the word had nothing to point at.
`Session.Candidates` holds what she just offered, bound at the moment she speaks
it and in the order she speaks it (`tasks.Spoken`). Binding afterwards would
resolve the word against a fresh query, and the list changes between two turns.
`cmd/mavend/ordinal.go` reads the position before routing and dispatches on the
candidate's kind.
An ordinal with no verb is read back, not acted on — "второй" names a task, it
does not say what to do with it. With a verb ("первую сделал", "последнюю
убери") the task moves and the list is spent, because a second ordinal against a
list that no longer holds closes the wrong work. A position she never read is
answered with how many she did read, not routed as a fresh sentence.
#### Saying she got it wrong is a feature
Landed 2026-08-04 (Vikunja #455). `Router.CorrectMisroute` could always append a
corrected utterance as a new classifier example, and until now nothing in the
daemon called it, so the mechanism existed and the behaviour did not.
`cmd/mavend/repair.go` reaches it. He says she got it wrong and names what it
should have been — "нет, это заметка", "это не напоминание, а факт" — and three
things happen in one turn: the classifier learns the utterance under the named
intent, the request is redone under it, and she says the correction landed. The
utterance he is correcting TO is the one with no "не" in front of it.
Read before routing, next to the confirm and clarify turns, because a correction
routed as a fresh utterance files the correction itself. One turn is correctable
once, inside five minutes, and only turns she acted on — a clarify asked instead
of acting, so there is nothing yet to be wrong about.
#### A restart expires a parked question
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
+7
View File
@@ -90,6 +90,13 @@ export LD_LIBRARY_PATH="$ROOT/deps/piper"
Without `-piper` it runs as a stub.
`-lexicon deploy/tts-lexicon.json` adds the pronunciation dictionary: a flat
JSON object of name to Russian spelling, applied to the text just before piper
reads it. It is how `Vikunja` is said as a word rather than spelled out, and how
`SearXNG` and `homesrv` are said at all. Off unless the flag is set; a path that
is set and unreadable stops mavttsd rather than letting it say names wrong in
silence. Adding a name needs a restart of mavttsd and nothing else.
## mavweb — PWA voice bridge (WebSocket ↔ TCP)
No CGo, no deps; builds with stock Go.
-5
View File
@@ -15,7 +15,6 @@ require github.com/kami/hexis v0.0.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kami/praxis v0.0.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -25,8 +24,4 @@ require (
modernc.org/memory v1.11.0 // indirect
)
replace github.com/kami/praxis v0.0.0 => /home/kami/apps/praxis
replace github.com/kami/nexus v0.0.0 => /home/kami/apps/nexus
replace github.com/kami/hexis v0.0.0 => /home/kami/apps/hexis
+9
View File
@@ -68,10 +68,19 @@ var dayWords = map[string]int{
// word ("завтра", "tomorrow") when the notification carries one, and the result
// is refused if it lands more than ambientPastGrace in the past. A bare start
// time gets DefaultReminderDuration.
//
// The clock reading is read in the daemon's zone (Vikunja #482). Posted is an
// instant and carries an offset; "созвон в 14:30" is a wall clock and carries
// none, so the zone has to come from somewhere else. A relay that posts
// "2026-08-02T09:00:00Z" used to make that 14:30 UTC, which stored an 18:30
// meeting on a UTC+4 box — wrong by the deploy's own offset, and invisible on a
// UTC box. The owner's phone and the box share a zone, so the box's zone is the
// honest reading of a bare wall clock.
func EventFromNotification(n Notification) (Event, bool) {
if n.Posted.IsZero() {
return Event{}, false
}
n.Posted = n.Posted.In(time.Local)
line := strings.TrimSpace(n.Title + " " + n.Text)
start, end, ok := parseTimeRange(line)
if !ok {
+43 -9
View File
@@ -1,12 +1,22 @@
package calendar
import (
"os"
"testing"
"time"
)
// A bare clock reading in a notification is read in the daemon's zone, so every
// test here needs a known one. UTC+4 is the deploy's (Europe/Samara) and it is
// the offset the 18:30 bug was measured at, so a regression shows up as four
// hours rather than as nothing at all on a UTC runner.
func TestMain(m *testing.M) {
time.Local = time.FixedZone("+04", 4*3600)
os.Exit(m.Run())
}
func TestEventFromNotification(t *testing.T) {
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.FixedZone("+04", 4*3600))
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local)
tests := []struct {
name string
@@ -98,10 +108,10 @@ func TestEventFromNotification(t *testing.T) {
if !ev.End.After(ev.Start) {
t.Errorf("end %v must be after start %v", ev.End, ev.Start)
}
// The event lands on the day the phone showed it, in the phone's
// location — not shifted into UTC.
if ev.Start.Location() != posted.Location() {
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
// The event lands on the day the phone showed it, in the daemon's
// zone — the clock reading is a wall clock, not an instant.
if ev.Start.Location() != time.Local {
t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local)
}
if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 3 {
t.Errorf("date = %d-%02d-%02d, want 2026-08-03", y, m, d)
@@ -115,8 +125,7 @@ func TestEventFromNotification(t *testing.T) {
// the meeting twelve hours in the past and filed it under today in FactKey. A
// wrong meeting stored is worse than nothing stored.
func TestEventFromNotificationDayWords(t *testing.T) {
loc := time.FixedZone("+04", 4*3600)
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, loc)
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local)
tests := []struct {
name string
@@ -191,7 +200,7 @@ func TestEventFromNotificationDayWords(t *testing.T) {
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
ev, ok := EventFromNotification(Notification{
Title: "Завтра Планёрка 09:00",
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC),
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local),
})
if !ok {
t.Fatal("expected an event")
@@ -201,6 +210,31 @@ func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
}
}
// Vikunja #482. A relay that posts its instant as UTC used to hand the wall
// clock inside the text the same zone, so "созвон в 14:30" was stored as 14:30Z
// and read back as 18:30 on a UTC+4 box — late by exactly the deploy's offset,
// and correct-looking on a UTC one. Nobody writes a notification meaning 14:30Z.
func TestEventFromNotificationReadsTheClockAsLocalTime(t *testing.T) {
ev, ok := EventFromNotification(Notification{
Package: "com.slack",
Title: "Standup",
Text: "созвон в 14:30",
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local
})
if !ok {
t.Fatal("expected an event")
}
if got := ev.Start.Format("15:04"); got != "14:30" {
t.Errorf("start = %s, want 14:30 local", got)
}
if ev.Start.Location() != time.Local {
t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local)
}
if got, want := FactKey(ev), "calendar_event_20260802_Standup"; got != want {
t.Errorf("fact key = %q, want %q", got, want)
}
}
func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
t.Error("a notification with no posted_at has no date to sit on")
@@ -211,7 +245,7 @@ func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
ev, ok := EventFromNotification(Notification{
Title: "Планёрка 10:00-10:30",
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.Local),
})
if !ok {
t.Fatal("expected an event")
+35
View File
@@ -42,12 +42,27 @@ type Turn struct {
Text string // raw utterance
}
// Candidate — one item she just read out loud, kept so his next words can
// pick it ("второй", "первую сделал"). Vikunja #448.
//
// Bound at the moment she speaks the list, not resolved afterwards: the list
// can change between two turns, and "второй" means the second thing she said,
// not the second row of a fresh query.
type Candidate struct {
Kind string // what it is, e.g. "task" — the resolver dispatches on this
Ref int64 // the row it points at
Label string // what she called it, so she can repeat it back
}
type Session struct {
Intent Intent
Slots Slots
Timestamp time.Time
TTL time.Duration
History []Turn // most recent turns, newest last; used for anaphora + cross-intent
// Candidates — the list she just offered, in the order she said it. Empty
// on every turn that offered no choice, which is most of them.
Candidates []Candidate
}
func (s *Session) IsExpired(now time.Time) bool {
@@ -143,6 +158,26 @@ func (s *SessionStore) Put(id string, sess *Session) {
s.save(id, sess)
}
// SetCandidates attaches a just-spoken list to the live session.
//
// In place rather than through Put, because the turn was already remembered by
// the time the answer was built: replacing the session here would drop the
// slots the next follow-up inherits. No session, no candidates — a choice with
// no turn behind it has nothing to be a choice about.
func (s *SessionStore) SetCandidates(id string, now time.Time, cands []Candidate) {
s.mu.Lock()
sess, ok := s.sessions[id]
if ok && !sess.IsExpired(now) {
sess.Candidates = cands
} else {
ok = false
}
s.mu.Unlock()
if ok {
s.save(id, sess)
}
}
func (s *SessionStore) Delete(id string) {
s.mu.Lock()
delete(s.sessions, id)
+21 -2
View File
@@ -85,6 +85,15 @@ type Config struct {
NCtx int
Timeout time.Duration
// StartupTimeout bounds the wait for llama-server to print the address it
// listens on. A config field and not a constant because the box may
// legitimately need longer: a cold 1.7B loading off a spinning disk can
// outrun a minute, and until this existed that returned "server did not
// start within 60s" with no way to raise it.
//
// 0 ⇒ defaultStartupTimeout.
StartupTimeout time.Duration
// CacheRAMMiB bounds llama-server's prompt cache, which is what actually ate
// this box. Measured on homesrv 2026-08-03: the server's own default limit is
// 8192 MiB, it stores the full KV state of every idle slot it evicts (112 kiB
@@ -134,6 +143,8 @@ func DefaultConfig(modelPath string) Config {
// 512 MiB caps total RSS near 1 GB and still holds several recent prompts.
CacheRAMMiB: 512,
Timeout: 30 * time.Second,
StartupTimeout: defaultStartupTimeout,
}
}
@@ -260,7 +271,15 @@ func llamaArgs(cfg Config) []string {
return args
}
// defaultStartupTimeout — the wait for llama-server's listen line when Config
// does not set one. A cold model load off disk is the slow part.
const defaultStartupTimeout = 60 * time.Second
func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) {
startupTimeout := cfg.StartupTimeout
if startupTimeout <= 0 {
startupTimeout = defaultStartupTimeout
}
p := &llamaProc{}
cmd := exec.CommandContext(ctx, cfg.BinPath, llamaArgs(cfg)...)
// Pdeathsig: the kernel SIGKILLs llama-server the moment mavend dies — by
@@ -338,8 +357,8 @@ func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) {
return fail(fmt.Errorf("llm: server output: %w; last output: %s", err, tail.String()))
case <-ctx.Done():
return fail(ctx.Err())
case <-time.After(60 * time.Second):
return fail(fmt.Errorf("llm: server did not start within 60s; last output: %s", tail.String()))
case <-time.After(startupTimeout):
return fail(fmt.Errorf("llm: server did not start within %s; last output: %s", startupTimeout, tail.String()))
}
}
+31
View File
@@ -181,6 +181,37 @@ exit 1`)
t.Fatalf("err = %v, want context.Canceled", err)
}
})
// The last arm of the startup race, and the one most likely to leak: a
// llama-server still loading a model is alive, so giving up on it without
// killing and reaping it orphans a process holding the GPU. Testable at all
// because Config.StartupTimeout replaced a hardcoded 60s (Vikunja #323).
t.Run("startup timeout", func(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "pid")
bin := fakeLlama(t, fmt.Sprintf(`echo $$ > %s
while : ; do sleep 1 ; done`, pidPath))
cfg := testCfg(bin)
cfg.StartupTimeout = 200 * time.Millisecond
_, err := startLlamaProc(context.Background(), cfg)
if err == nil || !strings.Contains(err.Error(), "did not start within 200ms") {
t.Fatalf("err = %v, want the startup-timeout arm naming the timeout", err)
}
raw, readErr := os.ReadFile(pidPath)
if readErr != nil {
t.Fatalf("fake server never recorded its pid: %v", readErr)
}
pid, convErr := strconv.Atoi(strings.TrimSpace(string(raw)))
if convErr != nil {
t.Fatalf("pid file = %q: %v", raw, convErr)
}
// Killed, and reaped: a zombie still answers signal 0, so this asserts
// the Wait ran too.
if err := syscall.Kill(pid, 0); err == nil {
t.Errorf("llama-server %d survived the startup timeout", pid)
}
})
}
func TestNewLLMPhraserSpawns(t *testing.T) {
+7
View File
@@ -237,7 +237,14 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter
// anything while its grammar set is the daemon's grammar set.
grammars = append(grammars, router.AgendaQueryGrammars()...)
grammars = append(grammars, router.FeedQueryGrammar())
// The list side of the same exposure: a phrasing with no possessive in it
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
grammars = append(grammars, router.TaskListGrammar())
grammars = append(grammars, router.ReminderGrammar())
grammars = append(grammars, router.TaskCaptureGrammar())
// "расскажи про X" is a world question the model called a fact, and the
// rule goes last because it matches on the first word alone (Vikunja #498).
grammars = append(grammars, router.NarrativeQueryGrammar())
return router.New(router.Config{
Grammars: grammars,
Classifier: cls,
+2
View File
@@ -25,6 +25,8 @@
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
{ "id": "ru-query-022", "utterance": "какие планы на завтра?", "lang": "ru", "intent": "query", "tags": ["calendar"], "note": "the same agenda question as ru-query-019 aimed at another day; it answered \u043f\u043e\u043a\u0430 \u043d\u0435 \u0443\u043c\u0435\u044e on the deployed daemon while the today form worked (Vikunja #471)" },
{ "id": "ru-query-023", "utterance": "\u043a\u043e\u0433\u0434\u0430 \u043f\u043b\u0430\u043d\u0451\u0440\u043a\u0430?", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "a named event with no calendar word — the noun is the only signal that this is a question about his day" },
{ "id": "ru-query-024", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["calendar", "no-question-word"], "note": "the rest of the day, with no possessive and no plan word to anchor on; the model called it a fact and the write had to be caught downstream (Vikunja #498)" },
{ "id": "ru-query-025", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "no-question-word"], "note": "a narrative request carries no question mark and no interrogative, so it routed fact; contrast ru-chat-003, where the same verb asks for a joke" },
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
+85
View File
@@ -0,0 +1,85 @@
package router
import (
"context"
"testing"
)
// narrativeRouter wires the grammars in the order the daemon wires them
// (voicewire.go), with the narrative rule last — so a test that passes here is
// a test of the deployed precedence, not of the rule in isolation.
func narrativeRouter(t *testing.T) *Router {
t.Helper()
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
r.grammars = append(r.grammars, AgendaQueryGrammars()...)
r.grammars = append(r.grammars, TaskListGrammar())
r.grammars = append(r.grammars, TaskCaptureGrammar())
r.grammars = append(r.grammars, NarrativeQueryGrammar())
return r
}
// "расскажи про X" and "что дальше?" carried no question mark and no
// interrogative, so nothing at stage 0 claimed them and the model called both
// facts (Vikunja #498, point 1 of #470). The fact write is contained now, but
// the round trip and the wrong fixture score are not.
func TestNarrativeAndRestOfDayRouteToQueryAtStageZero(t *testing.T) {
r := narrativeRouter(t)
for _, u := range []string{
"расскажи про битву при Ватерлоо",
"расскажи мне про Юникод",
"объясни как работает tcp",
"опиши Самару",
"перечисли планеты",
"tell me about the fall of Rome",
"что дальше?",
"и что там дальше",
"что дальше",
"what's next?",
} {
d, err := r.Route(context.Background(), u, refNow())
if err != nil {
t.Fatalf("route(%q): %v", u, err)
}
if d.Intent != IntentQuery {
t.Errorf("route(%q) = %s, want query", u, d.Intent)
}
if d.Stage != 0 {
t.Errorf("route(%q) decided at stage %d, want 0 — the point is to skip the model", u, d.Stage)
}
}
}
// The narrative rule must not take a turn that belongs to something else. A
// capture marker wins because it is what he said, and asking her for a joke is
// chat: the query chain has no source that answers it.
func TestNarrativeGrammarLeavesOtherTurnsAlone(t *testing.T) {
r := narrativeRouter(t)
for _, u := range []string{
"расскажи анекдот",
"расскажи о себе",
"расскажи шутку",
"расскажи",
} {
d, err := r.Route(context.Background(), u, refNow())
if err != nil {
t.Fatalf("route(%q): %v", u, err)
}
if d.Stage == 0 && d.Intent == IntentQuery {
t.Errorf("route(%q) was claimed as a world question at stage 0", u)
}
}
}
// The topic reaches the query chain without the verb that introduced it: the
// search leg wants "битву при Ватерлоо", not "расскажи про битву при Ватерлоо".
func TestNarrativeGrammarKeepsTheTopic(t *testing.T) {
r := narrativeRouter(t)
d, err := r.Route(context.Background(), "расскажи про битву при Ватерлоо", refNow())
if err != nil {
t.Fatal(err)
}
if got, want := d.Slots.Text, "битву при Ватерлоо"; got != want {
t.Errorf("text = %q, want %q", got, want)
}
}
+6
View File
@@ -2,6 +2,7 @@ package router
import (
"context"
"errors"
"log"
"time"
)
@@ -201,5 +202,10 @@ func (r *Router) gateLLMDecision(d *Decision) {
// retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over
// time, introspectable, no model surgery.
func (r *Router) CorrectMisroute(ctx context.Context, utterance string, corrected Intent) error {
if r == nil || r.classifier == nil {
// The LLM router can run with no classifier wired. The correction has
// nowhere to land then, and the caller redoes the request anyway.
return errors.New("router: no classifier to correct")
}
return r.classifier.AddExample(ctx, corrected, utterance)
}
+63
View File
@@ -196,6 +196,20 @@ func AgendaQueryGrammars() []Grammar {
Pattern: regexp.MustCompile(`(?i)(^|\s)(план|дел)[а-я]*\s+(на|в|во|по)\s+` + dayWordPattern + `(\s|[?!.]|$)`),
Build: agendaQueryBuild,
},
{
// "что дальше?" — the rest of the day, with no possessive and no
// plan word for the rules above to anchor on, so neither claimed
// it and the model called it a fact (Vikunja #498). The predicate
// for the same utterance already exists as IsRestOfDayQuery, one
// layer down in the query chain; this is what gets the turn there.
//
// "и что там дальше" and "что потом дальше" are the same question,
// and "what's next" splits into two tokens, hence the optional
// middles rather than plain adjacency.
Name: "rest-of-day-query",
Pattern: regexp.MustCompile(`(?i)^\s*(и\s+)?(что|чего|what'?s?)\s+(там\s+|ещё\s+|еще\s+|потом\s+|у\s+меня\s+)?(дальше|next)(\s|[?!.]|$)`),
Build: agendaQueryBuild,
},
{
// A named event with no calendar word at all: "когда планёрка?",
// "во сколько созвон". He is asking when something on his calendar
@@ -208,6 +222,55 @@ func AgendaQueryGrammars() []Grammar {
}
}
// chatNarrativeTopics — the things "расскажи X" asks for that are not
// questions about the world. She is being asked to entertain or to describe
// herself, and the query chain has no source for either.
var chatNarrativeTopics = regexp.MustCompile(`(?i)(анекдот|шутк|сказк|истори[юи]\s+на\s+ночь|о\s+себе|про\s+себя|о\s+нас|про\s+нас)`)
// NarrativeQueryGrammar — stage-0 rule for "расскажи про X", "объясни X",
// "опиши X", routed to IntentQuery.
//
// It carries no question mark and no interrogative, so the model called
// "расскажи про битву при Ватерлоо" a fact and tried to store the answer it
// invented (Vikunja #470, point 1). The write is contained now — actions_fact
// refuses a question-shaped write and re-runs the turn as a query — but every
// such utterance still paid a model round trip to reach a decision one regex
// can make, and the fixture still scored the routing as wrong (Vikunja #498).
//
// The lexicon is narrativeRequests in question.go, which IsQuestionShaped
// already uses. One list, two callers: a word that marks an utterance as
// asking must not mark it here and not there.
//
// Routing, not answering. Which source claims the turn is still the query
// chain's decision, and the personal boundary still sits where it sat.
func NarrativeQueryGrammar() Grammar {
return Grammar{
Name: "narrative-query",
// (\s|[?!.]|$) rather than \b: Go's \b is ASCII-only and never fires
// after a Cyrillic letter, so the pattern would silently never match.
Pattern: regexp.MustCompile(`(?is)^\s*(` + strings.Join(narrativeRequests, "|") + `)(?:\s+(?:мне|нам|us|me))?(?:\s+(?:про|о|об|about))?(\s+.+)$`),
Build: func(m []string) (Decision, bool) {
topic := strings.TrimSpace(m[2])
// "расскажи" with nothing after it is a conversational opener,
// and there is no topic to look up.
if topic == "" {
return Decision{}, false
}
// Against the whole utterance, not the topic: "о себе" has its
// preposition eaten by the pattern, leaving a bare "себе".
if chatNarrativeTopics.MatchString(m[0]) {
return Decision{}, false
}
return Decision{
Stage: 0,
Intent: IntentQuery,
Confidence: 1.0,
Slots: Slots{Text: topic},
}, true
},
}
}
// FeedQueryGrammar — stage-0 rule for "что нового в лентах?", routed to
// IntentQuery so it reaches queryFeeds.
//
+29
View File
@@ -248,3 +248,32 @@ func TaskCaptureGrammar() Grammar {
},
}
}
// TaskListGrammar — stage 0 for "какие у меня задачи", "список дел", "что мне
// нужно сделать" (Vikunja #467).
//
// The same exposure the capture marker had, pointed the other way. IsTaskListQuery
// is a deterministic lookup that lives inside a query source, so it is only
// consulted once the turn is already IntentQuery. A phrasing the model calls
// system or note never reaches it, and "пока не умею" is what he hears — the
// failure the agenda and feed rules were written for.
//
// Placed after the agenda rules, which already send "какие у меня задачи" to
// query. What this adds is the phrasings with no possessive in them.
func TaskListGrammar() Grammar {
return Grammar{
Name: "task-list-query",
Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`),
Build: func(m []string) (Decision, bool) {
if !IsTaskListQuery(m[1]) {
return Decision{}, false
}
return Decision{
Stage: 0,
Intent: IntentQuery,
Confidence: 1.0,
Slots: Slots{Text: strings.TrimSpace(m[1])},
}, true
},
}
}
+24
View File
@@ -119,3 +119,27 @@ func TestTaskCaptureGrammarClaimsTheMarker(t *testing.T) {
}
}
}
// TestTaskListGrammarClaimsTheAsk — a list question answered before the model,
// including the phrasings with no possessive that used to route elsewhere.
func TestTaskListGrammarClaimsTheAsk(t *testing.T) {
g := TaskListGrammar()
claimed := []string{"какие у меня задачи", "список дел", "что мне нужно сделать"}
for _, u := range claimed {
m := g.Pattern.FindStringSubmatch(u)
if m == nil {
t.Fatalf("%q did not match the grammar pattern", u)
}
d, ok := g.Build(m)
if !ok || d.Intent != IntentQuery {
t.Errorf("%q built %+v ok=%v; want a query", u, d, ok)
}
}
passed := []string{"как дела", "напомни купить хлеб", "что docker делает"}
for _, u := range passed {
m := g.Pattern.FindStringSubmatch(u)
if _, ok := g.Build(m); ok {
t.Errorf("%q was claimed as a task list", u)
}
}
}
+23
View File
@@ -268,3 +268,26 @@ func pluralTasksRU(n int) string {
}
return "задач"
}
// Spoken — the tasks FormatRU actually named, in the order it named them
// (Vikunja #448). "второй" has to mean the second thing she said, so the list
// an ordinal resolves against is built here and not by a caller guessing how
// the renderer split and truncated it.
func Spoken(ranked []Ranked) []Ranked {
var open, cands []Ranked
for _, r := range ranked {
if r.Status == StatusCandidate {
cands = append(cands, r)
} else {
open = append(open, r)
}
}
out := make([]Ranked, 0, 2*SpokenLimit)
for _, group := range [][]Ranked{open, cands} {
if len(group) > SpokenLimit {
group = group[:SpokenLimit]
}
out = append(out, group...)
}
return out
}
+128
View File
@@ -0,0 +1,128 @@
package tts
import (
"encoding/json"
"fmt"
"os"
"regexp"
"sort"
"strings"
)
// Pronunciation dictionary (Vikunja #458).
//
// piper reads a Russian sentence with a Russian voice, and a Latin service id
// inside that sentence comes out as letters or as noise: "Vikunja" is spelled
// out, "SearXNG" is unreadable, and "homesrv" is read as if it were a word. The
// fix is not a code change per name — it is a file of replacements applied to
// the text before piper sees it.
//
// Spelling, not phonemes. piper has no lexicon input of its own here, so the
// only lever is the text, and the entry for a name is how it should be spelled
// in Russian for the voice to say it right. That also means a wrong entry is
// visible: it is a word, and it is read out loud.
//
// The dictionary is data, so it ships as a file rather than a table in Go. A
// name added to it needs no rebuild and no deploy of the daemon that owns the
// text — only a restart of mavttsd, which is the process that reads it.
// Lexicon rewrites names into the spelling the voice reads correctly.
//
// The zero value is usable and rewrites nothing, so a daemon with no dictionary
// configured behaves exactly as it did before this existed.
type Lexicon struct {
// Rules are held in one alternation rather than as a map, so a text is
// scanned once however many entries there are, and the longest name wins
// where two overlap ("Home Assistant" before "Home").
re *regexp.Regexp
// by lower-cased name, because the match is case-insensitive and the
// replacement is not derived from what was matched.
by map[string]string
}
// LoadLexicon reads a dictionary file: a flat JSON object of name to spelling.
//
// {"Vikunja": "Викунья", "SearXNG": "сёрчиксэнджи"}
//
// An empty path returns an empty Lexicon and no error — the dictionary is off
// unless configured, like every other optional capability. A path that is set
// and unreadable IS an error: he asked for it, and silently saying names wrong
// is the failure this exists to remove.
func LoadLexicon(path string) (*Lexicon, error) {
if strings.TrimSpace(path) == "" {
return &Lexicon{}, nil
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("tts: lexicon %s: %w", path, err)
}
var entries map[string]string
if err := json.Unmarshal(raw, &entries); err != nil {
return nil, fmt.Errorf("tts: lexicon %s: %w", path, err)
}
return NewLexicon(entries), nil
}
// NewLexicon builds a lexicon from entries already in memory.
func NewLexicon(entries map[string]string) *Lexicon {
names := make([]string, 0, len(entries))
by := make(map[string]string, len(entries))
for name, say := range entries {
name = strings.TrimSpace(name)
if name == "" || strings.TrimSpace(say) == "" {
continue
}
names = append(names, name)
by[strings.ToLower(name)] = say
}
if len(names) == 0 {
return &Lexicon{}
}
// Longest first: "Home Assistant" must match before "Home" does, and Go's
// regexp alternation is leftmost-first, not longest-match.
sort.Slice(names, func(i, j int) bool { return len(names[i]) > len(names[j]) })
quoted := make([]string, len(names))
for i, n := range names {
quoted[i] = regexp.QuoteMeta(n)
}
// The boundaries are written out rather than left to \b, which is ASCII-only
// and never fires next to a Cyrillic letter — so "в Vikunja," would not
// match with \b on the left in a Russian sentence.
pattern := `(?i)(^|[^\p{L}\p{N}_])(` + strings.Join(quoted, "|") + `)($|[^\p{L}\p{N}_])`
return &Lexicon{re: regexp.MustCompile(pattern), by: by}
}
// Apply rewrites every name in the text. Text with no name in it comes back
// unchanged and untouched.
func (l *Lexicon) Apply(text string) string {
if l == nil || l.re == nil || text == "" {
return text
}
// Twice, because two names separated by a single space share the character
// between them and one pass consumes it: "Nexus Praxis" would leave the
// second name alone otherwise.
out := l.replaceOnce(text)
return l.replaceOnce(out)
}
func (l *Lexicon) replaceOnce(text string) string {
return l.re.ReplaceAllStringFunc(text, func(m string) string {
groups := l.re.FindStringSubmatch(m)
if groups == nil {
return m
}
say, ok := l.by[strings.ToLower(groups[2])]
if !ok {
return m
}
return groups[1] + say + groups[3]
})
}
// Size reports how many names are loaded, for the startup log line.
func (l *Lexicon) Size() int {
if l == nil {
return 0
}
return len(l.by)
}
+85
View File
@@ -0,0 +1,85 @@
package tts
import (
"os"
"path/filepath"
"testing"
)
func TestLexiconRewritesNames(t *testing.T) {
lex := NewLexicon(map[string]string{
"Vikunja": "Викунья",
"Home Assistant": "Хоум Ассистент",
"Home": "Хоум",
"GPU": "джи-пи-ю",
})
for _, tc := range []struct{ in, want string }{
{"задача в Vikunja готова", "задача в Викунья готова"},
// Case-insensitive: the router and the model both change the case of a
// name on the way through.
{"открой vikunja.", "открой Викунья."},
// Longest first, or "Home Assistant" is read as "Хоум Assistant".
{"Home Assistant не отвечает", "Хоум Ассистент не отвечает"},
// Two names in a row share the space between them, which one pass
// would consume.
{"GPU GPU", "джи-пи-ю джи-пи-ю"},
// Not a word boundary: a name inside a longer token is left alone.
{"vikunjaless", "vikunjaless"},
{"ничего не совпало", "ничего не совпало"},
{"", ""},
} {
if got := lex.Apply(tc.in); got != tc.want {
t.Errorf("Apply(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// The zero value and an unconfigured path rewrite nothing, so a daemon with no
// dictionary behaves as it did before this existed.
func TestLexiconOffByDefault(t *testing.T) {
var zero *Lexicon
if got := zero.Apply("Vikunja"); got != "Vikunja" {
t.Errorf("nil lexicon rewrote %q", got)
}
lex, err := LoadLexicon("")
if err != nil {
t.Fatalf("LoadLexicon(\"\"): %v", err)
}
if lex.Size() != 0 || lex.Apply("Vikunja") != "Vikunja" {
t.Errorf("empty path produced a live lexicon of %d names", lex.Size())
}
}
// A path he set and that cannot be read is a startup failure. Saying names
// wrong in silence is what the dictionary exists to stop.
func TestLexiconLoadErrors(t *testing.T) {
if _, err := LoadLexicon(filepath.Join(t.TempDir(), "nope.json")); err == nil {
t.Error("a missing dictionary must be an error")
}
bad := filepath.Join(t.TempDir(), "bad.json")
if err := os.WriteFile(bad, []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadLexicon(bad); err == nil {
t.Error("an unparseable dictionary must be an error")
}
}
func TestLexiconRoundTripsAFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "lex.json")
if err := os.WriteFile(path, []byte(`{"Praxis":"Праксис"," ":"skipped","Nexus":""}`), 0o644); err != nil {
t.Fatal(err)
}
lex, err := LoadLexicon(path)
if err != nil {
t.Fatalf("LoadLexicon: %v", err)
}
// Blank names and blank spellings are dropped: an entry that says nothing
// would delete the word it matched.
if lex.Size() != 1 {
t.Fatalf("Size = %d, want 1", lex.Size())
}
if got := lex.Apply("Praxis молчит"); got != "Праксис молчит" {
t.Errorf("Apply = %q", got)
}
}
-3
View File
@@ -15,8 +15,6 @@ github.com/google/uuid
# github.com/kami/hexis v0.0.0 => /home/kami/apps/hexis
## explicit; go 1.25.5
github.com/kami/hexis/pkg/client
# github.com/kami/praxis v0.0.0 => /home/kami/apps/praxis
## explicit; go 1.23
# github.com/mattn/go-isatty v0.0.20
## explicit; go 1.15
github.com/mattn/go-isatty
@@ -79,4 +77,3 @@ modernc.org/memory
modernc.org/sqlite
modernc.org/sqlite/lib
modernc.org/sqlite/vtab
# github.com/kami/nexus v0.0.0 => /home/kami/apps/nexus