6abd2768e8
resolveClarifyAnswer now decides what the utterance IS before deciding what to do with it: route, classify the role, then answer, cancel, or step aside. The side_query and new_request arms drop the parked question and say so — nothing may die in silence — and V-561 turns the first of those into a suspend. isOwnRequest is deleted rather than extended. It only ran where the answer filled nothing, which is why the greedy 'сейчас' in a weather question walked straight past it and set a reminder for a time nobody asked for.
427 lines
18 KiB
Go
427 lines
18 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"log"
|
||
"math/rand"
|
||
"strings"
|
||
"time"
|
||
|
||
"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{
|
||
"Прости, я слишком долго ждала ответа и отпустила прошлую просьбу. Если она ещё нужна, скажи заново.",
|
||
"Кажется, прошлая просьба уже не важна — я её отпустила. Если я ошибаюсь, повтори.",
|
||
"Ты как-то резко замолчал, и я не стала ждать дальше. Если та просьба ещё нужна, скажи заново.",
|
||
"Я не дождалась ответа и убрала прошлую просьбу. Повтори, если она всё ещё нужна.",
|
||
"Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.",
|
||
}
|
||
|
||
// clarifyExpiredLine picks one of them at random.
|
||
func clarifyExpiredLine() string {
|
||
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 {
|
||
for _, v := range clarifyExpiredVariants {
|
||
if strings.HasPrefix(s, v) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// trimClarifyExpired strips a leading expiry notice, leaving this turn's actual
|
||
// reply. "" ⇒ the notice was the whole thing.
|
||
func trimClarifyExpired(s string) string {
|
||
for _, v := range clarifyExpiredVariants {
|
||
if strings.HasPrefix(s, v) {
|
||
return strings.TrimSpace(strings.TrimPrefix(s, v))
|
||
}
|
||
}
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
// 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 ""
|
||
}
|
||
if !h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) {
|
||
return ""
|
||
}
|
||
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
||
return clarifyExpiredLine()
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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 dialogue.StillMissing(wantedSlots[dec.Intent], 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 clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||
missing := missingFor(dec)
|
||
if len(missing) == 0 {
|
||
return "", "", false
|
||
}
|
||
q, ok := clarifyQuestionFor(missing[0], 1)
|
||
if !ok {
|
||
return "", "", false
|
||
}
|
||
return missing[0], q, true
|
||
}
|
||
|
||
// 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 := 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
|
||
}
|
||
h.clarifyStore.Put(dialogueIDOf(ctx), &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,
|
||
})
|
||
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. V-561 suspends and resumes it instead of
|
||
// letting it go, and this line goes away with it.
|
||
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.clarifyStore.Delete(dialogueIDOf(ctx))
|
||
return clarifyCancelled, true
|
||
case roleSideQuery, 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
|
||
}
|
||
|
||
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)
|
||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
||
return h.reaskOrGiveUp(ctx, q, merged, text), true
|
||
}
|
||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
|
||
// 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 := dialogue.StillMissing(wantedSlots[intent], 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 := clarifyQuestionFor(remaining[0], q.Attempts+1)
|
||
if !ok || !q.CanAsk() {
|
||
return "", false
|
||
}
|
||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||
Intent: q.Intent,
|
||
Slots: merged,
|
||
Missing: []dialogue.Slot{remaining[0]},
|
||
Utterance: q.Utterance,
|
||
Asked: h.now(),
|
||
TTL: clarifyTTL,
|
||
Attempts: q.Attempts + 1,
|
||
MaxAttempts: q.MaxAttempts,
|
||
})
|
||
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".
|
||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||
question := ""
|
||
if len(q.Missing) > 0 {
|
||
question, _ = clarifyQuestionFor(q.Missing[0], q.Attempts+1)
|
||
}
|
||
if question == "" || !q.CanAsk() {
|
||
h.clarifyStore.Delete(dialogueIDOf(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 runs a completed decision through the same steps a freshly
|
||
// routed one takes: remember the turn, act, then phrase.
|
||
func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string {
|
||
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)
|
||
}
|
||
reply := h.applyAction(ctx, dec)
|
||
if reply == "" {
|
||
reply = h.replier.Reply(dec)
|
||
}
|
||
if reply == "" {
|
||
// Belt: an empty reply here would be a silent drop.
|
||
reply = clarifyGaveUp
|
||
}
|
||
return reply
|
||
}
|
||
|
||
// 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 = append(history, dialogue.Turn{
|
||
Intent: prev.Intent,
|
||
Slots: prev.Slots,
|
||
Text: prev.Slots.Text,
|
||
})
|
||
maxHist := len(prev.History)
|
||
if maxHist > 3 {
|
||
maxHist = 3
|
||
}
|
||
history = append(history, prev.History[:maxHist]...)
|
||
}
|
||
ttl := time.Duration(0) // use the store default (2 min)
|
||
if dec.Intent == router.IntentChat {
|
||
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,
|
||
Timestamp: now,
|
||
TTL: ttl,
|
||
History: history,
|
||
})
|
||
}
|