Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a85e71077 | |||
| bf6c2bf1a6 |
@@ -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 + " — запомнила."
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
func TestParseRepairReadsTheCorrectedIntent(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance string
|
||||
want router.Intent
|
||||
ok bool
|
||||
}{
|
||||
{"нет, ты не поняла, это заметка", router.IntentNote, true},
|
||||
{"нет, это заметка", router.IntentNote, true},
|
||||
{"это не напоминание, а заметка", router.IntentNote, true},
|
||||
{"это заметка, а не напоминание", router.IntentNote, true},
|
||||
{"ты не так поняла — это факт", router.IntentFact, true},
|
||||
{"неправильно поняла, это был вопрос", router.IntentQuery, true},
|
||||
{"you got it wrong, that was a note", router.IntentNote, true},
|
||||
// No marker: an ordinary request that happens to name an intent.
|
||||
{"запиши заметку купить хлеб", "", false},
|
||||
{"напомни мне про заметку", "", false},
|
||||
// A marker with no intent named: nothing to correct to.
|
||||
{"ты не так поняла", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, _, ok := parseRepair(c.utterance)
|
||||
if ok != c.ok || (ok && got != c.want) {
|
||||
t.Errorf("parseRepair(%q) = %q,%v; want %q,%v", c.utterance, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepairTeachesTheClassifierAndRedoesTheTurn is the whole feature: the
|
||||
// previous utterance is filed under the intent he named, the classifier keeps
|
||||
// it as an example, and he hears that it landed.
|
||||
func TestRepairTeachesTheClassifierAndRedoesTheTurn(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(256)
|
||||
cls := router.NewClassifier(emb)
|
||||
h.embedder = emb
|
||||
h.router = router.New(router.Config{Classifier: cls, Extractor: h.extractor})
|
||||
ctx := context.Background()
|
||||
|
||||
h.recordTurn("купить хлеб", router.IntentFact)
|
||||
reply, handled := h.resolveRepair(ctx, "нет, ты не поняла, это заметка")
|
||||
if !handled {
|
||||
t.Fatal("a spoken correction was not handled")
|
||||
}
|
||||
if !strings.Contains(reply, "заметка") {
|
||||
t.Errorf("the correction is not named out loud: %q", reply)
|
||||
}
|
||||
if strings.Contains(reply, "не вышло") {
|
||||
t.Errorf("learning failed unexpectedly: %q", reply)
|
||||
}
|
||||
|
||||
ex := cls.Examples(router.IntentNote)
|
||||
if len(ex) != 1 || ex[0].Text != "купить хлеб" {
|
||||
t.Fatalf("the classifier did not learn the correction: %+v", ex)
|
||||
}
|
||||
notes, err := st.RecentNotes(ctx, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("recent notes: %v", err)
|
||||
}
|
||||
if len(notes) != 1 || !strings.Contains(notes[0].Text, "купить хлеб") {
|
||||
t.Fatalf("the request was not redone as a note: %+v", notes)
|
||||
}
|
||||
_ = now
|
||||
}
|
||||
|
||||
func TestRepairNeedsARecentTurnToPointAt(t *testing.T) {
|
||||
h, _, now := newClarifyHandler(t)
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||
ctx := context.Background()
|
||||
|
||||
// Nothing said yet.
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||
t.Error("a correction with no previous turn was handled")
|
||||
}
|
||||
// Said, but long ago.
|
||||
h.recordTurn("купить хлеб", router.IntentFact)
|
||||
*now = now.Add(repairWindow + time.Minute)
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||
t.Error("a correction outside the window was handled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairIsSpentOnce(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(256)
|
||||
h.embedder = emb
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
|
||||
ctx := context.Background()
|
||||
|
||||
h.recordTurn("купить хлеб", router.IntentFact)
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled {
|
||||
t.Fatal("the first correction was not handled")
|
||||
}
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||
t.Error("the same turn was corrected twice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepairPassesWhenSheAlreadyDidThat — he names the intent she used. There
|
||||
// is nothing to teach and redoing it would file the request a second time.
|
||||
func TestRepairPassesWhenSheAlreadyDidThat(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||
h.recordTurn("купить хлеб", router.IntentNote)
|
||||
if _, handled := h.resolveRepair(context.Background(), "нет, это заметка"); handled {
|
||||
t.Error("a correction to the intent she already used was handled")
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,7 @@ type reactiveHandler struct {
|
||||
// the y/n answer. ponytail: single slot, single-user box — a second act
|
||||
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
|
||||
mu sync.Mutex
|
||||
lastRouted *routedTurn // the previous acted turn, for a spoken correction (repair.go)
|
||||
pending *pendingAct
|
||||
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
|
||||
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
|
||||
@@ -299,6 +300,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
// 4d. spoken correction — "нет, это была заметка" points at the previous
|
||||
// turn and names what it should have been (repair.go). Before routing,
|
||||
// like the confirm and clarify turns: routing the correction as a fresh
|
||||
// utterance files the correction itself instead of fixing anything.
|
||||
if reply, handled := h.resolveRepair(ctx, text); handled {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
// 5. route. An elliptical follow-up — "а завтра?" — is answered from the
|
||||
// previous turn instead (continuation.go): the intent is the part it is
|
||||
// missing, so no amount of routing recovers it, and the model's guess
|
||||
@@ -359,6 +368,13 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
}
|
||||
}
|
||||
|
||||
// Remember what this turn was routed as, so the next utterance can correct
|
||||
// it. Only turns she acts on: a clarify asked instead of acting, so there
|
||||
// is nothing yet to be wrong about.
|
||||
if !dec.Clarify {
|
||||
h.recordTurn(text, dec.Intent)
|
||||
}
|
||||
|
||||
// 8. action — execute the decision's intent. errors here surface as
|
||||
// short reply text (the user wants to know the action didn't land);
|
||||
// the round-trip stays alive.
|
||||
|
||||
@@ -223,6 +223,23 @@ Not alternatives — layers:
|
||||
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
||||
7 intents (`fact, reminder, note, query, act, chat, system`).
|
||||
|
||||
#### Saying she got it wrong is a feature
|
||||
|
||||
Landed 2026-08-04 (Vikunja #455). `Router.CorrectMisroute` could always append a
|
||||
corrected utterance as a new classifier example, and until now nothing in the
|
||||
daemon called it, so the mechanism existed and the behaviour did not.
|
||||
|
||||
`cmd/mavend/repair.go` reaches it. He says she got it wrong and names what it
|
||||
should have been — "нет, это заметка", "это не напоминание, а факт" — and three
|
||||
things happen in one turn: the classifier learns the utterance under the named
|
||||
intent, the request is redone under it, and she says the correction landed. The
|
||||
utterance he is correcting TO is the one with no "не" in front of it.
|
||||
|
||||
Read before routing, next to the confirm and clarify turns, because a correction
|
||||
routed as a fresh utterance files the correction itself. One turn is correctable
|
||||
once, inside five minutes, and only turns she acted on — a clarify asked instead
|
||||
of acting, so there is nothing yet to be wrong about.
|
||||
|
||||
#### A restart expires a parked question
|
||||
|
||||
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
|
||||
|
||||
@@ -2,6 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
@@ -201,5 +202,10 @@ func (r *Router) gateLLMDecision(d *Decision) {
|
||||
// retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over
|
||||
// time, introspectable, no model surgery.
|
||||
func (r *Router) CorrectMisroute(ctx context.Context, utterance string, corrected Intent) error {
|
||||
if r == nil || r.classifier == nil {
|
||||
// The LLM router can run with no classifier wired. The correction has
|
||||
// nowhere to land then, and the caller redoes the request anyway.
|
||||
return errors.New("router: no classifier to correct")
|
||||
}
|
||||
return r.classifier.AddExample(ctx, corrected, utterance)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user