8015fdbb79
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.
355 lines
14 KiB
Go
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 ©, 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)
|
|
}
|
|
}
|