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