7d08d27efb
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
119 lines
4.5 KiB
Go
119 lines
4.5 KiB
Go
// 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,
|
||
}
|