8015fdbb79
Replace nearest-neighbour personal routing with a frozen class-balanced linear head measured on historical, stratified, cross-validation, holdout, and fresh challenge gates (V-702). Close the four repair handoff holes, preserve nested clarification flows, and route Russian possession statements through structural grammar rather than lexical exceptions (V-573). Owner explicitly requested direct commits to master.
715 lines
32 KiB
Go
715 lines
32 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"log"
|
||
"math/rand"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/kami/maven/internal/dialogue"
|
||
"github.com/kami/maven/internal/router"
|
||
)
|
||
|
||
// clarifyTTL — how long a parked question stays answerable. Same 90s as the
|
||
// confirm gate, for the same reason: an answer is a same-breath gesture, and a
|
||
// stale question must not eat an unrelated later utterance.
|
||
const clarifyTTL = 90 * time.Second
|
||
|
||
// wantedSlots — what each intent needs before she can act on it. First entry is
|
||
// the one she asks about this turn; the rest are asked about on later turns, one
|
||
// per turn, as each answer lands (see askRemainingGap).
|
||
//
|
||
// Intents not listed here are never worth a question: note and query act on the
|
||
// raw utterance, chat and system have nothing to fill in. For those a clarify
|
||
// decision keeps the canned "не поняла" reply — inventing a question for noise
|
||
// is worse than admitting she missed it.
|
||
// A reminder wants BOTH what to remind about and when. Subject first: "напомни
|
||
// в 11" has a time and nothing to say at 11, and a reminder with no subject is
|
||
// not worth setting. Order here is the order she asks in.
|
||
var wantedSlots = map[router.Intent][]dialogue.Slot{
|
||
router.IntentReminder: {dialogue.SlotText, dialogue.SlotTime},
|
||
router.IntentFact: {dialogue.SlotKey},
|
||
router.IntentAct: {dialogue.SlotFn},
|
||
}
|
||
|
||
// The questions themselves live in clarifytemplates.go, one list per slot,
|
||
// picked by attempt (Vikunja #457). The first ask is the short one this map
|
||
// used to hold; a re-ask says it differently, because a question he already
|
||
// failed to answer is the worst one to repeat unchanged.
|
||
|
||
// clarifyGaveUp — she is out of questions and still does not have the slot. She
|
||
// says so out loud: dropping the request in silence would leave him thinking it
|
||
// landed. Feminine self-reference ("поняла"), as everywhere.
|
||
const clarifyGaveUp = "Прости, я не поняла. Скажи, пожалуйста, по-другому."
|
||
|
||
// clarifyExpiredVariants — his answer came after the TTL, so the parked request
|
||
// is already gone. Same tone as clarifyGaveUp, different reason: too much time
|
||
// passed, not "I did not understand". Feminine self-reference ("ждала",
|
||
// "отпустила"); he is addressed with a plain imperative.
|
||
//
|
||
// Five phrasings, not one. This is the line he hears whenever he walks off
|
||
// mid-request, so it is the line that repeats most — and the same sentence every
|
||
// time is what makes a house assistant sound like a kiosk. They all carry the
|
||
// same two facts (the old request is gone; say it again if it still matters),
|
||
// because the wording may vary and the meaning may not.
|
||
//
|
||
// Fixed templates rather than model output, for the same reason as
|
||
// clarifyQuestions: this text has to be right every time, and it is not worth a
|
||
// generation to say something this small.
|
||
var clarifyExpiredVariants = []string{
|
||
"Прости, я слишком долго ждала ответа и отпустила прошлую просьбу. Если она ещё нужна, скажи заново.",
|
||
"Кажется, прошлая просьба уже не важна — я её отпустила. Если я ошибаюсь, повтори.",
|
||
"Ты как-то резко замолчал, и я не стала ждать дальше. Если та просьба ещё нужна, скажи заново.",
|
||
"Я не дождалась ответа и убрала прошлую просьбу. Повтори, если она всё ещё нужна.",
|
||
"Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.",
|
||
}
|
||
|
||
// clarifyExpiredPluralVariants — the same notice when TWO parked requests died
|
||
// together (Vikunja #561). Since a side query suspends the flow instead of
|
||
// dropping it, the stack can hold both the flow and the thing he interrupted it
|
||
// with, and TakeExpired drops the whole stack when the top times out. "Прошлую
|
||
// просьбу" would then be a lie about the count: he loses two and hears about
|
||
// one.
|
||
//
|
||
// Two phrasings only, against five for the singular. This fires when he walks
|
||
// off in the middle of an interrupted exchange, which is rarer than walking off
|
||
// in the middle of a plain one, so it repeats less and needs less variety.
|
||
var clarifyExpiredPluralVariants = []string{
|
||
"Прости, я слишком долго ждала и отпустила обе прошлые просьбы. Если они ещё нужны, скажи заново.",
|
||
"Я не дождалась ответа и убрала обе прошлые просьбы. Повтори, если они всё ещё нужны.",
|
||
}
|
||
|
||
// clarifyExpiredLine picks one of them at random. n is how many requests died;
|
||
// anything above one gets the plural wording, because the bound is two today and
|
||
// a third would still be "обе" short of the truth only if MaxStackDepth grew.
|
||
func clarifyExpiredLine(n int) string {
|
||
if n > 1 {
|
||
return clarifyExpiredPluralVariants[rand.Intn(len(clarifyExpiredPluralVariants))]
|
||
}
|
||
return clarifyExpiredVariants[rand.Intn(len(clarifyExpiredVariants))]
|
||
}
|
||
|
||
// isClarifyExpired reports whether s opens with any of the expiry lines. The
|
||
// notice is glued in front of this turn's reply (see withNotice), so a caller
|
||
// checking for it has to match a prefix, not the whole string.
|
||
func isClarifyExpired(s string) bool {
|
||
_, ok := cutClarifyExpired(s)
|
||
return ok
|
||
}
|
||
|
||
// trimClarifyExpired strips a leading expiry notice, leaving this turn's actual
|
||
// reply. "" ⇒ the notice was the whole thing.
|
||
func trimClarifyExpired(s string) string {
|
||
rest, ok := cutClarifyExpired(s)
|
||
if !ok {
|
||
return strings.TrimSpace(s)
|
||
}
|
||
return rest
|
||
}
|
||
|
||
// cutClarifyExpired matches either expiry deck as a prefix and returns what
|
||
// follows it. Both decks, since V-561 added the plural line: a caller asking
|
||
// "did she say a request timed out" means the fact, not which wording carried
|
||
// it, and a helper that knew only the singular would read the two-request
|
||
// notice as ordinary reply text.
|
||
func cutClarifyExpired(s string) (string, bool) {
|
||
for _, deck := range [][]string{clarifyExpiredVariants, clarifyExpiredPluralVariants} {
|
||
for _, v := range deck {
|
||
if strings.HasPrefix(s, v) {
|
||
return strings.TrimSpace(strings.TrimPrefix(s, v)), true
|
||
}
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// clarifyExpiredNotice returns that line when a parked question had just timed
|
||
// out, and "" when nothing was parked. Call it right after
|
||
// resolveClarifyAnswer: a live question is answered there, an expired one is
|
||
// only reported here — the words themselves still go on to be routed fresh.
|
||
func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string {
|
||
if h.clarifyStore == nil {
|
||
return ""
|
||
}
|
||
n := h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now())
|
||
if n == 0 {
|
||
return ""
|
||
}
|
||
log.Printf("voice: clarify — %d parked question(s) expired, telling him and routing the words fresh", n)
|
||
return clarifyExpiredLine(n)
|
||
}
|
||
|
||
// withNotice glues the expiry notice in front of this turn's reply. One turn
|
||
// carries one reply on the wire, so the notice cannot be a message of its own —
|
||
// but neither the notice nor the fresh answer may be dropped.
|
||
func withNotice(notice, reply string) string {
|
||
if notice == "" {
|
||
return reply
|
||
}
|
||
if reply == "" {
|
||
return notice
|
||
}
|
||
return notice + " " + reply
|
||
}
|
||
|
||
// withResumed puts the resumed question on the END of this turn's reply, where
|
||
// withNotice puts the expiry notice on the front (Vikunja #561).
|
||
//
|
||
// The order is the owner's: "в Риме сейчас ..., на какое время поставить
|
||
// напоминание?" — answer first, then the open question. A question in front of
|
||
// its own answer would read as ignoring what he asked.
|
||
//
|
||
// Two sentences, not one (V-654). This used to fold the answer's full stop into
|
||
// a comma, on the strength of the owner having written it that way once. Spliced
|
||
// onto a real answer it reads as one run-on thought — "вот что я нашла: вайфай
|
||
// пароль лежит в ящике стола, на какое время поставить напоминание?" — and the
|
||
// question disappears into the tail of a sentence about something else. A reply
|
||
// with no terminator of its own is given one, so the join never depends on how
|
||
// the phraser chose to end.
|
||
//
|
||
// A resume with no answer in front of it is just the question.
|
||
func withResumed(reply, resumed string) string {
|
||
if resumed == "" {
|
||
return reply
|
||
}
|
||
reply = strings.TrimSpace(reply)
|
||
if reply == "" {
|
||
return resumed
|
||
}
|
||
if !endsSentence(reply) {
|
||
reply += "."
|
||
}
|
||
return reply + " " + resumed
|
||
}
|
||
|
||
// endsSentence reports whether s already closes itself. The ellipsis counts: a
|
||
// trailing "…" is a deliberate end, and a full stop after it reads as a typo.
|
||
func endsSentence(s string) bool {
|
||
r, _ := utf8.DecodeLastRuneInString(s)
|
||
return strings.ContainsRune(".!?…", r)
|
||
}
|
||
|
||
// missingFor returns the slots a decision still needs, most important first.
|
||
// Empty ⇒ there is nothing identifiable to ask about.
|
||
func missingFor(dec router.Decision) []dialogue.Slot {
|
||
return stillMissingFor(dec.Intent, dec.Utterance, toDialogueSlots(dec.Slots))
|
||
}
|
||
|
||
// clarifyQuestion picks the one question to ask for a clarify decision. Returns
|
||
// ("", false) when she has no idea what is missing.
|
||
//
|
||
// One question about one thing: if two slots are missing she asks about the
|
||
// first only. Two questions in one breath is an interrogation. The second gap
|
||
// is picked up on the turn after the first one is answered (askRemainingGap).
|
||
func (h *reactiveHandler) clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||
missing := missingFor(dec)
|
||
if len(missing) == 0 {
|
||
return "", "", false
|
||
}
|
||
q, ok := h.questionFor(missing[0], 1, dec.Utterance, toDialogueSlots(dec.Slots), "")
|
||
if !ok {
|
||
return "", "", false
|
||
}
|
||
return missing[0], q, true
|
||
}
|
||
|
||
// questionFor picks the wording for one gap. Every slot but the reminder's time
|
||
// reads its deck by attempt; the time asks about whichever of the hour, the half
|
||
// of the day and the day he has not said, and states the clock while it does
|
||
// (V-579).
|
||
//
|
||
// taken is the acknowledgement of what his last turn added, empty when it added
|
||
// nothing and empty for a first ask, which has no turn behind it (V-593).
|
||
func (h *reactiveHandler) questionFor(slot dialogue.Slot, attempt int, utterance string, s dialogue.Slots, taken string) (string, bool) {
|
||
if slot != dialogue.SlotTime {
|
||
return clarifyQuestionFor(slot, attempt)
|
||
}
|
||
return whenQuestion(whenGapOf(utterance, s.HasTime), attempt, h.now(), taken)
|
||
}
|
||
|
||
// askClarify parks the request and returns the question to ask instead of the
|
||
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
|
||
// the caller falls back to the canned reply.
|
||
func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (string, bool) {
|
||
if h.clarifyStore == nil {
|
||
return "", false
|
||
}
|
||
slot, question, ok := h.clarifyQuestion(dec)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
// An act she could not resolve is a refusal, not a question (Vikunja #556).
|
||
if slot == dialogue.SlotFn {
|
||
log.Printf("voice: clarify — act %q matched no capability; saying so instead of asking", dec.Utterance)
|
||
return actNotRecognized, true
|
||
}
|
||
q := &dialogue.PendingQuestion{
|
||
Intent: dialogue.Intent(dec.Intent),
|
||
Slots: toDialogueSlots(dec.Slots),
|
||
Missing: []dialogue.Slot{slot},
|
||
Utterance: dec.Utterance,
|
||
Asked: h.now(),
|
||
TTL: clarifyTTL,
|
||
Attempts: 1, // this ask
|
||
MaxAttempts: h.clarifyMaxAttempts,
|
||
}
|
||
// Push, not Put, when this turn suspended a flow (Vikunja #561): the side
|
||
// query needs clarifying of ITS own, and Put would replace the top of the
|
||
// stack — which is the very question the side query was allowed to interrupt
|
||
// rather than kill. Push keeps both.
|
||
//
|
||
// Push returns whatever the depth bound forced out, and that one has to be
|
||
// spoken: MaxStackDepth is a promise that every level she keeps is a level
|
||
// she can name when it dies. It is glued in front, like every other notice
|
||
// about something let go.
|
||
rt := turnRouteFrom(ctx)
|
||
if rt != nil && rt.suspended {
|
||
if evicted := h.clarifyStore.Push(dialogueIDOf(ctx), q); evicted != nil {
|
||
log.Printf("voice: clarify — stack full at %d, letting go of the request behind %q", dialogue.MaxStackDepth, evicted.Utterance)
|
||
rt.dropped = withNotice(rt.dropped, clarifyDropped)
|
||
}
|
||
// One question per breath still holds. The side query turned out to need
|
||
// a question of its own, so THAT is the one she asks; resuming as well
|
||
// would put two questions in one reply, which is the interrogation
|
||
// askRemainingGap already refuses to run. The suspended flow keeps its
|
||
// place underneath and is not lost — if it is never reached it dies on
|
||
// the TTL, and the expiry notice (now plural-aware) says so.
|
||
rt.resume = ""
|
||
log.Printf("voice: clarify — asked about %s for intent=%s, stacked on a suspended flow", slot, dec.Intent)
|
||
return question, true
|
||
}
|
||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||
log.Printf("voice: clarify — asked about %s for intent=%s", slot, dec.Intent)
|
||
return question, true
|
||
}
|
||
|
||
// clarifyCancelled — he called the half-built request off. Said out loud, like
|
||
// every other way it can end: a silent drop reads as "done". Feminine
|
||
// self-reference ("отменила"), as everywhere.
|
||
const clarifyCancelled = "Хорошо, отменила."
|
||
|
||
// clarifyDropped — he asked for something ELSE instead, so the parked request
|
||
// is gone. Glued in front of the answer to what he actually asked, because
|
||
// nothing may be dropped in silence.
|
||
//
|
||
// Only new_request and cancel reach this since V-561. A side query used to as
|
||
// well, and the owner rejected it on sight: he asks about the weather in the
|
||
// middle of setting a reminder, and hearing "прошлую просьбу отпускаю" tells him
|
||
// a thing he did not ask to lose has been lost. It had not been — there was
|
||
// simply nowhere to put it. Now there is (ClarifyStore's stack), so a side query
|
||
// suspends and resumes, and apologising for a drop that did not happen is worse
|
||
// than saying nothing.
|
||
const clarifyDropped = "Прошлую просьбу отпускаю."
|
||
|
||
// resolveClarifyAnswer reads an utterance against the parked question and
|
||
// decides what it IS before deciding what to do with it. Returns ("", false)
|
||
// when the turn is not this resolver's — nothing parked, or the utterance turned
|
||
// out to be a request of its own — so the caller dispatches it normally.
|
||
//
|
||
// The order is the point (Vikunja #560). The utterance is ROUTED first, and the
|
||
// role is read off that decision: a routed decision that stands on its own is
|
||
// not an answer, whatever the extractor found inside it. Before this the
|
||
// extractor decided, so "какая сейчас погода в Риме?" became the time of a
|
||
// reminder on the strength of the word "сейчас".
|
||
//
|
||
// The answer itself is parsed with the same extractor the router uses, for the
|
||
// intent she parked — no second parser. If it still does not fill the gap she
|
||
// asks again, up to MaxAttempts; after that she says out loud that she did not
|
||
// understand. She never drops the request in silence.
|
||
func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) (string, bool) {
|
||
if h.clarifyStore == nil {
|
||
return "", false
|
||
}
|
||
q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now())
|
||
if q == nil {
|
||
return "", false // not_applicable: nothing is pending
|
||
}
|
||
|
||
intent := router.Intent(q.Intent)
|
||
answer := h.extractor.Extract(ctx, intent, text, h.now())
|
||
|
||
var (
|
||
routed router.Decision
|
||
routedOK bool
|
||
)
|
||
if needsRoute(text) {
|
||
routed, routedOK = h.routeForRole(ctx, text)
|
||
}
|
||
role := classifyTurnRole(q, text, toDialogueSlots(answer), routed, routedOK)
|
||
log.Printf("voice: clarify — %q is a %s against %s (routed=%v)", text, role, dialogue.CapabilityFor(q.Intent), routedOK)
|
||
|
||
switch role {
|
||
case roleCancel:
|
||
h.completeClarifyTop(ctx)
|
||
return clarifyCancelled, true
|
||
case roleSideQuery:
|
||
// He asked something of his own WITHOUT leaving the flow. The question
|
||
// stays exactly where it is — same slot, same attempt, same parked
|
||
// utterance — and these words go on to be answered as themselves. The
|
||
// resumed question is then glued onto the back of that answer, so one
|
||
// reply carries both acts (Vikunja #561).
|
||
//
|
||
// No attempt is spent. He answered the side query, not the parked
|
||
// question, and charging a retry for a turn that was never an answer is
|
||
// the V-554 shape.
|
||
h.noteSuspended(ctx, q)
|
||
return "", false
|
||
case roleAside:
|
||
// He stated something in the middle of the flow. Same machinery as a
|
||
// side query and for the same reason: the words are answered as
|
||
// themselves, so the note or the fact is stored, and the question comes
|
||
// back on the end of the same reply (V-577 shape 2). Storing it in
|
||
// silence and dropping it in silence are both wrong, and dropping it is
|
||
// what she did.
|
||
h.noteSuspended(ctx, q)
|
||
return "", false
|
||
case roleNewRequest:
|
||
// He moved on. A parked question used to swallow whatever came next, so
|
||
// one act she could not fulfil ate the following three turns (Vikunja
|
||
// #554) and a world question set a reminder for a time nobody asked for
|
||
// (#558). Drop the question, say so, and let these words be themselves.
|
||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||
h.noteDropped(ctx)
|
||
return "", false
|
||
}
|
||
|
||
// He is answering, so the run of step-asides is over (V-654). Reset here
|
||
// rather than where a gap is FILLED: "позвонить маме" against a question
|
||
// about the time gives her nothing she asked for and still means he is in
|
||
// the exchange, and the retry it costs is bound enough on its own. The
|
||
// counter is for the case the bounds miss — he asked for other things and
|
||
// never came back.
|
||
q.Suspends = 0
|
||
|
||
merged := q.Answer(text, toDialogueSlots(answer))
|
||
// Fold a newly answered subject into the raw utterance. Downstream actions
|
||
// phrase from Utterance, not from the text slot — actionReminder stores it
|
||
// as the reminder payload — so a reminder clarified out of a bare "напомни"
|
||
// would fire at 11:00 saying "напомни" and nothing else.
|
||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
||
// A fact answers with a key and a value and fills no Text slot at all, so
|
||
// the fold above leaves the utterance at the bare "запиши" — and that is
|
||
// what the confirmation now reads back to him (V-592). His raw words are the
|
||
// only record of what he said, so they are what is folded. Never for a time
|
||
// question: what he says about when is kept apart in WhenText on purpose,
|
||
// or the reminder would read the day back at him when it fires.
|
||
if merged.Text == "" && !asksAboutTime(q.Missing) {
|
||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, text)
|
||
}
|
||
// An answer about the time joins everything else he has said about the time,
|
||
// and the whole of it is re-read as one request (V-579). "завтра" names the
|
||
// day of an hour she is already holding, and read alone it names no hour at
|
||
// all, so the parser would have nothing and she would ask for ever.
|
||
// What he had already said about the time, read BEFORE this answer joins it.
|
||
// A re-ask that cannot tell the two apart is the one that repeats itself
|
||
// byte for byte (V-593).
|
||
var taken string
|
||
if asksAboutTime(q.Missing) {
|
||
before := whenKnownOf(whenTextOf(q), q.Slots.HasTime)
|
||
q.WhenText = strings.TrimSpace(q.WhenText + " " + text)
|
||
if t, ok := h.readWhen(ctx, intent, q, text); ok {
|
||
merged.Time, merged.HasTime = t, true
|
||
}
|
||
if before.movedForward(whenKnownOf(whenTextOf(q), merged.HasTime)) {
|
||
taken = whenTakenLine(text)
|
||
}
|
||
}
|
||
if stillOpen(q.Missing, whenTextOf(q), merged) {
|
||
return h.reaskOrGiveUp(ctx, q, merged, text, taken), true
|
||
}
|
||
// One gap filled is not the same as a complete request. askClarify parks
|
||
// only the first gap, because one question per turn is the rule, but a
|
||
// reminder wants both a subject and a time. "напомни" with neither used to
|
||
// ask "О чём напомнить?", accept "позвонить маме", and then hand applyAction
|
||
// a reminder with no time, which answered "не получилось разобрать время
|
||
// напоминания." — an error for a request she never finished asking about.
|
||
// Re-enter the loop instead, one question at a time as before.
|
||
if reply, asked := h.askRemainingGap(ctx, q, intent, merged); asked {
|
||
return reply, true
|
||
}
|
||
h.completeClarifyTop(ctx)
|
||
|
||
// Rebuild the decision as if it had routed cleanly, then run it down the
|
||
// normal path. Clarify is deliberately false and the intent is unchanged:
|
||
// filling in an argument never grants authority, so the completed decision
|
||
// still meets the allowlist and the destructive-act confirm gate in
|
||
// applyAction exactly like any other decision.
|
||
dec := router.Decision{
|
||
Utterance: q.Utterance,
|
||
Stage: 2,
|
||
Intent: intent,
|
||
Slots: applyDialogueSlots(answer, merged),
|
||
}
|
||
return h.finishClarified(ctx, dec), true
|
||
}
|
||
|
||
// completeClarifyTop finishes only the active question. A nested question can
|
||
// sit above a flow that was suspended by a side request or repair; deleting the
|
||
// dialogue id here erased both. If one survives underneath, restart its clock
|
||
// from the moment it is spoken again and attach its question to this turn.
|
||
func (h *reactiveHandler) completeClarifyTop(ctx context.Context) {
|
||
if h.clarifyStore == nil {
|
||
return
|
||
}
|
||
_, resumed := h.clarifyStore.CompleteTop(dialogueIDOf(ctx), h.now())
|
||
if resumed == nil || len(resumed.Missing) == 0 {
|
||
return
|
||
}
|
||
question, ok := clarifyResumedFor(resumed.Missing[0])
|
||
if !ok {
|
||
return
|
||
}
|
||
if rt := turnRouteFrom(ctx); rt != nil {
|
||
rt.resume = question
|
||
}
|
||
}
|
||
|
||
// noteDropped records that the parked request was let go this turn, so runTurn
|
||
// can say it in front of whatever these words are answered with. Nothing to
|
||
// record outside runTurn — a unit test calling one resolver has no turn to glue
|
||
// a notice onto.
|
||
func (h *reactiveHandler) noteDropped(ctx context.Context) {
|
||
if rt := turnRouteFrom(ctx); rt != nil {
|
||
rt.dropped = clarifyDropped
|
||
}
|
||
}
|
||
|
||
// noteSuspended keeps the parked question alive across a side query and records
|
||
// the words that bring it back, so runTurn can put them after this turn's answer
|
||
// (Vikunja #561).
|
||
//
|
||
// Two things happen to the question and neither is an attempt. Its clock is
|
||
// restarted, because she is about to ask it again and the 90s TTL measures the
|
||
// pause since she last spoke it — leaving Asked at the original ask would let a
|
||
// flow he is actively working through die of a wait he did not take. And the
|
||
// stack is left exactly as it is: the question is already on top, so suspending
|
||
// it is not a write.
|
||
//
|
||
// A slot with no resumed wording (clarifyResumedFor says so) resumes nothing and
|
||
// says nothing. She must not claim to be holding a question she cannot re-ask.
|
||
//
|
||
// Suspension is bounded, since V-654. Neither of the two things above is a
|
||
// limit: no attempt is spent, and restarting the clock means the TTL cannot
|
||
// arrive while he keeps talking. So the count is the only thing that ends it,
|
||
// and past MaxSuspends she lets the request go and says so with the same line
|
||
// every other drop uses. The rule is unchanged — a question ends by being
|
||
// answered or by being let go out loud — this only recognises three unrelated
|
||
// requests in a row as the second of those.
|
||
func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.PendingQuestion) {
|
||
rt := turnRouteFrom(ctx)
|
||
if rt == nil || len(q.Missing) == 0 {
|
||
return
|
||
}
|
||
question, ok := clarifyResumedFor(q.Missing[0])
|
||
if !ok {
|
||
return
|
||
}
|
||
if !q.CanResume() {
|
||
h.completeClarifyTop(ctx)
|
||
h.noteDropped(ctx)
|
||
log.Printf("voice: clarify — letting the question about %s go: %d asides in a row, %d rides in all", q.Missing[0], q.Suspends, q.Rides)
|
||
return
|
||
}
|
||
q.Suspends++
|
||
// Rides is the same event counted without the reset (V-663). Incremented
|
||
// beside Suspends and never anywhere else, so the two cannot disagree about
|
||
// what happened, only about how much of it they remember.
|
||
q.Rides++
|
||
q.Asked = h.now()
|
||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||
rt.resume = question
|
||
rt.suspended = true
|
||
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply (suspend %d of %d, ride %d of %d)", q.Missing[0], q.Suspends, dialogue.MaxSuspends, q.Rides, dialogue.MaxRides)
|
||
}
|
||
|
||
// foldAnswerIntoUtterance appends an answered subject to the original words,
|
||
// unless they already carry it. "напомни" + "позвонить маме" reads as the
|
||
// request he would have made in one breath. Nothing is appended when the
|
||
// subject is empty or already present, so re-asking the same question twice
|
||
// cannot grow the utterance.
|
||
func foldAnswerIntoUtterance(utterance, subject string) string {
|
||
subject = strings.TrimSpace(subject)
|
||
if subject == "" || strings.Contains(utterance, subject) {
|
||
return utterance
|
||
}
|
||
if strings.TrimSpace(utterance) == "" {
|
||
return subject
|
||
}
|
||
return strings.TrimSpace(utterance) + " " + subject
|
||
}
|
||
|
||
// askRemainingGap re-parks the request when the answer closed one gap and
|
||
// wantedSlots still names another. Returns ("", false) when the request is
|
||
// complete, when there is no question for what is left, or when she is out of
|
||
// attempts — in all three the caller runs the decision as it stands, which for
|
||
// the out-of-attempts case is the old behaviour and is the right one: she has
|
||
// already asked enough.
|
||
//
|
||
// The attempt budget is shared with the re-ask path on purpose. A second gap
|
||
// costs a question exactly like a second try at the first one does, so the cap
|
||
// still bounds how many times she can speak before acting or letting go.
|
||
func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
||
remaining := stillMissingFor(intent, whenTextOf(q), merged)
|
||
if len(remaining) == 0 {
|
||
return "", false
|
||
}
|
||
// Attempts+1 is the question she is about to ask, and the budget is shared
|
||
// with the re-ask path, so the second gap is worded like a second try.
|
||
question, ok := h.questionFor(remaining[0], q.Attempts+1, whenTextOf(q), merged, "")
|
||
if !ok || !q.CanAsk() {
|
||
return "", false
|
||
}
|
||
// Suspends is not carried, and by this point it is already zero: the answer
|
||
// path resets it (V-654). Left off the literal so the zero is stated where
|
||
// the struct is built, rather than inherited from a field nobody names.
|
||
//
|
||
// Rides IS carried, and that is the whole point of it (V-663). This is the
|
||
// same request under a second question, not a new one, so the turns it has
|
||
// already ridden still count against it. Dropping the field here is exactly
|
||
// the re-basing that let one question ride twenty-six replies.
|
||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||
Intent: q.Intent,
|
||
Slots: merged,
|
||
Missing: []dialogue.Slot{remaining[0]},
|
||
Utterance: q.Utterance,
|
||
WhenText: q.WhenText,
|
||
Asked: h.now(),
|
||
TTL: clarifyTTL,
|
||
Attempts: q.Attempts + 1,
|
||
MaxAttempts: q.MaxAttempts,
|
||
Rides: q.Rides,
|
||
})
|
||
log.Printf("voice: clarify — one gap filled, still missing %s for intent=%s, asking again (attempt %d)", remaining[0], intent, q.Attempts+1)
|
||
return question, true
|
||
}
|
||
|
||
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
|
||
// again while she has attempts left, otherwise say she did not understand and
|
||
// let the request go. Never returns "" — a mute give-up reads as "done".
|
||
// taken is the acknowledgement of what this answer DID give, empty when it gave
|
||
// nothing (V-593). The give-up line never carries it: it is not another ask.
|
||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text, taken string) string {
|
||
question := ""
|
||
if len(q.Missing) > 0 {
|
||
question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged, taken)
|
||
}
|
||
if question == "" || !q.CanAsk() {
|
||
h.completeClarifyTop(ctx)
|
||
log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
|
||
return clarifyGaveUp
|
||
}
|
||
// Re-park with whatever the answer DID give, the clock restarted and one
|
||
// more question spent.
|
||
q.Slots = merged
|
||
q.Attempts++
|
||
q.Asked = h.now()
|
||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||
log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts)
|
||
return question
|
||
}
|
||
|
||
// finishClarified completes a decision whose parked gaps were already checked
|
||
// by resolveClarifyAnswer. It still records the turn for a later correction;
|
||
// the old path made anything completed through dialogue uncorrectable (V-573).
|
||
func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string {
|
||
return h.finishRebuilt(ctx, dec, false)
|
||
}
|
||
|
||
// finishRepaired validates a decision rebuilt from an older utterance. Unlike
|
||
// resolveClarifyAnswer, repair has not passed the current slot gate, so it must
|
||
// ask about any missing argument before acting (V-573).
|
||
func (h *reactiveHandler) finishRepaired(ctx context.Context, dec router.Decision) string {
|
||
return h.finishRebuilt(ctx, dec, true)
|
||
}
|
||
|
||
func (h *reactiveHandler) finishRebuilt(ctx context.Context, dec router.Decision, validate bool) string {
|
||
if validate && (dec.Clarify || len(missingFor(dec)) > 0) {
|
||
if reply := h.hexisBeforeClarify(ctx, dec); reply != "" {
|
||
return reply
|
||
}
|
||
if question, asked := h.askClarify(ctx, dec); asked {
|
||
return question
|
||
}
|
||
}
|
||
if h.dialogueSessions != nil {
|
||
now := h.now()
|
||
prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now)
|
||
dec = followUpMerge(prev, dec, now)
|
||
h.rememberTurn(ctx, prev, dec, now)
|
||
}
|
||
if !dec.Clarify {
|
||
h.recordTurn(dec.Utterance, dec.Intent)
|
||
}
|
||
reply := h.applyAction(ctx, dec)
|
||
if reply == "" {
|
||
reply = h.replier.Reply(ctx, dec)
|
||
}
|
||
if reply == "" {
|
||
// Belt: an empty reply here would be a silent drop.
|
||
reply = clarifyGaveUp
|
||
}
|
||
return reply
|
||
}
|
||
|
||
// maxCarriedHistory — how many turns of PRIOR history (beyond the immediate
|
||
// last turn) rememberTurn carries forward. The session ends up holding this
|
||
// many plus the one just-finished turn, so callers describing the total
|
||
// depth (chatHistory's doc comment, this one) say "up to 4".
|
||
const maxCarriedHistory = 3
|
||
|
||
// rememberTurn stores this turn as the dialogue session the next follow-up
|
||
// inherits from, carrying up to 4 prior turns of history for anaphora. Capped so
|
||
// one long conversation can't grow the session unboundedly.
|
||
func (h *reactiveHandler) rememberTurn(ctx context.Context, prev *dialogue.Session, dec router.Decision, now time.Time) {
|
||
var history []dialogue.Turn
|
||
if prev != nil {
|
||
// History is chronological. Keep the newest tail of the older history,
|
||
// then append the immediate prior turn. The previous implementation put
|
||
// the newest turn first while the type contract said newest-last, so the
|
||
// model read a conversation backwards.
|
||
from := len(prev.History) - maxCarriedHistory
|
||
if from < 0 {
|
||
from = 0
|
||
}
|
||
history = append(history, prev.History[from:]...)
|
||
history = append(history, sessionAsTurn(prev))
|
||
}
|
||
conversational := dec.Intent == router.IntentChat || opensConversation(dec.Utterance)
|
||
if prev != nil && (prev.Conversational || prev.Intent == dialogue.IntentChat) {
|
||
conversational = true
|
||
}
|
||
ttl := time.Duration(0) // use the store default (2 min)
|
||
if conversational {
|
||
ttl = 15 * time.Minute // conversational turns should last longer
|
||
}
|
||
// A system or query turn often carries no Text slot at all — a stage-0
|
||
// grammar fills none. The next turn may be an ellipsis ("а завтра?"),
|
||
// which knows the day but not what was asked ABOUT, so keep the raw
|
||
// utterance where continuation.go can find it. Only these two intents:
|
||
// everywhere else Text is a payload and must stay what the router put in.
|
||
//
|
||
// Overwritten, not filled: rememberTurn runs AFTER followUpMerge, which
|
||
// has already inherited a Text from the previous same-intent turn, so a
|
||
// fill-if-empty rule keeps the OLD topic for ever. Seen on the deployed
|
||
// daemon 01-08-2026 — "во сколько у меня встреча" then "какие у меня
|
||
// планы" then "а завтра?" continued the meeting, two turns stale.
|
||
//
|
||
// A continuation is the exception and keeps what it inherited: its
|
||
// utterance is the ellipsis, and the topic it carries is the real one.
|
||
slots := toDialogueSlots(dec.Slots)
|
||
if !dec.Continued && (dec.Intent == router.IntentSystem || dec.Intent == router.IntentQuery) {
|
||
slots.Text = dec.Utterance
|
||
}
|
||
h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{
|
||
Intent: dialogue.Intent(dec.Intent),
|
||
Slots: slots,
|
||
Utterance: dec.Utterance,
|
||
Conversational: conversational,
|
||
Timestamp: now,
|
||
TTL: ttl,
|
||
History: history,
|
||
})
|
||
}
|