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/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. // // 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() // 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()} } 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 + " — запомнила." }