diff --git a/CLAUDE.md b/CLAUDE.md index aeee1ed..a2a6492 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Makefile b/Makefile index 7fbf5ff..252d1fb 100644 --- a/Makefile +++ b/Makefile @@ -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 \ diff --git a/cmd/mavend/actions.go b/cmd/mavend/actions.go index 4145676..c8d0b95 100644 --- a/cmd/mavend/actions.go +++ b/cmd/mavend/actions.go @@ -58,6 +58,9 @@ var actionHandlers = map[router.Intent]func(*reactiveHandler, context.Context, r func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string { // Conversational: build history from dialogue session (prior user turns) // and let the LLM respond from general knowledge + context. + if h.phraser == nil { + return "поговорили." + } history := h.chatHistory() // The phraser hands back its own fallback text alongside the error, so the // turn survives a dead server and the failure still reaches the log. diff --git a/cmd/mavend/actions_fact.go b/cmd/mavend/actions_fact.go index d902642..9b442c2 100644 --- a/cmd/mavend/actions_fact.go +++ b/cmd/mavend/actions_fact.go @@ -39,6 +39,21 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s q.Slots.Value = "" return h.actionQuery(ctx, q) } + // A complaint is not a fact either (#481). "сеть какая-то медленная" and + // "интернет не работает" were stored as `self` rows at confidence 1.00, and + // recall reads a self row back later as if it were still true — the same + // class of row that outranked live search in #470. The sentence describes a + // moment, so she answers it and stores nothing. An explicit "запомни ..." + // and anything about him are both left alone by the test. + if router.IsTransientComplaint(dec.Utterance) { + log.Printf("voice: fact write refused, utterance is a passing complaint: %q (key %q) — answering as chat", + dec.Utterance, dec.Slots.Key) + c := dec + c.Intent = router.IntentChat + c.Slots.Key, c.Slots.HasKey = "", false + c.Slots.Value = "" + return h.actionChat(ctx, c) + } now := h.now() req := ipc.WriteFactReq{ Ts: now, diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 5a4ebce..13807e8 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -81,6 +81,11 @@ var querySources = []querySource{ // matcher requires a task noun or an explicit "что … сделать", so a // date-bearing question still reaches the calendar. {name: "tasks", answer: (*reactiveHandler).queryTasks}, + // Next to "tasks" and for the same reason: "что требует внимания?" is a + // question about the operational state Praxis holds, and it used to fall + // through every source to the web search (Vikunja #475). Its matcher needs + // an attention marker, and it falls through when Praxis is not configured. + {name: "attention", answer: (*reactiveHandler).queryAttention}, // Before the recall sources too: "сколько я потратил?" is a question about // the money facts the poller wrote, and the notes pass would otherwise // answer it from whatever he once said about spending. Its matcher needs a @@ -91,6 +96,12 @@ var querySources = []querySource{ // marker, so "надо бы съездить в магазин" is untouched. {name: "list", answer: (*reactiveHandler).queryList}, {name: "money", answer: (*reactiveHandler).queryMoney}, + // Also above the recall sources: "что я тебе говорил?" is a question about + // the facts he tapped in, and the notes pass would answer it with whatever + // note is nearest (Vikunja #456). Its matcher needs both halves of a + // history phrase and bails out when he names a topic, so "что я говорил + // про сервер" is still recall. + {name: "history", answer: (*reactiveHandler).queryHistory}, // Before the recall sources and before general knowledge: "что нового?" is // a question about the feeds she reads, and general knowledge would answer // it by inventing news. Its matcher needs a feed noun plus an ask, so @@ -145,6 +156,13 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) continue } if reply, ok := src.answer(h, ctx, t); ok { + // Which source claimed is the one thing about a query turn that was + // invisible from outside: /trace is the nudge-rule trace and carries + // no query-source field, so a wrong answer could not be told from a + // wrongly-ordered chain (Vikunja #474). Only the name is logged — + // the utterance and the answer are already on the voice lines above + // and below this one. + log.Printf("voice: query claimed by source %q", src.name) return reply } } @@ -283,9 +301,17 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, return "", false } if !h.feedsOn { - // Claim the turn rather than fall through: "не читаю ленты" is true, and - // letting general knowledge answer "что нового?" would be an invented - // news bulletin. + // Claim only when nothing below can read the world. The reason this + // source used to claim unconditionally was that general knowledge would + // answer "что нового?" with an invented news bulletin — true, and it + // stopped being the only alternative on 2026-08-02, when live search + // took the lead. With SearXNG or the ZIMs configured, "что происходит + // в новостях про искусственный интеллект?" has a real answer below, + // and a configuration status is the wrong thing to say instead + // (Vikunja #474). + if h.search != nil || h.kiwix != nil { + return "", false + } return phraser.Q(phraser.QueryFeedsOff, nil), true } // By source, not the last 200 notes of any kind: a busy day of voice notes @@ -322,6 +348,15 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, // h.now(), not time.Now(): the handler's clock is the injected one, so this // source can be tested at a fixed time like the rest. func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) { + // A day word is all this source matches on, so any question that merely + // names a day reached it first. "какая сегодня погода в Москве?" answered + // "на 02.08.2026 ничего нет." (Vikunja #474). Weather is asked about a day + // far more often than the calendar is, and the weather source sits right + // below, so the calendar steps aside on weather wording — the same bail-out + // queryHome already does for the same reason. + if isWeatherQuery(t.dec.Utterance) { + return "", false + } date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now()) if !ok { return "", false diff --git a/cmd/mavend/actions_reminder.go b/cmd/mavend/actions_reminder.go index ab9a544..a2c11a2 100644 --- a/cmd/mavend/actions_reminder.go +++ b/cmd/mavend/actions_reminder.go @@ -25,7 +25,10 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio return phraser.Ack(phraser.FailReminderTime, nil) } } - payload := `{"text":` + jsonString(dec.Utterance) + `}` + // The body is what she says at the hour, so the marker and the time come + // out of it: the fire time is already a column, and "напомни" is an + // instruction that has been carried out (Vikunja #469). + payload := `{"text":` + jsonString(reminderBody(dec.Utterance, dec.Slots.Text)) + `}` if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil { log.Printf("voice: create reminder: %v", err) return phraser.Ack(phraser.FailReminder, nil) diff --git a/cmd/mavend/actions_task.go b/cmd/mavend/actions_task.go index a2dded3..1da0148 100644 --- a/cmd/mavend/actions_task.go +++ b/cmd/mavend/actions_task.go @@ -4,6 +4,7 @@ import ( "context" "log" + "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" @@ -71,7 +72,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 diff --git a/cmd/mavend/attentionq.go b/cmd/mavend/attentionq.go new file mode 100644 index 0000000..bb5befa --- /dev/null +++ b/cmd/mavend/attentionq.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "strings" + + "github.com/kami/maven/internal/router" +) + +// attentionMarkers — the ways he asks what Praxis is holding. Substrings on a +// stem, because "внимание", "внимания" and "вниманию" are one word to him. +// +// "что нового" is deliberately absent: the feeds source claims it, and it +// still should — a question about news is a question about the feeds she +// reads. This list is about the operational state of his things. +var attentionMarkers = []string{ + "внимани", "что требует", "что не так", "что важн", "что срочн", + "needs attention", "what needs looking", +} + +// isAttentionQuery reports whether the utterance asks what needs looking at. +func isAttentionQuery(u string) bool { + s := strings.ToLower(strings.TrimSpace(u)) + if s == "" { + return false + } + for _, m := range attentionMarkers { + if strings.Contains(s, m) { + return true + } + } + return false +} + +// queryAttention answers "что требует внимания?" from Praxis. +// +// The capability was already built and already degraded correctly, and no +// utterance could reach it (Vikunja #475). Its aliases live on the act +// dispatch, and the question routes to IntentQuery, so it fell through every +// source to the web search and came back with an encyclopedia article about +// the concept of attention — worse than silence, because it reads as an +// answer. +// +// Placed above the recall sources and well above the personal boundary: this +// is operational state about his things, and a notes pass would otherwise +// answer it from whatever he once wrote about a server. An unconfigured or +// absent Praxis falls through rather than claiming the turn, the same +// convention queryHome and queryNetwork follow. A Praxis that is configured +// and down does claim it, and says it cannot reach the service — that is the +// degradation the ecosystem contract asks for, and it comes from the same +// handler the act path uses. +func (h *reactiveHandler) queryAttention(ctx context.Context, t *queryTurn) (string, bool) { + if !isAttentionQuery(t.dec.Utterance) { + return "", false + } + if h.ecosystem == nil || h.ecosystem.praxis == nil { + return "", false + } + reply := h.handlePraxisAct(ctx, router.Decision{ + Utterance: t.dec.Utterance, + Intent: router.IntentAct, + Slots: router.Slots{Fn: "list_attention", HasFn: true}, + }) + if reply == "" { + return "", false + } + return reply, true +} diff --git a/cmd/mavend/attentionq_test.go b/cmd/mavend/attentionq_test.go new file mode 100644 index 0000000..ee95411 --- /dev/null +++ b/cmd/mavend/attentionq_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/router" +) + +func TestIsAttentionQuery(t *testing.T) { + for _, tc := range []struct { + text string + want bool + }{ + {"что требует внимания", true}, + {"на что обратить внимание?", true}, + {"что не так?", true}, + {"что важного?", true}, + // The feeds source owns this one, and should keep owning it. + {"что нового?", false}, + {"какая погода?", false}, + {"", false}, + } { + if got := isAttentionQuery(tc.text); got != tc.want { + t.Errorf("isAttentionQuery(%q) = %v, want %v", tc.text, got, tc.want) + } + } +} + +// TestAttentionQuestionReachesPraxis — the defect (Vikunja #475). The question +// routes to IntentQuery, and every source used to pass, so a web search about +// the concept of attention answered it. +func TestAttentionQuestionReachesPraxis(t *testing.T) { + ctx := context.Background() + praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{ + "id": "item_1", "title": "disk almost full", "importance": 3.0, + })) + h := ecoHandler(t, nil, praxis, nil) + + reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{ + Intent: router.IntentQuery, Utterance: "что требует внимания", + }}) + if !ok { + t.Fatal("the attention question must be claimed before the world sources") + } + if !strings.Contains(reply, "disk almost full") { + t.Fatalf("reply = %q, want the praxis item", reply) + } +} + +// A configured Praxis that is down claims the turn and says so. Falling +// through here would answer an outage with an encyclopedia article. +func TestAttentionQuestionSaysWhenPraxisIsDown(t *testing.T) { + ctx := context.Background() + praxis := newFakePraxis(t, fixturePraxisAttentionItems()) + h := ecoHandler(t, nil, praxis, nil) + praxis.SetFault(503) + + reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{ + Intent: router.IntentQuery, Utterance: "что требует внимания", + }}) + if !ok || !strings.Contains(reply, "не могу") { + t.Fatalf("an outage must name the gap, got ok=%v reply=%q", ok, reply) + } +} + +// No Praxis configured means no claim: the rest of the chain still runs. +func TestAttentionQuestionFallsThroughWithoutPraxis(t *testing.T) { + h, _ := newFactGateHandler(t, time.Now()) + if _, ok := h.queryAttention(context.Background(), &queryTurn{dec: router.Decision{ + Intent: router.IntentQuery, Utterance: "что требует внимания", + }}); ok { + t.Fatal("an unconfigured praxis must not claim the turn") + } +} diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go index 1604d9d..d6efb09 100644 --- a/cmd/mavend/clarify.go +++ b/cmd/mavend/clarify.go @@ -33,19 +33,10 @@ var wantedSlots = map[router.Intent][]dialogue.Slot{ router.IntentAct: {dialogue.SlotFn}, } -// clarifyQuestions — one short question per missing slot. -// -// These are fixed templates, not model output. The resident model is a 0.8B; it -// would wander, and a question whose wording changes every time is harder to -// answer than a blunt one that always reads the same. They are infinitive -// questions, so there is no gender agreement to get wrong; the feminine -// self-reference lives in the reply she gives when she drops the request. -var clarifyQuestions = map[dialogue.Slot]string{ - dialogue.SlotTime: "Когда?", - dialogue.SlotText: "О чём напомнить?", - dialogue.SlotKey: "Что записать?", - dialogue.SlotFn: "Что сделать?", -} +// The questions themselves live in clarifytemplates.go, one list per slot, +// picked by attempt (Vikunja #457). The first ask is the short one this map +// used to hold; a re-ask says it differently, because a question he already +// failed to answer is the worst one to repeat unchanged. // clarifyGaveUp — she is out of questions and still does not have the slot. She // says so out loud: dropping the request in silence would leave him thinking it @@ -147,7 +138,7 @@ func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) { if len(missing) == 0 { return "", "", false } - q, ok := clarifyQuestions[missing[0]] + q, ok := clarifyQuestionFor(missing[0], 1) if !ok { return "", "", false } @@ -266,7 +257,9 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi if len(remaining) == 0 { return "", false } - question, ok := clarifyQuestions[remaining[0]] + // Attempts+1 is the question she is about to ask, and the budget is shared + // with the re-ask path, so the second gap is worded like a second try. + question, ok := clarifyQuestionFor(remaining[0], q.Attempts+1) if !ok || !q.CanAsk() { return "", false } @@ -290,7 +283,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string { question := "" if len(q.Missing) > 0 { - question = clarifyQuestions[q.Missing[0]] + question, _ = clarifyQuestionFor(q.Missing[0], q.Attempts+1) } if question == "" || !q.CanAsk() { h.clarifyStore.Delete(dialogueIDOf(ctx)) diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go index db1f140..36ce134 100644 --- a/cmd/mavend/clarify_test.go +++ b/cmd/mavend/clarify_test.go @@ -156,8 +156,14 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) { if !handled { t.Fatalf("answer %d must be consumed as an answer", i) } - if reply != "Когда?" { - t.Fatalf("attempt %d should ask again, got %q", i, reply) + // The wording changes with the attempt (Vikunja #457): repeating a + // question he already failed to answer is the worst way to ask it. + want, _ := clarifyQuestionFor(dialogue.SlotTime, i) + if reply != want { + t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply) + } + if first, _ := clarifyQuestionFor(dialogue.SlotTime, 1); reply == first { + t.Fatalf("attempt %d repeated the first wording: %q", i, reply) } if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil { t.Fatalf("attempt %d must leave the question armed", i) @@ -349,8 +355,11 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) { if !handled { t.Fatal("the answer must be consumed as an answer") } - if reply != "Когда?" { - t.Fatalf("a filled subject with no time must ask about the time, got %q", reply) + // Second gap, second attempt, so it is the second wording of the time + // question — the attempt budget is shared between the two paths. + want, _ := clarifyQuestionFor(dialogue.SlotTime, 2) + if reply != want { + t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply) } q := h.clarifyStore.Get(voiceDialogueID, h.now()) if q == nil { @@ -411,8 +420,9 @@ func TestClarifyProseHoldsThePersona(t *testing.T) { eval.CheckCringe: true, } lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...) - for _, q := range clarifyQuestions { - lines = append(lines, q) + lines = append(lines, clarifyMissedVariants...) + for _, variants := range clarifyQuestionVariants { + lines = append(lines, variants...) } for _, line := range lines { for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") { @@ -497,8 +507,12 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) { if !strings.Contains(reminders[0].Payload, "маме") { t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload) } - if !strings.Contains(reminders[0].Payload, "11") { - t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload) + // The hour is the fire time, not a word in the body: the body is what she + // says at the hour, and the time expression is stripped out of it + // (Vikunja #469). Clobbering the parked request would show up here as a + // reminder that fires at some other time than the one he asked for. + if got := reminders[0].FireTs.UTC(); !got.Equal(at.UTC()) { + t.Fatalf("the answer clobbered the original request: fires at %v, want %v", got, at.UTC()) } } diff --git a/cmd/mavend/clarifytemplates.go b/cmd/mavend/clarifytemplates.go new file mode 100644 index 0000000..c080210 --- /dev/null +++ b/cmd/mavend/clarifytemplates.go @@ -0,0 +1,109 @@ +package main + +import ( + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/router" +) + +// The clarify copy deck (Vikunja #457). +// +// Every clarify turn used to say one sentence per gap, and a re-ask repeated +// that sentence word for word. A question he already failed to answer is the +// worst one to ask again unchanged: the second wording is the one that tells +// him which part she missed. +// +// Fixed templates, not model output, for the reason clarifyQuestions has always +// given: the resident model would wander, and a question whose wording changes +// at random is harder to answer than a blunt one. What changes here is that the +// wording varies with the attempt rather than with a die roll — the first ask is +// short, the second names the gap, the third spells it out. +// +// No schema_version, unlike internal/phraser/nudge_templates.go. These are Go +// constants compiled into the daemon, so there is no file that can drift out of +// step with the code that reads it. +// +// Persona holds: infinitive and imperative questions, so there is no gender +// agreement to get wrong, "ты" throughout, and no pet names. +var clarifyQuestionVariants = map[dialogue.Slot][]string{ + dialogue.SlotTime: { + "Когда?", + "Во сколько напомнить?", + "Скажи время — например, «в семь вечера» или «через час».", + }, + dialogue.SlotText: { + "О чём напомнить?", + "Что сказать тебе в это время?", + "Скажи одной фразой, о чём напомнить.", + }, + dialogue.SlotKey: { + "Что записать?", + "Что именно отметить?", + "Назови, что записать — например, «выпил воды».", + }, + dialogue.SlotFn: { + "Что сделать?", + "Какое действие выполнить?", + "Назови действие — я умею только то, что ты мне разрешил.", + }, +} + +// clarifyQuestionFor picks the wording for this attempt. attempt is 1-based, as +// PendingQuestion.Attempts counts it; anything past the list uses the last and +// most explicit phrasing rather than wrapping round to the short one, because +// wrapping would ask the same short question he has already not answered. +// +// Deterministic on purpose, unlike clarifyExpiredLine: an expiry notice is the +// same statement however it is worded, and a re-ask is not. +func clarifyQuestionFor(slot dialogue.Slot, attempt int) (string, bool) { + variants, ok := clarifyQuestionVariants[slot] + if !ok || len(variants) == 0 { + return "", false + } + i := attempt - 1 + if i < 0 { + i = 0 + } + if i >= len(variants) { + i = len(variants) - 1 + } + return variants[i], true +} + +// clarifyMissedVariants — she is asking for the whole utterance again, because +// the gate fired on an intent with nothing identifiable to ask about (note, +// query, chat, system are not in wantedSlots). +// +// Rotated like the expiry lines and for the same reason: this is the line he +// hears whenever she misses him completely, so it is a line that repeats, and +// the same sentence every time is what makes a house assistant sound like a +// kiosk. All of them say the same two things — she did not catch it, and he +// should say it again — because the wording may vary and the meaning may not. +var clarifyMissedVariants = []string{ + "Не совсем поняла — скажи, пожалуйста, ещё раз.", + "Я тебя не разобрала. Повтори, пожалуйста.", + "Не уловила. Скажи это по-другому?", + "Прости, не поняла — попробуй сказать иначе.", +} + +// clarifyMissedFor picks a wording by the utterance itself, so the same words +// asked twice get the same answer and two different misses sound different. +// +// A hash, not rand: a test that drives an utterance twice must not depend on a +// die roll, and the point of rotating is only that consecutive misses differ. +func clarifyMissedFor(utterance string) string { + var sum int + for _, r := range utterance { + sum += int(r) + } + return clarifyMissedVariants[sum%len(clarifyMissedVariants)] +} + +// clarifyMissedLine is the canned reply for a clarify decision she cannot turn +// into a question. Returns "" for a decision that is not a clarify, so the +// caller keeps its own reply. +func clarifyMissedLine(dec router.Decision) string { + if !dec.Clarify { + return "" + } + return clarifyMissedFor(dec.Utterance) +} diff --git a/cmd/mavend/clarifytemplates_test.go b/cmd/mavend/clarifytemplates_test.go new file mode 100644 index 0000000..a35bbef --- /dev/null +++ b/cmd/mavend/clarifytemplates_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "testing" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/router" +) + +// Every slot she can ask about has a wording for every attempt she is allowed, +// and no two attempts on one slot read the same. A deck with a repeated line is +// the defect this deck exists to fix (Vikunja #457). +func TestClarifyQuestionsVaryByAttempt(t *testing.T) { + for slot, variants := range clarifyQuestionVariants { + seen := map[string]bool{} + for _, v := range variants { + if v == "" { + t.Errorf("%s: empty wording in the deck", slot) + } + if seen[v] { + t.Errorf("%s: repeated wording %q", slot, v) + } + seen[v] = true + } + for attempt := 1; attempt <= len(variants); attempt++ { + got, ok := clarifyQuestionFor(slot, attempt) + if !ok || got != variants[attempt-1] { + t.Errorf("%s attempt %d = %q ok=%v, want %q", slot, attempt, got, ok, variants[attempt-1]) + } + } + } +} + +// Past the end she keeps the most explicit wording. Wrapping round would ask +// the short question he has already not answered twice. +func TestClarifyQuestionPastTheEndKeepsTheLastWording(t *testing.T) { + last := clarifyQuestionVariants[dialogue.SlotTime][len(clarifyQuestionVariants[dialogue.SlotTime])-1] + for _, attempt := range []int{0, 4, 9} { + if got, _ := clarifyQuestionFor(dialogue.SlotTime, attempt); attempt > 1 && got != last { + t.Errorf("attempt %d = %q, want the last wording %q", attempt, got, last) + } + } + if _, ok := clarifyQuestionFor("nonesuch", 1); ok { + t.Error("an unknown slot must have no question") + } +} + +// The missed line is stable for one utterance and absent for a decision that is +// not a clarify. +func TestClarifyMissedLine(t *testing.T) { + d := router.Decision{Clarify: true, Utterance: "мгм"} + first := clarifyMissedLine(d) + if first == "" || first != clarifyMissedLine(d) { + t.Fatalf("the missed line must be stable for one utterance, got %q", first) + } + if got := clarifyMissedLine(router.Decision{Intent: router.IntentNote}); got != "" { + t.Errorf("a decision that is not a clarify got %q", got) + } + // The empty utterance still gets a line: she has to say something. + if got := clarifyMissedLine(router.Decision{Clarify: true}); got == "" { + t.Error("an empty utterance must still be answered out loud") + } +} diff --git a/cmd/mavend/factgate_test.go b/cmd/mavend/factgate_test.go index 5dc205f..d51b9dc 100644 --- a/cmd/mavend/factgate_test.go +++ b/cmd/mavend/factgate_test.go @@ -123,3 +123,48 @@ func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 { } return vec } + +// The write half of #481: a complaint about a thing is a state of the +// afternoon, not a fact about him. Stored as a `self` row at confidence 1.00 +// it comes back on recall as if the network were still down. +func TestActionFact_ComplaintIsNotWritten(t *testing.T) { + ctx := context.Background() + h, api := newFactGateHandler(t, time.Now()) + + reply := h.actionFact(ctx, router.Decision{ + Intent: router.IntentFact, + Utterance: "сеть какая-то медленная", + Slots: router.Slots{Key: "network_speed", HasKey: true, Value: "медленная"}, + }) + + if _, err := api.LatestFact(ctx, "network_speed"); err == nil { + t.Fatal("a passing complaint was stored as a fact about him") + } + hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "сеть какая-то медленная"), 3) + if err != nil { + t.Fatalf("memory search: %v", err) + } + if len(hits) != 0 { + t.Fatalf("the complaint was indexed for recall: %+v", hits) + } + if reply == "" { + t.Fatal("the turn was neither stored nor answered") + } +} + +// And the complaint he asked her to keep: the capture verb wins, as it does +// over the question gate. +func TestActionFact_AskedToRememberAComplaintStillWrites(t *testing.T) { + ctx := context.Background() + h, api := newFactGateHandler(t, time.Now()) + + h.actionFact(ctx, router.Decision{ + Intent: router.IntentFact, + Utterance: "запомни что интернет не работает", + Slots: router.Slots{Key: "internet", HasKey: true, Value: "не работает"}, + }) + + if _, err := api.LatestFact(ctx, "internet"); err != nil { + t.Fatalf("an explicit capture was refused: %v", err) + } +} diff --git a/cmd/mavend/feeds_test.go b/cmd/mavend/feeds_test.go index 4db05da..3166861 100644 --- a/cmd/mavend/feeds_test.go +++ b/cmd/mavend/feeds_test.go @@ -200,3 +200,22 @@ func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) { t.Fatal("the poller fetched a private address") } } + +// TestQueryFeedsPassesWhenTheWorldCanAnswer — the defect (Vikunja #474). The +// deployed box has no feeds block and does have SearXNG, and "что происходит +// сейчас в новостях про искусственный интеллект?" got a configuration status +// instead of the live answer sitting one source below. +func TestQueryFeedsPassesWhenTheWorldCanAnswer(t *testing.T) { + h := buildFeedHandler(t, false) + h.search = &searchWiring{max: 3, runes: 1500} + + if reply, ok := askFeeds(t, h, "что нового в лентах?"); ok { + t.Fatalf("feeds off with a search configured must fall through, got %q", reply) + } + // With nothing below that reads the world, the honest status is still said: + // general knowledge would otherwise answer with an invented bulletin. + h.search = nil + if reply, ok := askFeeds(t, h, "что нового в лентах?"); !ok || !strings.Contains(reply, "не настроены") { + t.Fatalf("no search and no ZIMs: reply = %q, ok = %v", reply, ok) + } +} diff --git a/cmd/mavend/historyq.go b/cmd/mavend/historyq.go new file mode 100644 index 0000000..ea85394 --- /dev/null +++ b/cmd/mavend/historyq.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "fmt" + "log" + "strings" + "time" +) + +// Command history — "что я тебе говорил?", "что ты записала сегодня?" +// (Vikunja #456). +// +// Read-only over the facts that already exist. No new mechanism and no new +// storage: everything he tapped in is already a row with a source and a +// timestamp, and this only reads them back. + +// historyMarkers — the ways he asks what he told her. Each entry is a pair of +// substrings that must BOTH appear, because either half alone is a different +// question: "что я говорил про сервер" is a recall question the notes pass +// answers better, and "что ты записала" with no "что" is not a question at all. +var historyMarkers = [][2]string{ + {"что я", "говорил"}, + {"что я", "сказал"}, + {"что я", "рассказ"}, + {"что ты", "записал"}, + {"что ты", "запомнил"}, + {"что я", "отмечал"}, + {"что я", "отметил"}, + {"what did i", "tell"}, + {"what did you", "record"}, +} + +// historyRecall — the word that turns a history question into a recall +// question. "что я говорил про сервер" names a topic, and the notes pass +// answers a topic far better than a list of the last five facts does. +var historyRecall = []string{" про ", " об ", " о ", " about "} + +// isHistoryQuery reports whether he is asking what he told her. +func isHistoryQuery(u string) bool { + s := " " + strings.ToLower(strings.TrimSpace(u)) + " " + if s == " " { + return false + } + for _, r := range historyRecall { + if strings.Contains(s, r) { + return false + } + } + for _, pair := range historyMarkers { + if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) { + return true + } + } + return false +} + +// historyScan — how many recent facts are read before filtering. Deliberately +// larger than historyReadOut: a poller writing every few minutes would +// otherwise push everything he said out of the window, the same way his own +// notes used to crowd out the feed headlines. +const historyScan = 100 + +// historyReadOut — how many she says out loud. Five is what fits in one spoken +// breath; the rest are on /history, which is the surface for reading a list. +const historyReadOut = 5 + +// historyWindow — how far back "recently" reaches. A day, because the question +// is about this conversation and not about the archive. +const historyWindow = 24 * time.Hour + +// queryHistory answers what he told her, from the facts he tapped in. +// +// Only "tap:" sources. A fact written by a poller, an inference or the ambient +// relay is a thing she learned, not a thing he said, and reading those back +// under "что я тебе говорил?" would put words in his mouth. +// +// Placed with the other sources that read his own rows and above the recall +// pass: the notes pass would otherwise answer this from whatever note happens +// to be nearest, which reads as an answer and is not one. +func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (string, bool) { + if !isHistoryQuery(t.dec.Utterance) { + return "", false + } + facts, err := h.api.RecentFacts(ctx, historyScan) + if err != nil { + log.Printf("voice: history: recent facts: %v", err) + return "не получилось посмотреть, что ты говорил.", true + } + cutoff := h.now().Add(-historyWindow) + var said []string + for _, f := range facts { + if !strings.HasPrefix(f.Source, "tap:") || f.Ts.Before(cutoff) { + continue + } + said = append(said, historyLine(f.Key, f.Value, f.Ts)) + if len(said) == historyReadOut { + break + } + } + if len(said) == 0 { + // Claim the turn rather than fall through. "ничего не говорил" is the + // true answer, and recall would answer it with an old note instead. + return "за последние сутки ты мне ничего такого не говорил.", true + } + return "ты говорил: " + strings.Join(said, "; "), true +} + +// historyLine — one fact as she says it. The hour and minute, because the day +// is already bounded by historyWindow and a date would be noise. +func historyLine(key, value string, ts time.Time) string { + what := key + if value != "" { + what = key + " — " + value + } + return fmt.Sprintf("%s (%s)", what, ts.Local().Format("15:04")) +} diff --git a/cmd/mavend/historyq_test.go b/cmd/mavend/historyq_test.go new file mode 100644 index 0000000..b19342a --- /dev/null +++ b/cmd/mavend/historyq_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" +) + +// historyAPI serves a fixed set of recent facts. +type historyAPI struct { + ipc.UnimplementedCoreAPI + facts []ipc.Fact + calls int +} + +func (a *historyAPI) RecentFacts(context.Context, int) ([]ipc.Fact, error) { + a.calls++ + return a.facts, nil +} + +func historyHandler(now time.Time, facts ...ipc.Fact) (*reactiveHandler, *historyAPI) { + api := &historyAPI{facts: facts} + return &reactiveHandler{api: api, now: func() time.Time { return now }}, api +} + +func askHistory(h *reactiveHandler, u string) (string, bool) { + return h.queryHistory(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: u}, + }) +} + +func TestIsHistoryQuery(t *testing.T) { + for _, tc := range []struct { + text string + want bool + }{ + {"что я тебе говорил?", true}, + {"что ты записала сегодня?", true}, + {"что я отмечал?", true}, + // A named topic is a recall question, and the notes pass answers it + // better than a list of the last five facts does. + {"что я говорил про сервер?", false}, + {"что у меня сегодня?", false}, + {"", false}, + } { + if got := isHistoryQuery(tc.text); got != tc.want { + t.Errorf("isHistoryQuery(%q) = %v, want %v", tc.text, got, tc.want) + } + } +} + +func TestHistoryReadsOnlyWhatHeSaid(t *testing.T) { + now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC) + h, api := historyHandler(now, + ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)}, + // Learned, not said: a poller writing this back under "что я тебе + // говорил?" would put words in his mouth. + ipc.Fact{Key: "spent_today", Value: "1200", Source: "poll:zenmoney", Ts: now.Add(-time.Hour)}, + // Older than the window. + ipc.Fact{Key: "shower", Value: "принял", Source: "tap:voice", Ts: now.Add(-30 * time.Hour)}, + ) + reply, ok := askHistory(h, "что я тебе говорил?") + if !ok { + t.Fatal("the history question must be claimed before the recall sources") + } + if !strings.Contains(reply, "water") { + t.Errorf("reply = %q, want the fact he tapped in", reply) + } + if strings.Contains(reply, "spent_today") || strings.Contains(reply, "shower") { + t.Errorf("reply = %q, want only what he said inside the window", reply) + } + if api.calls != 1 { + t.Errorf("RecentFacts called %d times, want 1", api.calls) + } +} + +// Nothing said is an answer of its own. Falling through would hand the question +// to recall, which answers it with an old note. +func TestHistorySaysWhenThereIsNothing(t *testing.T) { + now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC) + h, _ := historyHandler(now) + reply, ok := askHistory(h, "что я тебе говорил?") + if !ok || !strings.Contains(reply, "ничего") { + t.Fatalf("reply = %q, ok = %v", reply, ok) + } +} + +// Five is what fits in one spoken breath; the rest are on /history. +func TestHistoryStopsAtFive(t *testing.T) { + now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC) + var facts []ipc.Fact + for i := 0; i < 12; i++ { + facts = append(facts, ipc.Fact{Key: "k", Value: "v", Source: "tap:voice", Ts: now.Add(-time.Minute)}) + } + h, _ := historyHandler(now, facts...) + reply, _ := askHistory(h, "что ты записала?") + if got := strings.Count(reply, ";"); got != historyReadOut-1 { + t.Fatalf("reply = %q has %d separators, want %d", reply, got, historyReadOut-1) + } +} diff --git a/cmd/mavend/ordinal.go b/cmd/mavend/ordinal.go new file mode 100644 index 0000000..6b2cf61 --- /dev/null +++ b/cmd/mavend/ordinal.go @@ -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 +} diff --git a/cmd/mavend/ordinal_test.go b/cmd/mavend/ordinal_test.go new file mode 100644 index 0000000..cf63bd0 --- /dev/null +++ b/cmd/mavend/ordinal_test.go @@ -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) +} diff --git a/cmd/mavend/reminderbody.go b/cmd/mavend/reminderbody.go new file mode 100644 index 0000000..19ea305 --- /dev/null +++ b/cmd/mavend/reminderbody.go @@ -0,0 +1,53 @@ +package main + +import ( + "regexp" + "strings" +) + +// reminderMarker — the words that open a reminder. Stripped because they are +// the instruction, not the thing to say at the hour. +var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:напомни(?:те)?|напомнить|remind)\s*(?:мне|me)?[\s,:—-]*`) + +// reminderTimeWords — the time expressions a reminder carries, removed from +// the body because the fire time is already a column. Ordered longest-first +// where two could match the same words, so "через полтора часа" does not leave +// "полтора" behind. +// +// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the word +// boundaries here are written out as whitespace or an end of string — the same +// trap the agenda grammars hit. +var reminderTimeWords = []*regexp.Regexp{ + regexp.MustCompile(`(?i)(^|\s)через\s+\S+(\s+(часа?|часов|минут[уы]?|секунд[уы]?|дня|дней|недел[юи]))?(\s|$)`), + regexp.MustCompile(`(?i)(^|\s)(в|во)\s+\d{1,2}(:\d{2})?(\s*(часа?|часов))?(\s*(утра|вечера|дня|ночи))?(\s|$)`), + regexp.MustCompile(`(?i)(^|\s)(завтра|послезавтра|сегодня|вечером|утром|днём|днем|ночью)(\s|$)`), + regexp.MustCompile(`(?i)(^|\s)(at|in)\s+\d{1,2}(:\d{2})?\s*(am|pm)?(\s|$)`), + regexp.MustCompile(`(?i)(^|\s)(tomorrow|today|tonight)(\s|$)`), +} + +// reminderBody is what she says at the hour. +// +// The whole utterance used to be stored, so /reminders read "напомни завтра в +// 9 утра выпить таблетки" where it should read "выпить таблетки", and the +// agenda recited the marker back at him (Vikunja #469). The fire time is +// already a column, and the marker is an instruction that was carried out. +// +// Falls back to the fuller text whenever stripping would leave nothing: an +// empty body is a reminder that fires and says nothing, which is worse than a +// wordy one. +func reminderBody(utterance, text string) string { + body := strings.TrimSpace(text) + if body == "" { + body = strings.TrimSpace(utterance) + } + stripped := reminderMarker.ReplaceAllString(body, "") + for _, re := range reminderTimeWords { + stripped = re.ReplaceAllString(stripped, " ") + } + stripped = strings.TrimSpace(strings.Join(strings.Fields(stripped), " ")) + stripped = strings.Trim(stripped, " ,;:—-") + if stripped == "" { + return body + } + return stripped +} diff --git a/cmd/mavend/reminderbody_test.go b/cmd/mavend/reminderbody_test.go new file mode 100644 index 0000000..3597237 --- /dev/null +++ b/cmd/mavend/reminderbody_test.go @@ -0,0 +1,21 @@ +package main + +import "testing" + +func TestReminderBody(t *testing.T) { + for _, tc := range []struct{ utterance, text, want string }{ + // The row from the QA sitting: the whole utterance was the body. + {"напомни завтра в 9 утра выпить таблетки", "завтра в 9 утра выпить таблетки", "выпить таблетки"}, + {"напомни мне позвонить маме в семь вечера", "позвонить маме в 7 вечера", "позвонить маме"}, + {"напомни через полчаса проверить бэкап", "через полчаса проверить бэкап", "проверить бэкап"}, + {"remind me to call mom at 7pm", "to call mom at 7pm", "to call mom"}, + // Nothing left after stripping ⇒ keep what there was. A reminder that + // fires and says nothing is worse than a wordy one. + {"напомни завтра", "завтра", "завтра"}, + {"", "", ""}, + } { + if got := reminderBody(tc.utterance, tc.text); got != tc.want { + t.Errorf("reminderBody(%q, %q) = %q, want %q", tc.utterance, tc.text, got, tc.want) + } + } +} diff --git a/cmd/mavend/repair.go b/cmd/mavend/repair.go new file mode 100644 index 0000000..63329e0 --- /dev/null +++ b/cmd/mavend/repair.go @@ -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 + " — запомнила." +} diff --git a/cmd/mavend/repair_test.go b/cmd/mavend/repair_test.go new file mode 100644 index 0000000..4bf8958 --- /dev/null +++ b/cmd/mavend/repair_test.go @@ -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") + } +} diff --git a/cmd/mavend/replier_llm.go b/cmd/mavend/replier_llm.go index 496afdb..237f528 100644 --- a/cmd/mavend/replier_llm.go +++ b/cmd/mavend/replier_llm.go @@ -24,7 +24,12 @@ func newLLMReplier(c phraser.Completer, block func() string) *llmReplier { // answer from the stub, which is what keeps a turn from breaking on the model. func (r *llmReplier) Reply(d router.Decision) string { if d.Clarify { - return r.stub.Reply(d) + // The deck, not the stub's single sentence: a clarify she cannot turn + // into a question is the line he hears most often when she misses him, + // and it used to be the same words every time (Vikunja #457). Still no + // model call — this text has to be right every time, and it is not worth + // a generation to say something this small. + return clarifyMissedLine(d) } out, err := r.p.PhraseReply(context.Background(), d) if err != nil || out == "" { diff --git a/cmd/mavend/replier_llm_test.go b/cmd/mavend/replier_llm_test.go index 075716a..027670c 100644 --- a/cmd/mavend/replier_llm_test.go +++ b/cmd/mavend/replier_llm_test.go @@ -38,9 +38,21 @@ func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) { assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm") } -func TestLLMReplierClarifyUsesStub(t *testing.T) { +// A clarify never reaches the model, and since Vikunja #457 it is answered from +// the clarify deck rather than the stub's single sentence. +func TestLLMReplierClarifyReadsTheDeck(t *testing.T) { r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil) - assertStub(t, r, router.Decision{Clarify: true}, "clarify") + got := r.Reply(router.Decision{Clarify: true, Utterance: "мгм"}) + if got == "я всё поняла" { + t.Fatal("a clarify must not be phrased by the model") + } + if want := clarifyMissedFor("мгм"); got != want { + t.Errorf("on clarify: got %q, want %q", got, want) + } + // Two different misses do not sound identical. + if same := r.Reply(router.Decision{Clarify: true, Utterance: "а"}); same == got { + t.Log("two utterances hashed to the same line, which is allowed but should be rare") + } } // assertAck — the stub picks between variants now, so two calls to it are not diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index a0d3ac2..dac48ef 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -167,6 +167,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 @@ -305,6 +306,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 @@ -365,6 +382,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. diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 7658f15..cff9248 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -382,9 +382,12 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, // question and must keep reaching replySystem, while "что у меня сегодня" // is an agenda question and must not. grammars = append(grammars, router.AgendaQueryGrammars()...) - // After the agenda rules: "расскажи, что у меня сегодня" is an agenda - // question first and a narrative request second (Vikunja #498). - grammars = append(grammars, router.NarrativeQueryGrammars()...) + // 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.ListGrammars()...) grammars = append(grammars, router.ReminderGrammar()) // Last, and it matches any utterance shape — its Build is the filter. An @@ -392,6 +395,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, // 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.NarrativeQueryGrammars()...) return router.New(router.Config{ Grammars: grammars, Classifier: cls, diff --git a/cmd/mavend/weatherq_test.go b/cmd/mavend/weatherq_test.go index b41ca9e..8e24a2f 100644 --- a/cmd/mavend/weatherq_test.go +++ b/cmd/mavend/weatherq_test.go @@ -1,6 +1,11 @@ package main -import "testing" +import ( + "context" + "testing" + + "github.com/kami/maven/internal/router" +) // TestExtractWeatherLocation — any place he names comes through, not just the // six that used to be in a table (Vikunja #421). @@ -31,3 +36,30 @@ func TestExtractWeatherLocation(t *testing.T) { } } } + +// TestCalendarStepsAsideForWeather — the defect (Vikunja #474). "какая сегодня +// погода в Москве?" answered "на 02.08.2026 ничего нет.": the calendar matches +// on a day word alone, and it sits above the weather source. +func TestCalendarStepsAsideForWeather(t *testing.T) { + h, api := contQueryHandler() + for _, u := range []string{ + "какая сегодня погода в Москве?", + "будет дождь завтра?", + "сколько градусов сегодня?", + } { + if reply, ok := h.queryCalendar(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: u}, + }); ok { + t.Errorf("the calendar claimed %q with %q", u, reply) + } + } + if api.events != 0 { + t.Errorf("CalendarEvents called %d times for weather questions, want 0", api.events) + } + // The agenda question it exists for still reaches it. + if _, ok := h.queryCalendar(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: "что у меня сегодня?"}, + }); !ok { + t.Fatal("the calendar stopped answering the agenda question") + } +} diff --git a/cmd/mavttsd/main.go b/cmd/mavttsd/main.go index 88e3898..2f0ba7c 100644 --- a/cmd/mavttsd/main.go +++ b/cmd/mavttsd/main.go @@ -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") diff --git a/cmd/mavttsd/main_test.go b/cmd/mavttsd/main_test.go index 2b42cdf..07b9b00 100644 --- a/cmd/mavttsd/main_test.go +++ b/cmd/mavttsd/main_test.go @@ -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") + } +} diff --git a/cmd/mavttsd/piper_handler.go b/cmd/mavttsd/piper_handler.go index 6a39b0e..a80a618 100644 --- a/cmd/mavttsd/piper_handler.go +++ b/cmd/mavttsd/piper_handler.go @@ -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() diff --git a/cmd/mavweb/ambient_test.go b/cmd/mavweb/ambient_test.go index 1636a1b..8ebadf7 100644 --- a/cmd/mavweb/ambient_test.go +++ b/cmd/mavweb/ambient_test.go @@ -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)) diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 9d8368c..14aca14 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -946,6 +946,51 @@ func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow { return out } +// reminderRow is one line on /reminders, with the payload unwrapped and both +// timestamps already in his clock. +// +// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read +// `{"text":"выпить таблетки"}` and fired an hour off what he was told +// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal +// shape he never chose, and a time on a page he reads is the time on his wall. +type reminderRow struct { + Created string + Fires string + Status string + Text string +} + +// reminderText unwraps the {"text":...} payload the router writes. +// +// A copy of store.ReminderText rather than a call to it, because mavweb is one +// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The +// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to +// whoever renders it. Payload that is not that shape is shown as he said it. +func reminderText(payload string) string { + var m map[string]any + if err := json.Unmarshal([]byte(payload), &m); err == nil { + if t, ok := m["text"]; ok { + if s, isStr := t.(string); isStr && s != "" { + return s + } + } + } + return strings.TrimSpace(payload) +} + +func reminderRows(rs []ipc.Reminder) []reminderRow { + out := make([]reminderRow, 0, len(rs)) + for _, r := range rs { + out = append(out, reminderRow{ + Created: r.CreatedTs.Local().Format("02 Jan 15:04"), + Fires: r.FireTs.Local().Format("02 Jan 15:04"), + Status: r.Status, + Text: reminderText(r.Payload), + }) + } + return out +} + func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable) @@ -959,7 +1004,7 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminders}); err != nil { + if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminderRows(reminders)}); err != nil { log.Printf("reminders template: %v", err) } } diff --git a/cmd/mavweb/reminders.html b/cmd/mavweb/reminders.html index e0f6bb6..e28ef22 100644 --- a/cmd/mavweb/reminders.html +++ b/cmd/mavweb/reminders.html @@ -3,10 +3,10 @@ {{if .Reminders}}
| created | fires | status | what | |||
|---|---|---|---|---|---|---|
| {{.CreatedTs.Format "02 Jan 15:04"}} | -{{.FireTs.Format "02 Jan 15:04"}} | +{{.Created}} | +{{.Fires}} | {{.Status}} | -{{.Payload}} | +{{.Text}} |