mavend: answer "а завтра?" from the previous turn, not from the model

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
kami
2026-08-01 22:09:05 +04:00
parent d0d0021659
commit 7d08d27efb
3 changed files with 245 additions and 5 deletions
+118
View File
@@ -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,
}
+100
View File
@@ -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)
}
}
+27 -5
View File
@@ -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)
}