Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bbffa4f37 | |||
| 8b76dc50d3 | |||
| a1324e679f | |||
| 82ef1b0110 | |||
| 897dcf847a | |||
| 8b9e8e9f4e | |||
| b338d9bb40 | |||
| 6878e12d37 | |||
| 8d46ee39e0 | |||
| 6eba79b332 | |||
| 3f60ec3994 | |||
| 14ea06712e | |||
| 42d3feadd2 | |||
| 2a2f706b74 | |||
| e082e06868 | |||
| 88dc4e1383 | |||
| 60759a991e | |||
| 9537346441 | |||
| c6be818f13 | |||
| b5575a9402 | |||
| fcda5e3d2c | |||
| bdafc82e35 | |||
| c69023c310 | |||
| 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
|
// the y/n answer. ponytail: single slot, single-user box — a second act
|
||||||
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
|
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
lastRouted *routedTurn // the previous acted turn, for a spoken correction (repair.go)
|
||||||
pending *pendingAct
|
pending *pendingAct
|
||||||
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
|
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
|
||||||
pendingHexis *pendingHexisExec // mutating Hexis capability 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)
|
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
|
// 5. route. An elliptical follow-up — "а завтра?" — is answered from the
|
||||||
// previous turn instead (continuation.go): the intent is the part it is
|
// 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
|
// 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
|
// 8. action — execute the decision's intent. errors here surface as
|
||||||
// short reply text (the user wants to know the action didn't land);
|
// short reply text (the user wants to know the action didn't land);
|
||||||
// the round-trip stays alive.
|
// the round-trip stays alive.
|
||||||
|
|||||||
@@ -223,6 +223,23 @@ Not alternatives — layers:
|
|||||||
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
||||||
7 intents (`fact, reminder, note, query, act, chat, system`).
|
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
|
#### A restart expires a parked question
|
||||||
|
|
||||||
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
|
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -201,5 +202,10 @@ func (r *Router) gateLLMDecision(d *Decision) {
|
|||||||
// retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over
|
// retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over
|
||||||
// time, introspectable, no model surgery.
|
// time, introspectable, no model surgery.
|
||||||
func (r *Router) CorrectMisroute(ctx context.Context, utterance string, corrected Intent) error {
|
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)
|
return r.classifier.AddExample(ctx, corrected, utterance)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user