From 155823366544c02a9debde2aea8410f784c4dc18 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 04:14:07 +0400 Subject: [PATCH 1/3] build: make go mod tidy runnable, and drop two dead requirements (V-454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 25 ++++++++++++++++++++++--- go.mod | 5 ----- vendor/modules.txt | 3 --- 3 files changed, 22 insertions(+), 11 deletions(-) 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/go.mod b/go.mod index e28d2da..130e021 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/vendor/modules.txt b/vendor/modules.txt index 1888ded..7d8e309 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -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 From bf6c2bf1a6657302c91ac84efa39bfa60a58b510 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 04:21:03 +0400 Subject: [PATCH 2/3] 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. --- cmd/mavend/repair.go | 197 ++++++++++++++++++++++++++++++++++++++ docs/design.md | 17 ++++ internal/router/router.go | 6 ++ 3 files changed, 220 insertions(+) create mode 100644 cmd/mavend/repair.go 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/docs/design.md b/docs/design.md index 9c9e01d..95e441f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -223,6 +223,23 @@ Not alternatives — layers: Router contract: `[{"intent":, key?, value?, text?, verb?}, ...]` over 7 intents (`fact, reminder, note, query, act, chat, system`). +#### 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 diff --git a/internal/router/router.go b/internal/router/router.go index 1df278c..c30738e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) } From 6a85e710776e42f583da8e628017e426012a1820 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 04:21:03 +0400 Subject: [PATCH 3/3] 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. --- cmd/mavend/repair_test.go | 119 ++++++++++++++++++++++++++++++++++++++ cmd/mavend/voice.go | 16 +++++ 2 files changed, 135 insertions(+) create mode 100644 cmd/mavend/repair_test.go 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/voice.go b/cmd/mavend/voice.go index e7f4764..a13dc6f 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -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,14 @@ 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) + } + // 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 +368,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.