Files
Maven/cmd/mavend/repair.go
T
claude 8015fdbb79 Harden semantic boundaries and repair dialogue state
Replace nearest-neighbour personal routing with a frozen class-balanced linear head measured on historical, stratified, cross-validation, holdout, and fresh challenge gates (V-702). Close the four repair handoff holes, preserve nested clarification flows, and route Russian possession statements through structural grammar rather than lexical exceptions (V-573). Owner explicitly requested direct commits to master.
2026-08-13 03:00:31 +04:00

355 lines
14 KiB
Go

package main
import (
"context"
"log"
"strings"
"time"
"unicode"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
"github.com/kami/maven/internal/phraser"
"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
// traceID — the persisted trace of this turn, stamped after the fact by
// stampLastTurn. 0 when nothing persisted, and then a spoken correction
// still teaches the classifier: the durable label is the half that needs a
// row to point at (V-636).
traceID int64
}
// 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.
//
// From the lexicon, and staying a list rather than becoming seeds (Vikunja
// #528). This runs pre-route, before the turn vector exists, and a correction
// redoes the previous request — so a near-miss would act on something he never
// said. The set's note in lexicon_ru_v1.json carries the same reasoning.
var repairMarkers = lexicon.RepairMarkers()
// repairNegatives — "she got it wrong" with no target. Matched against the whole
// utterance, because these are complete sentences and the markers above are
// fragments: "это не" needs an intent word after it, "не так поняла" does not.
// Substring matching here would claim "не так" out of any sentence containing it
// (V-636).
var repairNegatives = lexicon.RepairNegatives()
// repairIntents — the words he uses for each intent, as dictionary forms. They
// used to be prefixes ("заметк"), which is what a prefix list costs: "команд"
// also matched "командировка", and "факт" matched "фактически". morph.SameWord
// compares the words themselves (Vikunja #528).
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
}
// Over tokens, not byte offsets. The negation test used to read the string
// immediately before a match, which meant it could only see "не" spelled
// exactly there; a token list makes the previous word plain to read.
toks := repairTokens(s)
best, say, at := router.Intent(""), "", -1
for i, tok := range toks {
if at >= 0 && i > at {
break
}
for _, w := range repairIntents {
if !morph.SameWord(tok, w.word) {
continue
}
if i > 0 && (toks[i-1] == "не" || toks[i-1] == "not") {
// The one he is ruling out: "не напоминание, а заметка".
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
}
// repairTokens splits a correction into lowercase word tokens. Punctuation goes,
// because "это заметка, а не напоминание" glues a comma to the word the negation
// test has to look past.
func repairTokens(s string) []string {
return strings.FieldsFunc(s, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
// 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()}
}
// stampLastTurn attaches the trace id to the turn a correction would point at.
// It cannot be done in recordTurn: the trace is written when the turn ends, and
// recordTurn runs in the middle of it.
func (h *reactiveHandler) stampLastTurn(utterance string, traceID int64) {
h.mu.Lock()
defer h.mu.Unlock()
if h.lastRouted == nil || h.lastRouted.utterance != utterance {
return
}
h.lastRouted.traceID = traceID
}
// takeLastTurnIf atomically claims the previous acted turn only when the
// caller can actually handle it. A declined repair must not spend the pointer:
// "нет, это заметка" may name the intent Maven already chose and be followed
// immediately by the real correction. The older read-then-clear helper lost
// the original before checking either that case or the repair window (V-573).
func (h *reactiveHandler) takeLastTurnIf(accept func(*routedTurn) bool) *routedTurn {
h.mu.Lock()
defer h.mu.Unlock()
if h.lastRouted == nil || !accept(h.lastRouted) {
return nil
}
last := *h.lastRouted
// A handled correction is still spent once. Returning a copy prevents a
// later trace stamp from mutating the evidence after this resolver owns it.
h.lastRouted = nil
return &last
}
// takeTargetedRepair atomically distinguishes the three outcomes a targeted
// correction needs. A recent, differently-routed turn is claimed and spent; a
// recent turn already carrying that intent is retained and reported as
// already-correct; everything else declines. Treating the second case as a
// generic decline lets runTurn route the correction words as a fresh turn and
// record them over the very pointer this helper was meant to preserve.
func (h *reactiveHandler) takeTargetedRepair(now time.Time, corrected router.Intent) (last *routedTurn, already bool) {
h.mu.Lock()
defer h.mu.Unlock()
if h.lastRouted == nil || now.Sub(h.lastRouted.at) > repairWindow {
return nil, false
}
if h.lastRouted.intent == corrected {
return nil, true
}
copy := *h.lastRouted
h.lastRouted = nil
return &copy, false
}
// suspendClarifyForRepair makes a correction an aside to any question already
// parked in this dialogue. It is called only after a repair has actually found
// a target, so an ordinary utterance that merely resembles one changes no
// dialogue state. If the redo itself needs a question, askClarify sees the
// suspended flag and pushes that question instead of overwriting the older
// request.
func (h *reactiveHandler) suspendClarifyForRepair(ctx context.Context) {
if h.clarifyStore == nil {
return
}
if q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now()); q != nil {
h.noteSuspended(ctx, q)
}
}
// resolveUntargetedRepair handles the cheap half of a spoken correction: he says
// she got it wrong and does not say what it should have been (V-636).
//
// It is worth having on its own. V-630 made the target optional on the web for
// the same reason: a turn marked wrong with no target is a usable negative, and
// requiring the target would cost the correction he was willing to give. Voice
// needs it more than the web does — naming an intent aloud means saying
// "заметка" or "факт", which is Maven's vocabulary and not his.
//
// Nothing is redone and the classifier is not taught. There is no target, so
// there is nothing to redo it as and nothing to teach. Only the label is written,
// and she says so, because a correction he cannot see reads as one that was
// dropped.
func (h *reactiveHandler) resolveUntargetedRepair(ctx context.Context, text string) (string, bool) {
if !isRepairNegative(text) {
return "", false
}
now := h.now()
last := h.takeLastTurnIf(func(last *routedTurn) bool {
return now.Sub(last.at) <= repairWindow && last.traceID != 0
})
if last == nil {
// No row to point at, so there is no label to write and nothing this
// resolver can do. Routing the words normally is the honest outcome.
return "", false
}
h.suspendClarifyForRepair(ctx)
h.labelCorrection(ctx, last, "")
log.Printf("voice: repair — %q marked wrong, no target given", last.utterance)
return phraser.A(phraser.RepairNoted, nil), true
}
// isRepairNegative matches the whole utterance, minus a leading "нет" and any
// trailing punctuation. "нет, не так" is the shortest one he says.
func isRepairNegative(utterance string) bool {
s := strings.ToLower(strings.TrimSpace(utterance))
s = strings.TrimRight(s, " .!?")
for _, p := range []string{"нет,", "нет", "no,", "no"} {
if rest := strings.TrimSpace(strings.TrimPrefix(s, p)); rest != s && rest != "" {
s = rest
break
}
}
for _, n := range repairNegatives {
if s == n {
return true
}
}
return false
}
// 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, already := h.takeTargetedRepair(h.now(), corrected)
if already {
// This is still a correction turn, not slot material and not a fresh note.
// Say why nothing ran, retain the original pointer, and keep any parked
// question audible for the next breath.
h.suspendClarifyForRepair(ctx)
return "это уже " + say + " — ничего не переделываю.", true
}
if last == nil {
// Nothing recent to correct. Routing the words normally is the honest
// outcome; an expired pointer cannot become usable again.
return "", false
}
h.suspendClarifyForRepair(ctx)
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)
h.labelCorrection(ctx, last, string(corrected))
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.finishRepaired(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 + " — запомнила."
}
// labelCorrection promotes a spoken correction into routing_labels, the same
// table the /chat gesture writes (V-630, V-636).
//
// Two sinks and not one, because they keep different things. CorrectMisroute
// appends a classifier seed, which is what makes the NEXT turn better today.
// The label is what a fitted head trains on later, it survives the 14-day
// transcript, and until now only the web produced any. A sample that only ever
// held typed turns would skew to whatever he happens to be at a keyboard for,
// and voice is where the hard cases are.
//
// Best-effort and silent. He has already been told the correction landed, and a
// second sink failing is not his problem to hear about.
func (h *reactiveHandler) labelCorrection(ctx context.Context, last *routedTurn, shouldBe string) {
if h.api == nil || last == nil || last.traceID == 0 {
return
}
if err := h.api.CorrectTurn(ctx, last.traceID, shouldBe); err != nil {
log.Printf("voice: repair: could not label trace %d: %v", last.traceID, err)
}
}