Compare commits

..

2 Commits

Author SHA1 Message Date
claude 87d176153a router: stage 0 claims the task marker before the model renames it (V-467)
Spoken capture was dead. "добавь в задачи купить молоко" routed act, so the
gate found no allowlisted fn and asked "Что сделать?", and the list stayed
empty. Capture rides the note intent by design (#130, no eighth intent), and
nothing under actionNote was reached any more. The model also rewrote the
payload on the way — "купить молоко" came back as "сделать покупку молока",
and a task must read as the words he said.

TaskCaptureGrammar answers it at stage 0, the same place the agenda rules
went. It matches any utterance and lets ParseTaskCapture refuse, so the
marker list stays data. Three phrasings he used are added to that list:
"запиши в список дел" and the two next to it were missing.

The other deterministic matchers were checked for the same exposure. They
are all question-shaped — money, habit, feed, day plan, task list, calendar —
and a question lands on query, which is where they already sit. Capture was
the only imperative among them, which is why only it was taken.

ru-note-006 is the fixture case. The classifier alone cannot pass it, and the
hash baseline drops by that one case; the daemon answers it at stage 0.
2026-08-04 03:12:47 +04:00
claude 7d4b4ad736 clarify: a parked question belongs to the conversation that was asked (V-466)
The clarify store had one key for the whole daemon, so a question asked in
the web chat and never answered captured the next three utterances from any
source — telegram, or the mic — and answered them against a request the
speaker never made.

The reach now supplies a conversation id on the IPC Chat call, and the
daemon carries it on the context the way it already carries the correlation
id, so the six clarify call sites read it instead of a constant. The mic has
no id of its own and keeps the key it had, so voice behaves exactly as
before. mavweb has no per-browser session, so every tab is one conversation:
right for a single-owner box, and still distinct from telegram and the mic.

Dialogue sessions stay global on purpose — they are what she remembers about
him, not what she is waiting for from one channel.
2026-08-04 03:08:09 +04:00
19 changed files with 279 additions and 59 deletions
+13 -13
View File
@@ -106,11 +106,11 @@ func trimClarifyExpired(s string) string {
// out, and "" when nothing was parked. Call it right after // out, and "" when nothing was parked. Call it right after
// resolveClarifyAnswer: a live question is answered there, an expired one is // resolveClarifyAnswer: a live question is answered there, an expired one is
// only reported here — the words themselves still go on to be routed fresh. // only reported here — the words themselves still go on to be routed fresh.
func (h *reactiveHandler) clarifyExpiredNotice() string { func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string {
if h.clarifyStore == nil { if h.clarifyStore == nil {
return "" return ""
} }
if !h.clarifyStore.TakeExpired(voiceDialogueID, h.now()) { if !h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) {
return "" return ""
} }
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh") log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
@@ -157,7 +157,7 @@ func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
// askClarify parks the request and returns the question to ask instead of the // askClarify parks the request and returns the question to ask instead of the
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so // canned "не поняла". Returns ("", false) when there is nothing to ask about, so
// the caller falls back to the canned reply. // the caller falls back to the canned reply.
func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) { func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (string, bool) {
if h.clarifyStore == nil { if h.clarifyStore == nil {
return "", false return "", false
} }
@@ -165,7 +165,7 @@ func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
if !ok { if !ok {
return "", false return "", false
} }
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{ h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
Intent: dialogue.Intent(dec.Intent), Intent: dialogue.Intent(dec.Intent),
Slots: toDialogueSlots(dec.Slots), Slots: toDialogueSlots(dec.Slots),
Missing: []dialogue.Slot{slot}, Missing: []dialogue.Slot{slot},
@@ -192,7 +192,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
if h.clarifyStore == nil { if h.clarifyStore == nil {
return "", false return "", false
} }
q := h.clarifyStore.Get(voiceDialogueID, h.now()) q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now())
if q == nil { if q == nil {
return "", false return "", false
} }
@@ -206,9 +206,9 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
// would fire at 11:00 saying "напомни" and nothing else. // would fire at 11:00 saying "напомни" and nothing else.
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text) q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
if len(dialogue.StillMissing(q.Missing, merged)) > 0 { if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
return h.reaskOrGiveUp(q, merged, text), true return h.reaskOrGiveUp(ctx, q, merged, text), true
} }
h.clarifyStore.Delete(voiceDialogueID) h.clarifyStore.Delete(dialogueIDOf(ctx))
// One gap filled is not the same as a complete request. askClarify parks // 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 // only the first gap, because one question per turn is the rule, but a
@@ -217,7 +217,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
// a reminder with no time, which answered "не получилось разобрать время // a reminder with no time, which answered "не получилось разобрать время
// напоминания." — an error for a request she never finished asking about. // напоминания." — an error for a request she never finished asking about.
// Re-enter the loop instead, one question at a time as before. // Re-enter the loop instead, one question at a time as before.
if reply, asked := h.askRemainingGap(q, intent, merged); asked { if reply, asked := h.askRemainingGap(ctx, q, intent, merged); asked {
return reply, true return reply, true
} }
@@ -261,7 +261,7 @@ func foldAnswerIntoUtterance(utterance, subject string) string {
// The attempt budget is shared with the re-ask path on purpose. A second gap // 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 // 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. // still bounds how many times she can speak before acting or letting go.
func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) { func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
remaining := dialogue.StillMissing(wantedSlots[intent], merged) remaining := dialogue.StillMissing(wantedSlots[intent], merged)
if len(remaining) == 0 { if len(remaining) == 0 {
return "", false return "", false
@@ -270,7 +270,7 @@ func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent ro
if !ok || !q.CanAsk() { if !ok || !q.CanAsk() {
return "", false return "", false
} }
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{ h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
Intent: q.Intent, Intent: q.Intent,
Slots: merged, Slots: merged,
Missing: []dialogue.Slot{remaining[0]}, Missing: []dialogue.Slot{remaining[0]},
@@ -287,13 +287,13 @@ func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent ro
// reaskOrGiveUp handles an answer that left the gap open: ask the same question // 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 // 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". // 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 { func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
question := "" question := ""
if len(q.Missing) > 0 { if len(q.Missing) > 0 {
question = clarifyQuestions[q.Missing[0]] question = clarifyQuestions[q.Missing[0]]
} }
if question == "" || !q.CanAsk() { if question == "" || !q.CanAsk() {
h.clarifyStore.Delete(voiceDialogueID) 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) log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
return clarifyGaveUp return clarifyGaveUp
} }
@@ -302,7 +302,7 @@ func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dial
q.Slots = merged q.Slots = merged
q.Attempts++ q.Attempts++
q.Asked = h.now() q.Asked = h.now()
h.clarifyStore.Put(voiceDialogueID, q) 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) log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts)
return question return question
} }
+50 -21
View File
@@ -81,7 +81,7 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
ctx := context.Background() ctx := context.Background()
h, st, _ := newClarifyHandler(t) h, st, _ := newClarifyHandler(t)
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")) question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
if !asked || question != "Когда?" { if !asked || question != "Когда?" {
t.Fatalf("expected the time question, got %q asked=%v", question, asked) t.Fatalf("expected the time question, got %q asked=%v", question, asked)
} }
@@ -112,7 +112,7 @@ func TestClarifyFactCompletesOnAnswer(t *testing.T) {
ctx := context.Background() ctx := context.Background()
h, st, _ := newClarifyHandler(t) h, st, _ := newClarifyHandler(t)
if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked { if _, asked := h.askClarify(ctx, clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
t.Fatal("a fact with no key should be asked about") t.Fatal("a fact with no key should be asked about")
} }
if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp { if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp {
@@ -128,7 +128,7 @@ func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) {
ctx := context.Background() ctx := context.Background()
h, st, now := newClarifyHandler(t) h, st, now := newClarifyHandler(t)
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question") t.Fatal("expected a question")
} }
*now = now.Add(clarifyTTL + time.Second) *now = now.Add(clarifyTTL + time.Second)
@@ -147,7 +147,7 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
ctx := context.Background() ctx := context.Background()
h, st, _ := newClarifyHandler(t) h, st, _ := newClarifyHandler(t)
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a first question") t.Fatal("expected a first question")
} }
// Two more unclear answers ⇒ two more questions (3 asks in total). // Two more unclear answers ⇒ two more questions (3 asks in total).
@@ -185,7 +185,7 @@ func TestClarifyMaxAttemptsIsConfigurable(t *testing.T) {
h, _, _ := newClarifyHandler(t) h, _, _ := newClarifyHandler(t)
h.clarifyMaxAttempts = 1 h.clarifyMaxAttempts = 1
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question") t.Fatal("expected a question")
} }
if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp { if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp {
@@ -199,7 +199,7 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
ctx := context.Background() ctx := context.Background()
h, st, _ := newClarifyHandler(t) h, st, _ := newClarifyHandler(t)
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked { if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
t.Fatal("expected a question") t.Fatal("expected a question")
} }
// First answer parses, but re-park it by hand as if she had asked again: // First answer parses, but re-park it by hand as if she had asked again:
@@ -232,7 +232,7 @@ func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) {
h, st, _ := newClarifyHandler(t) h, st, _ := newClarifyHandler(t)
marker := filepath.Join(t.TempDir(), "not-allowed-ran") marker := filepath.Join(t.TempDir(), "not-allowed-ran")
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked { if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
t.Fatal("an act with no fn should be asked about") t.Fatal("an act with no fn should be asked about")
} }
reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker) reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker)
@@ -260,7 +260,7 @@ func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked { if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
t.Fatal("expected a question") t.Fatal("expected a question")
} }
reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups") reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups")
@@ -284,7 +284,7 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"), clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"),
clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."), clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."),
} { } {
if question, asked := h.askClarify(dec); asked { if question, asked := h.askClarify(context.Background(), dec); asked {
t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question) t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question)
} }
} }
@@ -296,29 +296,29 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the // TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the
// TTL: she must say the old request is gone AND still answer the new words. // TTL: she must say the old request is gone AND still answer the new words.
func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) { func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
ctx := context.Background() ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
h, _, now := newClarifyHandler(t) h, _, now := newClarifyHandler(t)
emb := router.NewHashEmbedder(1024) emb := router.NewHashEmbedder(1024)
h.embedder = emb h.embedder = emb
h.router = buildRouter(emb, h.matcher, 0.55, nil) h.router = buildRouter(emb, h.matcher, 0.55, nil)
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question") t.Fatal("expected a question")
} }
*now = now.Add(clarifyTTL + time.Second) *now = now.Add(clarifyTTL + time.Second)
reply := h.handleText(ctx, "как дела") reply := h.handleText(ctx, "", "как дела")
if !isClarifyExpired(reply) { if !isClarifyExpired(reply) {
t.Fatalf("expired question must be announced first, got %q", reply) t.Fatalf("expired question must be announced first, got %q", reply)
} }
if trimClarifyExpired(reply) == "" { if trimClarifyExpired(reply) == "" {
t.Fatalf("the new words must still be answered, got only the notice: %q", reply) t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
} }
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
t.Fatal("the expired question must be gone") t.Fatal("the expired question must be gone")
} }
// The notice is said once, not on every later utterance. // The notice is said once, not on every later utterance.
if reply := h.handleText(ctx, "как дела"); isClarifyExpired(reply) { if reply := h.handleText(ctx, "", "как дела"); isClarifyExpired(reply) {
t.Fatalf("notice repeated on a later turn: %q", reply) t.Fatalf("notice repeated on a later turn: %q", reply)
} }
} }
@@ -340,7 +340,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
ctx := context.Background() ctx := context.Background()
h, st, _ := newClarifyHandler(t) h, st, _ := newClarifyHandler(t)
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни")) question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
if !asked || question != "О чём напомнить?" { if !asked || question != "О чём напомнить?" {
t.Fatalf("expected the subject question, got %q asked=%v", question, asked) t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
} }
@@ -380,7 +380,7 @@ func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
h, _, _ := newClarifyHandler(t) h, _, _ := newClarifyHandler(t)
h.clarifyMaxAttempts = 1 h.clarifyMaxAttempts = 1
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked { if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
t.Fatal("expected the subject question") t.Fatal("expected the subject question")
} }
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме") reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
@@ -440,10 +440,10 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
// The confirm turn used to return before the notice was even computed, so he // The confirm turn used to return before the notice was even computed, so he
// answered the confirm and never heard that the older request was let go. // answered the confirm and never heard that the older request was let go.
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) { func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
ctx := context.Background() ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
h, _, now := newClarifyHandler(t) h, _, now := newClarifyHandler(t)
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question") t.Fatal("expected a question")
} }
// A confirm parked with a longer life than the question, so only the // A confirm parked with a longer life than the question, so only the
@@ -451,7 +451,7 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)} h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
*now = now.Add(clarifyTTL + time.Second) *now = now.Add(clarifyTTL + time.Second)
reply := h.handleText(ctx, "нет") reply := h.handleText(ctx, "", "нет")
if !isClarifyExpired(reply) { if !isClarifyExpired(reply) {
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply) t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
} }
@@ -461,7 +461,7 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
if h.pending != nil { if h.pending != nil {
t.Fatal("the confirm must still have been consumed") t.Fatal("the confirm must still have been consumed")
} }
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
t.Fatal("the expired question must be gone") t.Fatal("the expired question must be gone")
} }
} }
@@ -476,7 +476,7 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
h, st, _ := newClarifyHandler(t) h, st, _ := newClarifyHandler(t)
at := h.now().Add(2 * time.Hour) at := h.now().Add(2 * time.Hour)
question, asked := h.askClarify(clarifyDec(router.IntentReminder, question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
router.Slots{Time: at, HasTime: true}, "напомни в 11")) router.Slots{Time: at, HasTime: true}, "напомни в 11"))
if !asked || question != "О чём напомнить?" { if !asked || question != "О чём напомнить?" {
t.Fatalf("expected the subject question, got %q asked=%v", question, asked) t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
@@ -501,3 +501,32 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload) t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
} }
} }
// TestClarifyIsPerConversation — the parked question belongs to the reach that
// was asked. Before this the clarify store had one global key, so a question
// asked in the web chat and never answered captured the next utterance from
// telegram, or from the mic, and answered it against a request the speaker had
// never made (Vikunja #466).
func TestClarifyIsPerConversation(t *testing.T) {
h, _, _ := newClarifyHandler(t)
web := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
telegram := withDialogueID(context.Background(), dialogueIDFor(sourceText, "telegram:42"))
if _, asked := h.askClarify(web, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question on the web conversation")
}
if _, handled := h.resolveClarifyAnswer(telegram, "в 11:00"); handled {
t.Fatal("a question asked on the web must not eat a telegram utterance")
}
if _, handled := h.resolveClarifyAnswer(voiceCtx(), "в 11:00"); handled {
t.Fatal("a question asked on the web must not eat what he says at the mic")
}
if reply, handled := h.resolveClarifyAnswer(web, "в 11:00"); !handled || reply == clarifyGaveUp {
t.Fatalf("the asker's own answer must land, handled=%v reply=%q", handled, reply)
}
}
// voiceCtx — the mic's conversation, which carries no id of its own.
func voiceCtx() context.Context {
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
}
+50 -3
View File
@@ -1,17 +1,64 @@
package main package main
import ( import (
"context"
"time" "time"
"github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
) )
// voiceDialogueID — the single dialogue-session key. This is a single-user box // voiceDialogueID — the dialogue-session key for the microphone, and the
// (ponytail), so one slot suffices; a second speaker would need per-speaker ids, // clarify key for it too. This is a single-user box (ponytail), so one slot
// which waits on voice-print attribution (see PROGRESS multi-user deferral). // suffices; a second speaker would need per-speaker ids, which waits on
// voice-print attribution (see PROGRESS multi-user deferral).
const voiceDialogueID = "voice" const voiceDialogueID = "voice"
// textDialogueID — the clarify key for a text turn that named no conversation.
// Separate from the mic: an old client that sends no id still must not answer
// a question she asked out loud.
const textDialogueID = "text"
// dialogueKey — the context key carrying the id of the conversation this turn
// belongs to. It rides the context rather than a parameter for the same reason
// the correlation id does: every step of the turn needs it, most of them only
// to hand to the next one, and threading it by hand would put it in six
// clarify signatures that have nothing else to say about it.
type dialogueKey struct{}
// dialogueIDFor builds the id a turn is held under: the conversation the reach
// named, qualified by the tap it arrived on, or the tap's own fallback when it
// named none.
//
// A parked clarifying question used to be held under voiceDialogueID no matter
// where the turn came from, so one unanswerable question captured the next
// three utterances from anywhere. Three independent curl sessions fed a
// capture attempt that had already failed, and a reminder among them was lost
// (Vikunja #466).
func dialogueIDFor(src turnSource, conversation string) string {
if conversation != "" {
return string(src) + ":" + conversation
}
if src == sourceVoice {
return voiceDialogueID
}
return textDialogueID
}
// withDialogueID tags a turn with that id.
func withDialogueID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, dialogueKey{}, id)
}
// dialogueIDOf reads it back. Falls back to the microphone's slot, which is
// what an unthreaded caller — a test, an internal replay — gets.
func dialogueIDOf(ctx context.Context) string {
if id, ok := ctx.Value(dialogueKey{}).(string); ok && id != "" {
return id
}
return voiceDialogueID
}
// toDialogueSlots and applyDialogueSlots are the only bridge between // toDialogueSlots and applyDialogueSlots are the only bridge between
// router.Slots and dialogue.Slots. dialogue must not import router (import // router.Slots and dialogue.Slots. dialogue must not import router (import
// cycle), so the two structs are hand-kept copies and every field has to be // cycle), so the two structs are hand-kept copies and every field has to be
+37
View File
@@ -2,12 +2,14 @@ package main
import ( import (
"context" "context"
"strings"
"testing" "testing"
"time" "time"
"github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/tool" "github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/voice" "github.com/kami/maven/internal/voice"
) )
@@ -85,3 +87,38 @@ func TestReactiveNotesReminders(t *testing.T) {
} }
}) })
} }
// TestSpokenTaskCaptureFilesATask — the whole path, from the utterance to the
// task table. It went dead when the router started claiming the marker as an
// act: capture rides the note intent, so nothing below actionNote was ever
// reached and every capture answered "Что сделать?" (Vikunja #467).
func TestSpokenTaskCaptureFilesATask(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
api := ipc.NewStoreAPI(st)
now := time.Now()
emb := router.NewHashEmbedder(1024)
matcher := tool.NewMatcher(api)
h := &reactiveHandler{
api: api,
embedder: emb,
router: buildRouter(emb, matcher, 0.55, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
memStore: memory.NewInMemoryStore(),
dataStore: st,
}
reply := h.handleText(ctx, "web", "добавь в задачи купить молоко")
if !strings.Contains(reply, "купить молоко") {
t.Fatalf("capture did not claim the turn: %q", reply)
}
open, err := st.ListTasks(ctx, store.TaskOpen)
if err != nil || len(open) != 1 {
t.Fatalf("task was not filed: tasks=%v err=%v", open, err)
}
// The words he said, not the model's rewrite of them.
if open[0].Text != "купить молоко" {
t.Fatalf("task text was rewritten: %q", open[0].Text)
}
}
+3 -3
View File
@@ -986,7 +986,7 @@ type daemonAPI struct {
getTrace func() *loop.TickTrace getTrace func() *loop.TickTrace
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
getDayPlan func(ctx context.Context) ipc.DayPlan getDayPlan func(ctx context.Context) ipc.DayPlan
chatFn func(ctx context.Context, text string) string chatFn func(ctx context.Context, conversation, text string) string
getMCPServers func() []ipc.MCPServerStatus getMCPServers func() []ipc.MCPServerStatus
getEvents func(n int) []ipc.IntakeEvent getEvents func(n int) []ipc.IntakeEvent
} }
@@ -1002,11 +1002,11 @@ func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent,
return d.getEvents(n), nil return d.getEvents(n), nil
} }
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) { func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
if d.chatFn == nil { if d.chatFn == nil {
return "", errors.New("mavend: chat not available") return "", errors.New("mavend: chat not available")
} }
return d.chatFn(ctx, text), nil return d.chatFn(ctx, conversation, text), nil
} }
// MCPServers — the configured MCP servers and their health (Vikunja #251). // MCPServers — the configured MCP servers and their health (Vikunja #251).
+4 -4
View File
@@ -220,9 +220,9 @@ func (h *reactiveHandler) upgradeAPI(api ipc.CoreAPI) {
// handleText — the core reactive path without stt/tts. Used by the IPC Chat // handleText — the core reactive path without stt/tts. Used by the IPC Chat
// endpoint (and eventually by telegram). Splits out the audio bookends from // endpoint (and eventually by telegram). Splits out the audio bookends from
// HandlePushToTalk so text channels share the same routing logic. // HandlePushToTalk so text channels share the same routing logic.
func (h *reactiveHandler) handleText(ctx context.Context, text string) string { func (h *reactiveHandler) handleText(ctx context.Context, conversation, text string) string {
log.Printf("voice: handleText: %q", text) log.Printf("voice: handleText: %q", text)
return h.runTurn(ctx, text, sourceText) return h.runTurn(withDialogueID(ctx, dialogueIDFor(sourceText, conversation)), text, sourceText)
} }
// turnSource — which channel this utterance arrived on, in the same provenance // turnSource — which channel this utterance arrived on, in the same provenance
@@ -255,7 +255,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// early. He can be asked a question, walk off, come back and say "да" to a // early. He can be asked a question, walk off, come back and say "да" to a
// confirm that is still parked; computing the notice after that return meant // confirm that is still parked; computing the notice after that return meant
// he answered the confirm and never heard that the older request was let go. // he answered the confirm and never heard that the older request was let go.
expiredNotice := h.clarifyExpiredNotice() expiredNotice := h.clarifyExpiredNotice(ctx)
// 2. confirm turn — if a destructive act is parked, this utterance is its // 2. confirm turn — if a destructive act is parked, this utterance is its
// y/n answer, not a fresh command. Handled before routing so "да" doesn't // y/n answer, not a fresh command. Handled before routing so "да" doesn't
@@ -351,7 +351,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// and park the request (clarify.go); otherwise the replier's canned reply // and park the request (clarify.go); otherwise the replier's canned reply
// stands. // stands.
if dec.Clarify { if dec.Clarify {
if question, asked := h.askClarify(dec); asked { if question, asked := h.askClarify(ctx, dec); asked {
return withNotice(expiredNotice, question) return withNotice(expiredNotice, question)
} }
} }
+5
View File
@@ -380,6 +380,11 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
// is an agenda question and must not. // is an agenda question and must not.
grammars = append(grammars, router.AgendaQueryGrammars()...) grammars = append(grammars, router.AgendaQueryGrammars()...)
grammars = append(grammars, router.ReminderGrammar()) grammars = append(grammars, router.ReminderGrammar())
// Last, and it matches any utterance shape — its Build is the filter. An
// explicit capture marker beats the model, which called it an act and
// rewrote the task text (Vikunja #467). After the rules above because a
// marker never collides with a clock or agenda question.
grammars = append(grammars, router.TaskCaptureGrammar())
return router.New(router.Config{ return router.New(router.Config{
Grammars: grammars, Grammars: grammars,
Classifier: cls, Classifier: cls,
+1 -1
View File
@@ -77,7 +77,7 @@ func (f *fakeCore) MCPServers(context.Context) ([]ipc.MCPServerStatus, error) {
return f.mcpServers, f.mcpErr return f.mcpServers, f.mcpErr
} }
func (f *fakeCore) Chat(_ context.Context, text string) (string, error) { func (f *fakeCore) Chat(_ context.Context, _, text string) (string, error) {
f.chatText = text f.chatText = text
if f.chatErr != nil { if f.chatErr != nil {
return "", f.chatErr return "", f.chatErr
+7 -1
View File
@@ -1678,7 +1678,13 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
http.Redirect(w, r, "/chat", http.StatusSeeOther) http.Redirect(w, r, "/chat", http.StatusSeeOther)
return return
} }
reply, err := core.Chat(r.Context(), text) // One conversation id for the whole web chat, and a different one from
// telegram or the mic. A parked question belongs to the reach that was
// asked; before this, a clarify nobody answered on the web ate the next
// utterance spoken at the mic (Vikunja #466). This server has no
// per-browser session, so every browser tab is the same conversation —
// which is right for a single-owner box.
reply, err := core.Chat(r.Context(), "web", text)
if err != nil { if err != nil {
log.Printf("chat api: %v", err) log.Printf("chat api: %v", err)
http.Redirect(w, r, "/chat", http.StatusSeeOther) http.Redirect(w, r, "/chat", http.StatusSeeOther)
+3 -3
View File
@@ -311,7 +311,7 @@ func TestGate_IpcServer_CheckWiredThroughSocket(t *testing.T) {
if fake.writes != 0 { if fake.writes != 0 {
t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes) t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes)
} }
_, err = cli.Chat(context.Background(), "привет") _, err = cli.Chat(context.Background(), "web", "привет")
if !errors.Is(err, ipc.ErrForbidden) { if !errors.Is(err, ipc.ErrForbidden) {
t.Errorf("wire: chat from unenrolled uid = %v; want ipc.ErrForbidden", err) t.Errorf("wire: chat from unenrolled uid = %v; want ipc.ErrForbidden", err)
} }
@@ -344,7 +344,7 @@ func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) {
t.Fatalf("dial: %v", err) t.Fatalf("dial: %v", err)
} }
t.Cleanup(func() { _ = cli.Close() }) t.Cleanup(func() { _ = cli.Close() })
reply, err := cli.Chat(context.Background(), "привет") reply, err := cli.Chat(context.Background(), "web", "привет")
if err != nil { if err != nil {
t.Fatalf("Chat: %v", err) t.Fatalf("Chat: %v", err)
} }
@@ -373,7 +373,7 @@ func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64,
return int64(r.writes), nil return int64(r.writes), nil
} }
func (r *recordingAPI) Chat(_ context.Context, text string) (string, error) { func (r *recordingAPI) Chat(_ context.Context, _, text string) (string, error) {
r.chats++ r.chats++
return "echo: " + text, nil return "echo: " + text, nil
} }
+13 -2
View File
@@ -593,8 +593,14 @@ type MCPServerStatus struct {
} }
// chatReq / chatResp — text chat round-trip for the IPC Chat method. // chatReq / chatResp — text chat round-trip for the IPC Chat method.
//
// Conversation names the thread this utterance belongs to: a mavweb session, a
// telegram chat. It is opaque to the daemon and only has to be stable for one
// conversation and distinct across them. Empty is allowed and means "the
// unattributed text tap", which is what an old client sends.
type chatReq struct { type chatReq struct {
Text string `json:"text"` Text string `json:"text"`
Conversation string `json:"conversation,omitempty"`
} }
type chatResp struct { type chatResp struct {
Reply string `json:"reply"` Reply string `json:"reply"`
@@ -759,7 +765,12 @@ type CoreAPI interface {
// Chat routes a text utterance through the reactive handler's core path // Chat routes a text utterance through the reactive handler's core path
// (router → dialogue → action → replier) and returns the reply text. // (router → dialogue → action → replier) and returns the reply text.
// No audio or stt/tts — for text channels (mavweb, telegram). // No audio or stt/tts — for text channels (mavweb, telegram).
Chat(ctx context.Context, text string) (string, error) //
// conversation names the thread. A parked clarifying question is held per
// conversation, so an unanswered question on one reach cannot eat the next
// utterance from another (Vikunja #466). Empty means the unattributed text
// tap and is still one conversation of its own, separate from the mic.
Chat(ctx context.Context, conversation, text string) (string, error)
// RecentEvents returns the daemon's unified intake journal, newest first // RecentEvents returns the daemon's unified intake journal, newest first
// (Vikunja #283) — one envelope per thing that arrived, whatever direction // (Vikunja #283) — one envelope per thing that arrived, whatever direction
+2 -2
View File
@@ -623,9 +623,9 @@ func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error {
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil) return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
} }
func (c *Client) Chat(ctx context.Context, text string) (string, error) { func (c *Client) Chat(ctx context.Context, conversation, text string) (string, error) {
var r chatResp var r chatResp
if err := c.call(ctx, MethodChat, chatReq{Text: text}, &r); err != nil { if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil {
return "", err return "", err
} }
return r.Reply, nil return r.Reply, nil
+2 -2
View File
@@ -401,7 +401,7 @@ func TestChatViaClient(t *testing.T) {
} }
t.Cleanup(func() { _ = cli.Close() }) t.Cleanup(func() { _ = cli.Close() })
reply, err := cli.Chat(context.Background(), "привет") reply, err := cli.Chat(context.Background(), "web", "привет")
if err != nil { if err != nil {
t.Fatalf("Chat: %v", err) t.Fatalf("Chat: %v", err)
} }
@@ -417,7 +417,7 @@ type chatTestAPI struct {
UnimplementedCoreAPI UnimplementedCoreAPI
} }
func (a *chatTestAPI) Chat(ctx context.Context, text string) (string, error) { func (a *chatTestAPI) Chat(ctx context.Context, _, text string) (string, error) {
if text == "привет" { if text == "привет" {
return "и тебе привет!", nil return "и тебе привет!", nil
} }
+2 -2
View File
@@ -240,7 +240,7 @@ func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
return newID, mapErr(err) return newID, mapErr(err)
} }
func (a *storeAPI) Chat(ctx context.Context, text string) (string, error) { func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
return "", errors.New("store: chat not available via direct store API") return "", errors.New("store: chat not available via direct store API")
} }
@@ -974,7 +974,7 @@ var methodTable = map[Method]handlerFunc{
return map[string]int64{"new_id": newID}, nil return map[string]int64{"new_id": newID}, nil
}), }),
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) { MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
reply, err := api.Chat(ctx, p.Text) reply, err := api.Chat(ctx, p.Conversation, p.Text)
return chatResp{Reply: reply}, err return chatResp{Reply: reply}, err
}), }),
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) { MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
+1 -1
View File
@@ -141,6 +141,6 @@ func (UnimplementedCoreAPI) MCPServers(ctx context.Context) ([]MCPServerStatus,
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) { func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
return DayPlan{}, ErrNotImplemented return DayPlan{}, ErrNotImplemented
} }
func (UnimplementedCoreAPI) Chat(ctx context.Context, text string) (string, error) { func (UnimplementedCoreAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
return "", ErrNotImplemented return "", ErrNotImplemented
} }
+1
View File
@@ -71,6 +71,7 @@
{ "id": "ru-note-003", "utterance": "заметка про настройку vlan на свитче", "lang": "ru", "intent": "note", "tags": ["homelab"] }, { "id": "ru-note-003", "utterance": "заметка про настройку vlan на свитче", "lang": "ru", "intent": "note", "tags": ["homelab"] },
{ "id": "ru-note-004", "utterance": "запиши идею: гидропоника на балконе", "lang": "ru", "intent": "note" }, { "id": "ru-note-004", "utterance": "запиши идею: гидропоника на балконе", "lang": "ru", "intent": "note" },
{ "id": "ru-note-005", "utterance": "запиши что сосед просил номер электрика", "lang": "ru", "intent": "note" }, { "id": "ru-note-005", "utterance": "запиши что сосед просил номер электрика", "lang": "ru", "intent": "note" },
{ "id": "ru-note-006", "utterance": "добавь в задачи купить молоко", "lang": "ru", "intent": "note", "tags": ["capture"], "note": "an explicit capture marker — the model called it an act and rewrote the payload (Vikunja #467), stage 0 claims it" },
{ "id": "en-note-001", "utterance": "note: rotate the kuma api key", "lang": "en", "intent": "note", "tags": ["homelab"] }, { "id": "en-note-001", "utterance": "note: rotate the kuma api key", "lang": "en", "intent": "note", "tags": ["homelab"] },
{ "id": "ru-sys-001", "utterance": "сколько сейчас времени в киеве", "lang": "ru", "intent": "system", "tags": ["time"] }, { "id": "ru-sys-001", "utterance": "сколько сейчас времени в киеве", "lang": "ru", "intent": "system", "tags": ["time"] },
+48 -1
View File
@@ -1,6 +1,9 @@
package router package router
import "strings" import (
"regexp"
"strings"
)
// Task capture and task listing, matched deterministically (Vikunja #130). // Task capture and task listing, matched deterministically (Vikunja #130).
// //
@@ -201,3 +204,47 @@ func IsTaskListQuery(text string) bool {
} }
return false return false
} }
// TaskCaptureGrammar — stage 0 for an explicit capture marker, so the resident
// model never sees it (Vikunja #467).
//
// Capture was built to ride the note intent, deliberately: #130 said no eighth
// intent, and while the classifier was routing, a note-shaped utterance with a
// marker in it reached actionNote and captureTaskFromNote claimed it there. The
// router pre-empted that. Measured 2026-08-02: "добавь в задачи купить молоко"
// routed act, so captureTaskFromNote was never consulted, the act arm found no
// allowlisted fn, and the gate asked "Что сделать?". Every capture utterance
// tried filed nothing.
//
// The model also rewrote the payload on the way — "купить молоко" came back as
// "сделать покупку молока". A task must read as the words he said, which is a
// second reason to answer this before the model rather than to prompt around
// it.
//
// The marker list is data (task_phrases.json) and the parse strips urgency, so
// the pattern here matches any utterance and the decision is ParseTaskCapture's
// to make — same shape as the wake-word act grammar, which also matches broadly
// and refuses in Build. Intent stays note: the daemon's note path is where
// capture lives, and nothing about the contract with the model changes.
func TaskCaptureGrammar() Grammar {
return Grammar{
Name: "task-capture",
Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`),
Build: func(m []string) (Decision, bool) {
c, ok := ParseTaskCapture(m[1])
if !ok {
return Decision{}, false // not a capture — fall through
}
return Decision{
Stage: 0,
Intent: IntentNote,
Confidence: 1.0,
// The capture text, not the raw utterance: it is what the
// clarify gate reads as the payload. captureTaskFromNote
// re-parses the utterance itself, so the task text comes from
// the same place either way.
Slots: Slots{Text: c.Text},
}, true
},
}
}
+3
View File
@@ -27,6 +27,9 @@
"добавь в список", "добавь в список",
"добавь задачу", "добавь задачу",
"запиши в задачи", "запиши в задачи",
"запиши в список дел",
"запиши в список задач",
"запиши в список",
"запиши задачу", "запиши задачу",
"новая задача", "новая задача",
"поставь задачу", "поставь задачу",
+34
View File
@@ -85,3 +85,37 @@ func TestIsTaskListQuery(t *testing.T) {
} }
} }
} }
// TestTaskCaptureGrammarClaimsTheMarker — the capture marker is answered at
// stage 0, so the model never gets to call it an act (Vikunja #467).
func TestTaskCaptureGrammarClaimsTheMarker(t *testing.T) {
g := TaskCaptureGrammar()
captures := map[string]string{
"добавь в задачи купить молоко": "купить молоко",
"запиши в список дел купить хлеб": "купить хлеб",
"поставь задачу вынести мусор": "вынести мусор",
"добавь в задачи срочно оплатить дом": "оплатить дом",
}
for in, want := range captures {
m := g.Pattern.FindStringSubmatch(in)
if m == nil {
t.Fatalf("%q did not match the grammar pattern", in)
}
d, ok := g.Build(m)
if !ok {
t.Fatalf("%q must be claimed as a capture", in)
}
if d.Intent != IntentNote || d.Slots.Text != want {
t.Errorf("%q → intent=%s text=%q, want note/%q", in, d.Intent, d.Slots.Text, want)
}
}
// Everything without a marker falls through, including a marker with no
// task after it and a question about the list.
for _, in := range []string{"надо бы поспать", "добавь в задачи", "какие у меня задачи?", "перезапусти nginx"} {
if m := g.Pattern.FindStringSubmatch(in); m != nil {
if _, ok := g.Build(m); ok {
t.Errorf("%q must fall through to the cascade", in)
}
}
}
}