Merge pull request 'Conversation repair: name the misroute-correction mechanism as a feature' (#140) from task/455-conversation-repair-name-the-misroute-co into master

This commit was merged in pull request #140.
This commit is contained in:
2026-08-04 18:24:22 +02:00
8 changed files with 377 additions and 11 deletions
+22 -3
View File
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models build-gpud
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel tidy eval-router eval-recall eval-phrasing eval-models build-gpud
all: build
@@ -74,7 +74,7 @@ run-web: build-web
# base.Tool(), which only stats pkg/tool and exits. So build them in once here.
GO_TARBALL := go$(GO_VERSION).linux-amd64.tar.gz
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
deps-go:
deps-go: deps-sentinel
@mkdir -p deps/go
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
cd deps/go && echo '$(GO_SHA256) $(GO_TARBALL)' | sha256sum -c -
@@ -84,6 +84,25 @@ deps-go:
done
$(GO) version
# deps/go.mod — the sentinel that stops the module walk at deps/ (Vikunja #454).
# The vendored toolchain lives inside the module tree, so `go mod tidy` walked
# Go's own compiler-error fixtures and died on files that are malformed on
# purpose ("unicode//utf8": double slash). A nested module is not part of the
# parent, so one three-line file ends the walk. deps/ is gitignored, so it is
# generated here rather than committed, and every target that populates deps/
# writes it.
deps-sentinel:
@mkdir -p deps
@printf 'module github.com/kami/maven/deps\n\ngo 1.21\n' > deps/go.mod
# Run the tidy the sentinel makes possible. Not part of `test`: it rewrites
# go.mod, and a build target that edits the module file is a surprise.
# vendor/ is committed, so a tidy that drops a requirement must be followed by
# a re-vendor or the next build fails on "inconsistent vendoring".
tidy: deps-sentinel
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod tidy
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod vendor
# fmt-check fails if any file needs gofmt. docs/design.md has always said `make
# test` gates on gofmt and vet; it did not, so nine files quietly drifted.
# Run `gofmt -w` on whatever this prints.
@@ -178,7 +197,7 @@ run-tts: build-tts
./mavttsd -socket /tmp/maven/tts.sock \
-piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK)
deps: deps-whisper deps-piper
deps: deps-sentinel deps-whisper deps-piper
deps-whisper:
cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"context"
"log"
"strings"
"time"
"github.com/kami/maven/internal/router"
)
// Conversation repair (Vikunja #455).
//
// The classifier has been able to learn from a correction since it was
// written — CorrectMisroute appends the utterance as a new example for the
// intent he names, append-only, no retrain. Nothing in the daemon could reach
// it: the only caller was a test. So the mechanism existed and the behaviour
// did not.
//
// This is the reachable half. He says she got it wrong and names what it
// should have been, she redoes the previous utterance under that intent, and
// she says out loud that the correction landed — because a correction he
// cannot see is indistinguishable from one that was dropped.
//
// Taken before routing, like the confirm and clarify turns: "нет, это была
// заметка" is an answer to the previous turn, not a fresh command, and routing
// it as one files the correction itself as a note.
// routedTurn — the previous utterance and where it went, which is all a
// correction needs to point at.
type routedTurn struct {
utterance string
intent router.Intent
at time.Time
}
// repairWindow — how long a turn stays correctable. Long enough that he can
// hear the wrong answer, think, and say so; short enough that "это заметка"
// half an hour later is a fresh sentence and not a verdict on something he has
// forgotten.
const repairWindow = 5 * time.Minute
// repairMarkers — the ways he says she got it wrong. One of these must appear:
// naming an intent alone is an ordinary sentence ("напиши заметку"), and
// treating it as a correction would rewrite the last turn every time he used
// the word.
var repairMarkers = []string{
"не так поняла",
"неправильно поняла",
"ты не поняла",
"не поняла меня",
"ты ошиблась",
"это не",
"а не",
"не про то",
"got it wrong",
"not a ",
"that was wrong",
}
// repairIntents — the words he uses for each intent. Prefixes, because Russian
// declines them: "заметка", "заметку", "заметкой".
var repairIntents = []struct {
word string
intent router.Intent
say string
}{
{"заметк", router.IntentNote, "заметка"},
{"напоминани", router.IntentReminder, "напоминание"},
{"напомнить", router.IntentReminder, "напоминание"},
{"факт", router.IntentFact, "факт"},
{"вопрос", router.IntentQuery, "вопрос"},
{"команд", router.IntentAct, "команда"},
{"note", router.IntentNote, "заметка"},
{"reminder", router.IntentReminder, "напоминание"},
{"fact", router.IntentFact, "факт"},
{"question", router.IntentQuery, "вопрос"},
}
// parseRepair reads a spoken correction: a marker saying she was wrong, plus
// the intent it should have been.
//
// The negated half is skipped. "это заметка, а не напоминание" names both
// intents, and the one he is correcting TO is the one he did not put "не" in
// front of.
func parseRepair(utterance string) (router.Intent, string, bool) {
s := strings.ToLower(strings.TrimSpace(utterance))
if s == "" {
return "", "", false
}
// A leading "нет" is a marker on its own — "нет, это заметка" is the
// shortest correction he actually says. Only leading: "нет" in the middle
// of a sentence is an ordinary word.
marked := strings.HasPrefix(s, "нет") || strings.HasPrefix(s, "no,")
for _, m := range repairMarkers {
if marked || strings.Contains(s, m) {
marked = true
break
}
}
if !marked {
return "", "", false
}
best, say, at := router.Intent(""), "", -1
for _, w := range repairIntents {
i := strings.Index(s, w.word)
if i < 0 || negatedAt(s, i) {
continue
}
// Leftmost wins: "это заметка, а не напоминание" corrects to the first.
if at < 0 || i < at {
best, say, at = w.intent, w.say, i
}
}
if at < 0 {
return "", "", false
}
return best, say, true
}
// negatedAt reports whether the word at i is the one he is ruling out. Only
// the words immediately before it are read, so "не напоминание, а заметка"
// negates the first and leaves the second alone.
func negatedAt(s string, i int) bool {
before := strings.TrimSpace(s[:i])
return strings.HasSuffix(before, "не") || strings.HasSuffix(before, "not")
}
// recordTurn keeps the utterance a correction would point at. Only turns she
// acted on: a clarify asked instead of acting, so there is nothing yet to be
// wrong about.
func (h *reactiveHandler) recordTurn(utterance string, intent router.Intent) {
h.mu.Lock()
defer h.mu.Unlock()
h.lastRouted = &routedTurn{utterance: utterance, intent: intent, at: h.now()}
}
func (h *reactiveHandler) takeLastTurn() *routedTurn {
h.mu.Lock()
defer h.mu.Unlock()
last := h.lastRouted
// Taken, not read: one utterance is corrected once. Saying "нет, не так"
// twice would otherwise redo the same request twice.
h.lastRouted = nil
return last
}
// resolveRepair handles a spoken correction of the previous turn: teach the
// classifier, redo the request under the corrected intent, and say so.
func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (string, bool) {
corrected, say, ok := parseRepair(text)
if !ok || h.router == nil {
return "", false
}
last := h.takeLastTurn()
if last == nil || h.now().Sub(last.at) > repairWindow {
return "", false
}
if last.intent == corrected {
// She already did what he is asking for. Correcting the classifier
// here would teach it the label it produced, and redoing the request
// would file it twice.
return "", false
}
learned := true
if err := h.router.CorrectMisroute(ctx, last.utterance, corrected); err != nil {
// The redo is still worth doing: he asked for something and it did not
// happen. Only the learning half is lost, and he is told so.
log.Printf("voice: repair: could not learn %q as %s: %v", last.utterance, corrected, err)
learned = false
}
log.Printf("voice: repair — %q was %s, corrected to %s (learned=%v)", last.utterance, last.intent, corrected, learned)
dec := router.Decision{
Utterance: last.utterance,
Stage: 2,
Intent: corrected,
Slots: h.extractor.Extract(ctx, corrected, last.utterance, h.now()),
}
// A reminder's Text is what she says at the hour and stays empty when it
// was not spoken, so the gap is asked about rather than filled with the
// whole sentence. Everywhere else the utterance IS the payload.
if dec.Slots.Text == "" && corrected != router.IntentReminder {
dec.Slots.Text = last.utterance
}
return repairLine(say, learned) + " " + h.finishClarified(ctx, dec), true
}
// repairLine — what she says before redoing it, so the correction is visible
// and not just filed. Feminine, informal, no apology: he corrected a routing
// call, he did not complain about her.
func repairLine(say string, learned bool) string {
if !learned {
return "поняла, это " + say + " — переделываю, но запомнить поправку не вышло."
}
return "поняла, это " + say + " — запомнила."
}
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/router"
)
func TestParseRepairReadsTheCorrectedIntent(t *testing.T) {
cases := []struct {
utterance string
want router.Intent
ok bool
}{
{"нет, ты не поняла, это заметка", router.IntentNote, true},
{"нет, это заметка", router.IntentNote, true},
{"это не напоминание, а заметка", router.IntentNote, true},
{"это заметка, а не напоминание", router.IntentNote, true},
{"ты не так поняла — это факт", router.IntentFact, true},
{"неправильно поняла, это был вопрос", router.IntentQuery, true},
{"you got it wrong, that was a note", router.IntentNote, true},
// No marker: an ordinary request that happens to name an intent.
{"запиши заметку купить хлеб", "", false},
{"напомни мне про заметку", "", false},
// A marker with no intent named: nothing to correct to.
{"ты не так поняла", "", false},
{"", "", false},
}
for _, c := range cases {
got, _, ok := parseRepair(c.utterance)
if ok != c.ok || (ok && got != c.want) {
t.Errorf("parseRepair(%q) = %q,%v; want %q,%v", c.utterance, got, ok, c.want, c.ok)
}
}
}
// TestRepairTeachesTheClassifierAndRedoesTheTurn is the whole feature: the
// previous utterance is filed under the intent he named, the classifier keeps
// it as an example, and he hears that it landed.
func TestRepairTeachesTheClassifierAndRedoesTheTurn(t *testing.T) {
h, st, now := newClarifyHandler(t)
emb := router.NewHashEmbedder(256)
cls := router.NewClassifier(emb)
h.embedder = emb
h.router = router.New(router.Config{Classifier: cls, Extractor: h.extractor})
ctx := context.Background()
h.recordTurn("купить хлеб", router.IntentFact)
reply, handled := h.resolveRepair(ctx, "нет, ты не поняла, это заметка")
if !handled {
t.Fatal("a spoken correction was not handled")
}
if !strings.Contains(reply, "заметка") {
t.Errorf("the correction is not named out loud: %q", reply)
}
if strings.Contains(reply, "не вышло") {
t.Errorf("learning failed unexpectedly: %q", reply)
}
ex := cls.Examples(router.IntentNote)
if len(ex) != 1 || ex[0].Text != "купить хлеб" {
t.Fatalf("the classifier did not learn the correction: %+v", ex)
}
notes, err := st.RecentNotes(ctx, 5)
if err != nil {
t.Fatalf("recent notes: %v", err)
}
if len(notes) != 1 || !strings.Contains(notes[0].Text, "купить хлеб") {
t.Fatalf("the request was not redone as a note: %+v", notes)
}
_ = now
}
func TestRepairNeedsARecentTurnToPointAt(t *testing.T) {
h, _, now := newClarifyHandler(t)
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
ctx := context.Background()
// Nothing said yet.
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
t.Error("a correction with no previous turn was handled")
}
// Said, but long ago.
h.recordTurn("купить хлеб", router.IntentFact)
*now = now.Add(repairWindow + time.Minute)
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
t.Error("a correction outside the window was handled")
}
}
func TestRepairIsSpentOnce(t *testing.T) {
h, _, _ := newClarifyHandler(t)
emb := router.NewHashEmbedder(256)
h.embedder = emb
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
ctx := context.Background()
h.recordTurn("купить хлеб", router.IntentFact)
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled {
t.Fatal("the first correction was not handled")
}
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
t.Error("the same turn was corrected twice")
}
}
// TestRepairPassesWhenSheAlreadyDidThat — he names the intent she used. There
// is nothing to teach and redoing it would file the request a second time.
func TestRepairPassesWhenSheAlreadyDidThat(t *testing.T) {
h, _, _ := newClarifyHandler(t)
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
h.recordTurn("купить хлеб", router.IntentNote)
if _, handled := h.resolveRepair(context.Background(), "нет, это заметка"); handled {
t.Error("a correction to the intent she already used was handled")
}
}
+16
View File
@@ -161,6 +161,7 @@ type reactiveHandler struct {
// the y/n answer. ponytail: single slot, single-user box — a second act
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
mu sync.Mutex
lastRouted *routedTurn // the previous acted turn, for a spoken correction (repair.go)
pending *pendingAct
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
@@ -299,6 +300,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.
+17
View File
@@ -223,6 +223,23 @@ Not alternatives — layers:
Router contract: `[{"intent":<enum>, 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
-5
View File
@@ -15,7 +15,6 @@ require github.com/kami/hexis v0.0.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kami/praxis v0.0.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -25,8 +24,4 @@ require (
modernc.org/memory v1.11.0 // indirect
)
replace github.com/kami/praxis v0.0.0 => /home/kami/apps/praxis
replace github.com/kami/nexus v0.0.0 => /home/kami/apps/nexus
replace github.com/kami/hexis v0.0.0 => /home/kami/apps/hexis
+6
View File
@@ -2,6 +2,7 @@ package router
import (
"context"
"errors"
"log"
"time"
)
@@ -201,5 +202,10 @@ func (r *Router) gateLLMDecision(d *Decision) {
// retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over
// time, introspectable, no model surgery.
func (r *Router) CorrectMisroute(ctx context.Context, utterance string, corrected Intent) error {
if r == nil || r.classifier == nil {
// The LLM router can run with no classifier wired. The correction has
// nowhere to land then, and the caller redoes the request anyway.
return errors.New("router: no classifier to correct")
}
return r.classifier.AddExample(ctx, corrected, utterance)
}
-3
View File
@@ -15,8 +15,6 @@ github.com/google/uuid
# github.com/kami/hexis v0.0.0 => /home/kami/apps/hexis
## explicit; go 1.25.5
github.com/kami/hexis/pkg/client
# github.com/kami/praxis v0.0.0 => /home/kami/apps/praxis
## explicit; go 1.23
# github.com/mattn/go-isatty v0.0.20
## explicit; go 1.15
github.com/mattn/go-isatty
@@ -79,4 +77,3 @@ modernc.org/memory
modernc.org/sqlite
modernc.org/sqlite/lib
modernc.org/sqlite/vtab
# github.com/kami/nexus v0.0.0 => /home/kami/apps/nexus