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 } 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 } // 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 } last := h.takeLastTurn() if last == nil || h.now().Sub(last.at) > repairWindow { return "", false } if last.traceID == 0 { // 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.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 := 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) 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.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 + " — запомнила." } // 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) } }