Files
Maven/cmd/mavend/continuation.go
T
kami 7d08d27efb 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
2026-08-01 22:09:05 +04:00

119 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
}