From 7d08d27efb679d9d47421d79be5503c60028fe21 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 22:09:05 +0400 Subject: [PATCH] =?UTF-8?q?mavend:=20answer=20"=D0=B0=20=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D1=80=D0=B0=3F"=20from=20the=20previous=20turn,=20not=20?= =?UTF-8?q?from=20the=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An elliptical follow-up carries no intent of its own. followUpMerge cannot help — it inherits slots once the intent is known, and here the intent is the missing part. So "а завтра?" went to the router, which on a 1.7B is close to a coin flip, and the guess cost ~2.7s. continuationDecision runs before the router and rebuilds the turn from the previous one: same intent, same key, new day. Deterministic and free. Three guards, all narrow on purpose. A parseable date is required, which is what separates an ellipsis from an ordinary short utterance. Four tokens max. And only query, system and reminder may be inherited: fact and note would write something he did not say, and act would let a two-word utterance re-run an allowlisted fn, which is a way to fire a destructive command nobody typed. A continuation is still remembered, so "а завтра?" then "а послезавтра?" chains. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX --- cmd/mavend/continuation.go | 118 ++++++++++++++++++++++++++++++++ cmd/mavend/continuation_test.go | 100 +++++++++++++++++++++++++++ cmd/mavend/voice.go | 32 +++++++-- 3 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 cmd/mavend/continuation.go create mode 100644 cmd/mavend/continuation_test.go diff --git a/cmd/mavend/continuation.go b/cmd/mavend/continuation.go new file mode 100644 index 0000000..83a1896 --- /dev/null +++ b/cmd/mavend/continuation.go @@ -0,0 +1,118 @@ +// Elliptical follow-ups — "а завтра?" after "какие напоминания на сегодня". +// +// These carry no intent of their own. Two words, one of them a particle, and +// everything that makes the utterance meaningful lives in the turn before it. +// Sent to the router they get whatever the model guesses, which on a 1.7B is +// close to a coin flip, and the guess costs ~2.7s to obtain. +// +// followUpMerge (followup.go) cannot help: it inherits SLOTS once the intent is +// known, and here the intent is the missing part. So this runs before the +// router and answers from the previous turn directly, which is both correct by +// construction and free. +package main + +import ( + "time" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/router" +) + +// continuationMaxTokens — an ellipsis is short by definition. Past four tokens +// the utterance carries enough of its own content to be routed on its merits, +// and inheriting an intent for it would be overreach. +const continuationMaxTokens = 4 + +// continuationParticles — the words that open a follow-up. A leading particle +// is one of the two ways in; the other is an utterance that is nothing but a +// date ("завтра?"). +var continuationParticles = map[string]bool{ + "а": true, "и": true, "ну": true, + "what": true, "and": true, "how": true, +} + +// continuableIntents — which intents an ellipsis may inherit. +// +// query and system are questions: asking the same question about a different +// day is exactly what "а завтра?" means. reminder is an instruction that names +// a time, so re-aiming it at another day is a coherent second instruction. +// +// The rest are excluded on purpose. fact and note would write something he did +// not say — "поужинал" then "а вчера?" is a question about yesterday, not a +// claim about it. chat has no slot to re-aim. act is the dangerous one: an +// allowlisted fn inherited by a two-word utterance is a way to run a +// destructive command nobody typed, and no follow-up is worth that. +var continuableIntents = map[dialogue.Intent]bool{ + dialogue.IntentQuery: true, + dialogue.IntentSystem: true, + dialogue.IntentReminder: true, +} + +// continuationDecision reads an utterance as "the previous question, but for +// this other day". Returns ok=false whenever anything is uncertain, which +// hands the turn back to the ordinary router path. +// +// The date is what makes this safe. An ellipsis with no parseable day is just +// a short utterance, and short utterances are the router's job. +func continuationDecision(prev *dialogue.Session, text string, now time.Time) (router.Decision, bool) { + if prev == nil || prev.IsExpired(now) || !continuableIntents[prev.Intent] { + return router.Decision{}, false + } + tokens := quietTokens(text) + if len(tokens) == 0 || len(tokens) > continuationMaxTokens { + return router.Decision{}, false + } + day, ok := router.ParseCalendarDate(text, now) + if !ok { + return router.Decision{}, false + } + // Either it opens with a particle, or the whole utterance is the date. + if !continuationParticles[tokens[0]] && !isBareDate(tokens, day, now) { + return router.Decision{}, false + } + + dec := router.Decision{ + Utterance: text, + Intent: router.Intent(prev.Intent), + Confidence: 1.0, + Stage: 0, + Slots: router.Slots{ + Key: prev.Slots.Key, + HasKey: prev.Slots.HasKey, + Value: prev.Slots.Value, + Text: prev.Slots.Text, + // Fn/Args are deliberately not carried: continuableIntents + // excludes act, so there is never one to carry. + Time: day, + HasTime: true, + }, + } + return dec, true +} + +// isBareDate reports whether the utterance is nothing but its date expression. +// "завтра" and "на выходных" qualify; "напомни завтра" does not, because the +// verb is content of its own and belongs to the router. +// +// Implemented by re-parsing each token: if every token that is not part of a +// date expression is a preposition or a question mark's leftovers, the +// utterance is bare. Cheap enough at four tokens. +func isBareDate(tokens []string, day time.Time, now time.Time) bool { + for _, t := range tokens { + if continuationFillers[t] { + continue + } + if d, ok := router.ParseCalendarDate(t, now); ok && d.Equal(day) { + continue + } + return false + } + return true +} + +// continuationFillers — tokens that carry no content of their own inside a +// date expression ("на выходных", "в среду"). +var continuationFillers = map[string]bool{ + "на": true, "в": true, "во": true, "за": true, "про": true, + "about": true, "on": true, "for": true, +} diff --git a/cmd/mavend/continuation_test.go b/cmd/mavend/continuation_test.go new file mode 100644 index 0000000..674dc03 --- /dev/null +++ b/cmd/mavend/continuation_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "testing" + "time" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/router" +) + +var contNow = time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + +func contSession(intent dialogue.Intent, key string) *dialogue.Session { + return &dialogue.Session{ + Intent: intent, + Slots: dialogue.Slots{Key: key, HasKey: key != "", Text: "какие напоминания на сегодня"}, + Timestamp: contNow.Add(-30 * time.Second), + TTL: 2 * time.Minute, + } +} + +func TestContinuationInheritsTheQuestion(t *testing.T) { + prev := contSession(dialogue.IntentQuery, "water") + dec, ok := continuationDecision(prev, "а завтра?", contNow) + if !ok { + t.Fatal("continuationDecision returned false, want a decision") + } + if dec.Intent != router.IntentQuery { + t.Errorf("intent = %q, want query", dec.Intent) + } + if dec.Slots.Key != "water" || !dec.Slots.HasKey { + t.Errorf("key = %q, want water carried over", dec.Slots.Key) + } + if !dec.Slots.HasTime { + t.Fatal("no time slot; the whole point is re-aiming the day") + } + if got, want := dec.Slots.Time.Format("2006-01-02"), "2026-08-02"; got != want { + t.Errorf("time = %s, want %s", got, want) + } +} + +func TestContinuationAcceptsABareDate(t *testing.T) { + prev := contSession(dialogue.IntentQuery, "water") + for _, s := range []string{"завтра?", "вчера", "а вчера?", "и завтра"} { + if _, ok := continuationDecision(prev, s, contNow); !ok { + t.Errorf("continuationDecision(%q) = false, want true", s) + } + } +} + +func TestContinuationDeclinesWhatIsNotAnEllipsis(t *testing.T) { + prev := contSession(dialogue.IntentQuery, "water") + for _, s := range []string{ + // No date to re-aim at — an ordinary short utterance, the router's job. + "а что там", "а бэкап?", "привет", "", + // Content of its own: the verb is not an ellipsis. + "напомни завтра позвонить маме", + // Too long to be an ellipsis even with a date in it. + "а что у меня стоит в календаре на завтра", + } { + if _, ok := continuationDecision(prev, s, contNow); ok { + t.Errorf("continuationDecision(%q) = true, want false", s) + } + } +} + +func TestContinuationDeclinesUncontinuableIntents(t *testing.T) { + // act is the one that matters: inheriting an allowlisted fn from a + // two-word utterance would be a way to run a destructive command. + for _, in := range []dialogue.Intent{ + dialogue.IntentAct, dialogue.IntentFact, dialogue.IntentNote, dialogue.IntentChat, + } { + if _, ok := continuationDecision(contSession(in, "water"), "а завтра?", contNow); ok { + t.Errorf("continuationDecision inherited intent %q, want refusal", in) + } + } +} + +func TestContinuationDeclinesWithoutALiveSession(t *testing.T) { + if _, ok := continuationDecision(nil, "а завтра?", contNow); ok { + t.Error("continued with no previous turn") + } + stale := contSession(dialogue.IntentQuery, "water") + stale.Timestamp = contNow.Add(-10 * time.Minute) + if _, ok := continuationDecision(stale, "а завтра?", contNow); ok { + t.Error("continued an expired session") + } +} + +func TestContinuationNeverCarriesAnFn(t *testing.T) { + prev := contSession(dialogue.IntentQuery, "water") + prev.Slots.Fn, prev.Slots.HasFn = "restart", true + dec, ok := continuationDecision(prev, "а завтра?", contNow) + if !ok { + t.Fatal("want a decision") + } + if dec.Slots.HasFn || dec.Slots.Fn != "" { + t.Fatalf("carried fn %q into a continuation", dec.Slots.Fn) + } +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index b95bc35..1428ef1 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -265,8 +265,27 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour return withNotice(expiredNotice, reply) } - // 5. router — classify the utterance. - dec, err := h.router.Route(ctx, text, h.now()) + // 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 + // costs seconds to obtain and is close to a coin flip. Everything else + // goes to the router. + var ( + dec router.Decision + err error + prev *dialogue.Session + ) + now := h.now() + if h.dialogueSessions != nil { + prev = h.dialogueSessions.Get(voiceDialogueID, now) + } + cont := false + if dec, cont = continuationDecision(prev, text, now); cont { + log.Printf("voice: continuation of %s from the previous turn", dec.Intent) + } + if !cont { + dec, err = h.router.Route(ctx, text, now) + } if err != nil { // ErrNoIntents ⇒ classifier unseeded (cold boot). reply with a // "still warming up" rather than a wire error. @@ -282,10 +301,13 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour // turn (follow-ups like «напомни завтра» → «…позвонить маме»), then remember // this turn for the next follow-up. Only same-intent, non-expired, non- // clarify turns carry (see followUpMerge). Best-effort: nil store ⇒ skipped. + // A continuation already carries the previous turn's slots, so there is + // nothing left to inherit — but it is still remembered, so a chain of them + // ("а завтра?" … "а послезавтра?") keeps working. if h.dialogueSessions != nil { - now := h.now() - prev := h.dialogueSessions.Get(voiceDialogueID, now) - dec = followUpMerge(prev, dec, now) + if !cont { + dec = followUpMerge(prev, dec, now) + } if !dec.Clarify { h.rememberTurn(prev, dec, now) }