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) }