3f98a99f44
Deployed check, second round: "привет" after "какой сегодня день" answered with the date. followUpMerge fills an empty Text from the previous same-intent turn, so the topic-widening added a minute earlier was reading an inherited topic on turns that had nothing to do with it. Decision gains Continued, set only by continuation.go and never by the router. replySystem widens on that and nothing else, so an inherited Text is back to being invisible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
126 lines
4.9 KiB
Go
126 lines
4.9 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, and re-aiming the Time slot answers it
|
||
// completely.
|
||
//
|
||
// 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.
|
||
//
|
||
// reminder was in this list and came out after a live check on 01-08-2026. A
|
||
// reminder's payload is its Text, and the Text embeds the day word it was
|
||
// created with: continuing "напомни сегодня о событиях" with "а завтра?" fires
|
||
// tomorrow with the text still reading "сегодня". Re-aiming Time is not enough
|
||
// when the day is also written into the payload, and rewriting the payload
|
||
// needs the date's span in the string, which ParseCalendarDate does not report.
|
||
var continuableIntents = map[dialogue.Intent]bool{
|
||
dialogue.IntentQuery: true,
|
||
dialogue.IntentSystem: 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,
|
||
Continued: true,
|
||
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,
|
||
}
|