// 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, }