b9a24334ea
Wording fixes from the review of the clarify + phrasing PRs. - "На когда напомнить?" → "Когда?". After she has just been asked something, the long form is the phrasing of a form field, not of a person. - A reminder now wants a subject as well as a time. "напомни в 11" had a time and nothing to say at 11, and she asked nothing at all — she now asks "О чём напомнить?". Subject first, since a reminder with no subject is not worth setting. - The expiry notice is five phrasings picked at random instead of one fixed sentence. It is the line he hears every time he walks off mid-request, so it is the line that repeats most. - The nudge prompt's ban on "обращения" is now "ласковые обращения". It was meant to forbid "милый"/"дорогой", not his name — "Ками, ноутбук на трёх процентах" is how she talks, and the eval's cringe check already only flags pet names. - The nudge example no longer claims she plugged the laptop in. She has no hands and no smart plug; an example where she acts teaches the model to invent actions Maven never took. - replySystem: "тепло" → "спокойно и без официальных формулировок". A one-word mood instruction a 1.7B can't act on, replaced with the behaviour meant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
293 lines
12 KiB
Go
293 lines
12 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; the rest are only used to decide act-vs-drop.
|
|
//
|
|
// 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 — she still only asks
|
|
// about the first one missing.
|
|
var wantedSlots = map[router.Intent][]dialogue.Slot{
|
|
router.IntentReminder: {dialogue.SlotText, dialogue.SlotTime},
|
|
router.IntentFact: {dialogue.SlotKey},
|
|
router.IntentAct: {dialogue.SlotFn},
|
|
}
|
|
|
|
// clarifyQuestions — one short question per missing slot.
|
|
//
|
|
// These are fixed templates, not model output. The resident model is a 0.8B; it
|
|
// would wander, and a question whose wording changes every time is harder to
|
|
// answer than a blunt one that always reads the same. They are infinitive
|
|
// questions, so there is no gender agreement to get wrong; the feminine
|
|
// self-reference lives in the reply she gives when she drops the request.
|
|
var clarifyQuestions = map[dialogue.Slot]string{
|
|
dialogue.SlotTime: "Когда?",
|
|
dialogue.SlotText: "О чём напомнить?",
|
|
dialogue.SlotKey: "Что записать?",
|
|
dialogue.SlotFn: "Что сделать?",
|
|
}
|
|
|
|
// 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() string {
|
|
if h.clarifyStore == nil {
|
|
return ""
|
|
}
|
|
if !h.clarifyStore.TakeExpired(voiceDialogueID, 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 and lets the rest go. Two questions in a row is an interrogation.
|
|
func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
|
missing := missingFor(dec)
|
|
if len(missing) == 0 {
|
|
return "", "", false
|
|
}
|
|
q, ok := clarifyQuestions[missing[0]]
|
|
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(dec router.Decision) (string, bool) {
|
|
if h.clarifyStore == nil {
|
|
return "", false
|
|
}
|
|
slot, question, ok := clarifyQuestion(dec)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
h.clarifyStore.Put(voiceDialogueID, &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
|
|
}
|
|
|
|
// resolveClarifyAnswer reads an utterance as the answer to a parked question.
|
|
// Returns ("", false) when no live question is parked (or it expired), so the
|
|
// caller routes the utterance normally as a fresh request. Sibling of
|
|
// resolveConfirm and checked in the same place.
|
|
//
|
|
// The answer 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(voiceDialogueID, h.now())
|
|
if q == nil {
|
|
return "", false
|
|
}
|
|
|
|
intent := router.Intent(q.Intent)
|
|
answer := h.extractor.Extract(ctx, intent, text, h.now())
|
|
merged := q.Answer(text, toDialogueSlots(answer))
|
|
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
|
return h.reaskOrGiveUp(q, merged, text), true
|
|
}
|
|
h.clarifyStore.Delete(voiceDialogueID)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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(q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
|
question := ""
|
|
if len(q.Missing) > 0 {
|
|
question = clarifyQuestions[q.Missing[0]]
|
|
}
|
|
if question == "" || !q.CanAsk() {
|
|
h.clarifyStore.Delete(voiceDialogueID)
|
|
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(voiceDialogueID, 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(voiceDialogueID, now)
|
|
dec = followUpMerge(prev, dec, now)
|
|
h.rememberTurn(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(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
|
|
}
|
|
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{
|
|
Intent: dialogue.Intent(dec.Intent),
|
|
Slots: toDialogueSlots(dec.Slots),
|
|
Timestamp: now,
|
|
TTL: ttl,
|
|
History: history,
|
|
})
|
|
}
|