Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87d176153a | |||
| 7d4b4ad736 |
+13
-13
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
"добавь в список",
|
"добавь в список",
|
||||||
"добавь задачу",
|
"добавь задачу",
|
||||||
"запиши в задачи",
|
"запиши в задачи",
|
||||||
|
"запиши в список дел",
|
||||||
|
"запиши в список задач",
|
||||||
|
"запиши в список",
|
||||||
"запиши задачу",
|
"запиши задачу",
|
||||||
"новая задача",
|
"новая задача",
|
||||||
"поставь задачу",
|
"поставь задачу",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user