Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d8b0af99d | |||
| a2835bbdf6 |
@@ -1,180 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/dialogue"
|
|
||||||
"github.com/kami/maven/internal/router"
|
|
||||||
)
|
|
||||||
|
|
||||||
// clarifyTTL — how long a parked question stays answerable. Same 90s as the
|
|
||||||
// confirm gate, for the same reason: an answer is a same-breath gesture, and a
|
|
||||||
// stale question must not eat an unrelated later utterance.
|
|
||||||
const clarifyTTL = 90 * time.Second
|
|
||||||
|
|
||||||
// wantedSlots — what each intent needs before she can act on it. First entry is
|
|
||||||
// the one she asks about; the rest are only used to decide act-vs-drop.
|
|
||||||
//
|
|
||||||
// Intents not listed here are never worth a question: note and query act on the
|
|
||||||
// raw utterance, chat and system have nothing to fill in. For those a clarify
|
|
||||||
// decision keeps the canned "не поняла" reply — inventing a question for noise
|
|
||||||
// is worse than admitting she missed it.
|
|
||||||
var wantedSlots = map[router.Intent][]dialogue.Slot{
|
|
||||||
router.IntentReminder: {dialogue.SlotTime},
|
|
||||||
router.IntentFact: {dialogue.SlotKey},
|
|
||||||
router.IntentAct: {dialogue.SlotFn},
|
|
||||||
}
|
|
||||||
|
|
||||||
// clarifyQuestions — one short question per missing slot.
|
|
||||||
//
|
|
||||||
// These are fixed templates, not model output. The resident model is a 0.8B; it
|
|
||||||
// would wander, and a question whose wording changes every time is harder to
|
|
||||||
// answer than a blunt one that always reads the same. They are infinitive
|
|
||||||
// questions, so there is no gender agreement to get wrong; the feminine
|
|
||||||
// self-reference lives in the reply she gives when she drops the request.
|
|
||||||
var clarifyQuestions = map[dialogue.Slot]string{
|
|
||||||
dialogue.SlotTime: "На когда напомнить?",
|
|
||||||
dialogue.SlotKey: "Что записать?",
|
|
||||||
dialogue.SlotFn: "Что сделать?",
|
|
||||||
}
|
|
||||||
|
|
||||||
// clarifyDropped — she asked once, the answer still did not fill the gap, so
|
|
||||||
// the request is gone. Said plainly, once, with no second question.
|
|
||||||
const clarifyDropped = "Не разобрала — скажи целиком, пожалуйста."
|
|
||||||
|
|
||||||
// missingFor returns the slots a decision still needs, most important first.
|
|
||||||
// Empty ⇒ there is nothing identifiable to ask about.
|
|
||||||
func missingFor(dec router.Decision) []dialogue.Slot {
|
|
||||||
return dialogue.StillMissing(wantedSlots[dec.Intent], toDialogueSlots(dec.Slots))
|
|
||||||
}
|
|
||||||
|
|
||||||
// clarifyQuestion picks the one question to ask for a clarify decision. Returns
|
|
||||||
// ("", false) when she has no idea what is missing.
|
|
||||||
//
|
|
||||||
// One question about one thing: if two slots are missing she asks about the
|
|
||||||
// first and lets the rest go. Two questions in a row is an interrogation.
|
|
||||||
func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
|
||||||
missing := missingFor(dec)
|
|
||||||
if len(missing) == 0 {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
q, ok := clarifyQuestions[missing[0]]
|
|
||||||
if !ok {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
return missing[0], q, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// askClarify parks the request and returns the question to ask instead of the
|
|
||||||
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
|
|
||||||
// the caller falls back to the canned reply.
|
|
||||||
func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
|
||||||
if h.clarifyStore == nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
slot, question, ok := clarifyQuestion(dec)
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
|
||||||
Intent: dialogue.Intent(dec.Intent),
|
|
||||||
Slots: toDialogueSlots(dec.Slots),
|
|
||||||
Missing: []dialogue.Slot{slot},
|
|
||||||
Utterance: dec.Utterance,
|
|
||||||
Asked: h.now(),
|
|
||||||
TTL: clarifyTTL,
|
|
||||||
Attempts: 1, // asked once; MaxAttempts is 1, so there is no second ask
|
|
||||||
})
|
|
||||||
log.Printf("voice: clarify — asked about %s for intent=%s", slot, dec.Intent)
|
|
||||||
return question, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveClarifyAnswer reads an utterance as the answer to a parked question.
|
|
||||||
// Returns ("", false) when no live question is parked (or it expired), so the
|
|
||||||
// caller routes the utterance normally as a fresh request. Sibling of
|
|
||||||
// resolveConfirm and checked in the same place.
|
|
||||||
//
|
|
||||||
// The answer is parsed with the same extractor the router uses, for the intent
|
|
||||||
// she parked — no second parser. If it still does not fill the gap the request
|
|
||||||
// is dropped: she does not ask again.
|
|
||||||
func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) (string, bool) {
|
|
||||||
if h.clarifyStore == nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
|
||||||
if q == nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
// One shot either way: the question is consumed whether or not the answer
|
|
||||||
// works, so a failed answer can't leave the question armed.
|
|
||||||
h.clarifyStore.Delete(voiceDialogueID)
|
|
||||||
|
|
||||||
intent := router.Intent(q.Intent)
|
|
||||||
answer := h.extractor.Extract(ctx, intent, text, h.now())
|
|
||||||
merged := q.Answer(text, toDialogueSlots(answer))
|
|
||||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
|
||||||
log.Printf("voice: clarify — answer %q did not fill %v, dropping", text, q.Missing)
|
|
||||||
return clarifyDropped, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild the decision as if it had routed cleanly, then run it down the
|
|
||||||
// normal path. Clarify is deliberately false and the intent is unchanged:
|
|
||||||
// filling in an argument never grants authority, so the completed decision
|
|
||||||
// still meets the allowlist and the destructive-act confirm gate in
|
|
||||||
// applyAction exactly like any other decision.
|
|
||||||
dec := router.Decision{
|
|
||||||
Utterance: q.Utterance,
|
|
||||||
Stage: 2,
|
|
||||||
Intent: intent,
|
|
||||||
Slots: applyDialogueSlots(answer, merged),
|
|
||||||
}
|
|
||||||
return h.finishClarified(ctx, dec), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// finishClarified runs a completed decision through the same steps a freshly
|
|
||||||
// routed one takes: remember the turn, act, then phrase.
|
|
||||||
func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string {
|
|
||||||
if h.dialogueSessions != nil {
|
|
||||||
now := h.now()
|
|
||||||
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
|
||||||
dec = followUpMerge(prev, dec, now)
|
|
||||||
h.rememberTurn(prev, dec, now)
|
|
||||||
}
|
|
||||||
reply := h.applyAction(ctx, dec)
|
|
||||||
if reply == "" {
|
|
||||||
reply = h.replier.Reply(dec)
|
|
||||||
}
|
|
||||||
return reply
|
|
||||||
}
|
|
||||||
|
|
||||||
// rememberTurn stores this turn as the dialogue session the next follow-up
|
|
||||||
// inherits from, carrying up to 4 prior turns of history for anaphora. Capped so
|
|
||||||
// one long conversation can't grow the session unboundedly.
|
|
||||||
func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decision, now time.Time) {
|
|
||||||
var history []dialogue.Turn
|
|
||||||
if prev != nil {
|
|
||||||
history = append(history, dialogue.Turn{
|
|
||||||
Intent: prev.Intent,
|
|
||||||
Slots: prev.Slots,
|
|
||||||
Text: prev.Slots.Text,
|
|
||||||
})
|
|
||||||
maxHist := len(prev.History)
|
|
||||||
if maxHist > 3 {
|
|
||||||
maxHist = 3
|
|
||||||
}
|
|
||||||
history = append(history, prev.History[:maxHist]...)
|
|
||||||
}
|
|
||||||
ttl := time.Duration(0) // use the store default (2 min)
|
|
||||||
if dec.Intent == router.IntentChat {
|
|
||||||
ttl = 15 * time.Minute // conversational turns should last longer
|
|
||||||
}
|
|
||||||
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{
|
|
||||||
Intent: dialogue.Intent(dec.Intent),
|
|
||||||
Slots: toDialogueSlots(dec.Slots),
|
|
||||||
Timestamp: now,
|
|
||||||
TTL: ttl,
|
|
||||||
History: history,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/dialogue"
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
|
||||||
"github.com/kami/maven/internal/router"
|
|
||||||
"github.com/kami/maven/internal/store"
|
|
||||||
"github.com/kami/maven/internal/tool"
|
|
||||||
"github.com/kami/maven/internal/voice"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newClarifyHandler builds a handler with the clarify path wired and no model:
|
|
||||||
// stub date parser, the real fact parser, and a matcher over whatever tools the
|
|
||||||
// test enabled. `now` is fixed so TTL behaviour is testable.
|
|
||||||
func newClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) {
|
|
||||||
t.Helper()
|
|
||||||
st := newTestStore(t)
|
|
||||||
api := ipc.NewStoreAPI(st)
|
|
||||||
now := time.Date(2026, 7, 31, 9, 0, 0, 0, time.UTC)
|
|
||||||
matcher := tool.NewMatcher(api)
|
|
||||||
h := &reactiveHandler{
|
|
||||||
api: api,
|
|
||||||
dataStore: st,
|
|
||||||
tools: tool.NewExecutor(api, 2*time.Second),
|
|
||||||
matcher: matcher,
|
|
||||||
replier: voice.NewStubReplier(),
|
|
||||||
now: func() time.Time { return now },
|
|
||||||
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
|
|
||||||
clarifyStore: dialogue.NewClarifyStore(clarifyTTL),
|
|
||||||
extractor: router.Extractor{
|
|
||||||
Time: router.StubDateTimeParser{},
|
|
||||||
Acts: matcher,
|
|
||||||
Facts: router.DefaultFactParser{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return h, st, &now
|
|
||||||
}
|
|
||||||
|
|
||||||
func clarifyDec(intent router.Intent, slots router.Slots, utterance string) router.Decision {
|
|
||||||
return router.Decision{Utterance: utterance, Stage: 3, Intent: intent, Slots: slots, Clarify: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClarifyQuestionForMissingSlot pins which question goes with which gap, and
|
|
||||||
// which intents get no question at all.
|
|
||||||
func TestClarifyQuestionForMissingSlot(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
dec router.Decision
|
|
||||||
want string
|
|
||||||
asked bool
|
|
||||||
}{
|
|
||||||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "На когда напомнить?", true},
|
|
||||||
{"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true},
|
|
||||||
{"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true},
|
|
||||||
{"reminder that already has a time", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "", false},
|
|
||||||
{"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false},
|
|
||||||
{"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
_, got, asked := clarifyQuestion(tc.dec)
|
|
||||||
if asked != tc.asked || got != tc.want {
|
|
||||||
t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, got, asked, tc.want, tc.asked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClarifyReminderCompletesOnAnswer is the whole point of the feature: she
|
|
||||||
// asks for the missing time and the answer creates the reminder.
|
|
||||||
func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
h, st, _ := newClarifyHandler(t)
|
|
||||||
|
|
||||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
|
||||||
if !asked || question != "На когда напомнить?" {
|
|
||||||
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
|
||||||
}
|
|
||||||
|
|
||||||
reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00")
|
|
||||||
if !handled {
|
|
||||||
t.Fatal("the answer to an open question must be consumed as an answer")
|
|
||||||
}
|
|
||||||
if reply == clarifyDropped {
|
|
||||||
t.Fatalf("a good answer must not drop the request: %q", reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
|
||||||
if err != nil || len(reminders) != 1 {
|
|
||||||
t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(reminders[0].Payload, "маме") {
|
|
||||||
t.Fatalf("the reminder lost the original request: %q", reminders[0].Payload)
|
|
||||||
}
|
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
|
||||||
t.Fatal("the question must be cleared once answered")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClarifyFactCompletesOnAnswer — the fact path, where the answer carries
|
|
||||||
// both the key and the value.
|
|
||||||
func TestClarifyFactCompletesOnAnswer(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
h, st, _ := newClarifyHandler(t)
|
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
|
||||||
t.Fatal("a fact with no key should be asked about")
|
|
||||||
}
|
|
||||||
if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyDropped {
|
|
||||||
t.Fatalf("answer should complete the fact, handled=%v reply=%q", handled, reply)
|
|
||||||
}
|
|
||||||
if fact, err := st.LatestFact(ctx, "water"); err != nil || fact.Key != "water" {
|
|
||||||
t.Fatalf("clarified fact was not written: fact=%+v err=%v", fact, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClarifyAnswerAfterTTLIsANewRequest — a late answer is not an answer.
|
|
||||||
func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
h, st, now := newClarifyHandler(t)
|
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
|
||||||
t.Fatal("expected a question")
|
|
||||||
}
|
|
||||||
*now = now.Add(clarifyTTL + time.Second)
|
|
||||||
|
|
||||||
if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); handled {
|
|
||||||
t.Fatalf("an answer past the TTL must fall through to normal routing, got %q", reply)
|
|
||||||
}
|
|
||||||
if reminders, err := st.DueReminders(ctx, now.Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
|
||||||
t.Fatalf("expired question must not create anything: reminders=%v err=%v", reminders, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClarifyUnclearAnswerDropsWithoutAskingAgain — MaxAttempts is 1.
|
|
||||||
func TestClarifyUnclearAnswerDropsWithoutAskingAgain(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
h, st, _ := newClarifyHandler(t)
|
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
|
||||||
t.Fatal("expected a question")
|
|
||||||
}
|
|
||||||
reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю")
|
|
||||||
if !handled || reply != clarifyDropped {
|
|
||||||
t.Fatalf("an unclear answer should drop the request, handled=%v reply=%q", handled, reply)
|
|
||||||
}
|
|
||||||
if strings.Contains(reply, "?") {
|
|
||||||
t.Fatalf("she must not ask a second question: %q", reply)
|
|
||||||
}
|
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
|
||||||
t.Fatal("a dropped request must leave no armed question")
|
|
||||||
}
|
|
||||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
|
||||||
t.Fatalf("a dropped request must not create anything: reminders=%v err=%v", reminders, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClarifiedActOffAllowlistIsStillRefused — clarification fills in an
|
|
||||||
// argument, it never grants authority.
|
|
||||||
func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
h, st, _ := newClarifyHandler(t)
|
|
||||||
marker := filepath.Join(t.TempDir(), "not-allowed-ran")
|
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
|
||||||
t.Fatal("an act with no fn should be asked about")
|
|
||||||
}
|
|
||||||
reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker)
|
|
||||||
if !handled {
|
|
||||||
t.Fatal("the answer should be consumed")
|
|
||||||
}
|
|
||||||
if strings.Contains(reply, "готово") {
|
|
||||||
t.Fatalf("an act that is not on the allowlist must not report success: %q", reply)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("a clarified act off the allowlist ran anyway: %v", err)
|
|
||||||
}
|
|
||||||
if tools, err := st.ListTools(ctx, "enabled"); err != nil || len(tools) != 0 {
|
|
||||||
t.Fatalf("clarify must not enable a tool: tools=%+v err=%v", tools, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClarifiedDestructiveActStillNeedsConfirm — the confirm gate survives the
|
|
||||||
// clarify path.
|
|
||||||
func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
h, st, _ := newClarifyHandler(t)
|
|
||||||
marker := filepath.Join(t.TempDir(), "destructive-ran")
|
|
||||||
if err := st.EnableTool(ctx, "delete_backups", []string{"touch", marker}, true, "test", h.now()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
|
||||||
t.Fatal("expected a question")
|
|
||||||
}
|
|
||||||
reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups")
|
|
||||||
if !handled {
|
|
||||||
t.Fatal("the answer should be consumed")
|
|
||||||
}
|
|
||||||
if !strings.Contains(reply, "да") || h.pending == nil {
|
|
||||||
t.Fatalf("a clarified destructive act must still park a confirm: reply=%q pending=%+v", reply, h.pending)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("a clarified destructive act ran before confirmation: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNoQuestionWhenNothingIsMissing — noise keeps the canned reply, so she
|
|
||||||
// never invents a question for nothing.
|
|
||||||
func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
|
||||||
h, _, _ := newClarifyHandler(t)
|
|
||||||
for _, dec := range []router.Decision{
|
|
||||||
clarifyDec(router.IntentChat, router.Slots{Text: "эм"}, "эм"),
|
|
||||||
clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"),
|
|
||||||
clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."),
|
|
||||||
} {
|
|
||||||
if question, asked := h.askClarify(dec); asked {
|
|
||||||
t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
|
||||||
t.Fatal("noise must not park a question")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNoPendingQuestionFallsThrough — with nothing parked, an utterance routes
|
|
||||||
// normally.
|
|
||||||
func TestNoPendingQuestionFallsThrough(t *testing.T) {
|
|
||||||
h, _, _ := newClarifyHandler(t)
|
|
||||||
if reply, handled := h.resolveClarifyAnswer(context.Background(), "напомни в 11:00"); handled {
|
|
||||||
t.Fatalf("no open question ⇒ must not be treated as an answer, got %q", reply)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+53
-43
@@ -226,8 +226,6 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
|
|
||||||
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
||||||
dialogueSessions := dialogue.NewSessionStore(2 * time.Minute)
|
dialogueSessions := dialogue.NewSessionStore(2 * time.Minute)
|
||||||
clarifyStore := dialogue.NewClarifyStore(clarifyTTL)
|
|
||||||
timeParser := router.NewPythonDateParser()
|
|
||||||
|
|
||||||
// ----- replier (LLM-backed when the engine is on, Stub floor otherwise) -----
|
// ----- replier (LLM-backed when the engine is on, Stub floor otherwise) -----
|
||||||
replier := voice.Replier(voice.NewStubReplier())
|
replier := voice.Replier(voice.NewStubReplier())
|
||||||
@@ -252,10 +250,8 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
memStore: memStore,
|
memStore: memStore,
|
||||||
dataStore: dataStore,
|
dataStore: dataStore,
|
||||||
dialogueSessions: dialogueSessions,
|
dialogueSessions: dialogueSessions,
|
||||||
clarifyStore: clarifyStore,
|
|
||||||
extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},
|
|
||||||
queryMinScore: cfg.Voice.QueryMinScore,
|
queryMinScore: cfg.Voice.QueryMinScore,
|
||||||
timeParser: timeParser,
|
timeParser: router.NewPythonDateParser(),
|
||||||
ecosystem: eco,
|
ecosystem: eco,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,14 +304,6 @@ type reactiveHandler struct {
|
|||||||
// box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over.
|
// box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over.
|
||||||
dialogueSessions *dialogue.SessionStore
|
dialogueSessions *dialogue.SessionStore
|
||||||
|
|
||||||
// clarifyStore parks the request behind an open question she asked (see
|
|
||||||
// clarify.go). nil ⇒ she falls back to the canned "не поняла" reply.
|
|
||||||
clarifyStore *dialogue.ClarifyStore
|
|
||||||
|
|
||||||
// extractor parses the answer to an open question, with the same parsers
|
|
||||||
// the router's own stage-2 uses.
|
|
||||||
extractor router.Extractor
|
|
||||||
|
|
||||||
// pending destructive-act confirmation. A destructive act replies with a
|
// pending destructive-act confirmation. A destructive act replies with a
|
||||||
// "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as
|
// "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as
|
||||||
// the y/n answer. ponytail: single slot, single-user box — a second act
|
// the y/n answer. ponytail: single slot, single-user box — a second act
|
||||||
@@ -390,13 +378,6 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
|
|||||||
return h.reply(ctx, reply, nil)
|
return h.reply(ctx, reply, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1b2. clarify answer — if she asked a question last turn, this utterance is
|
|
||||||
// its answer, not a fresh command. After the confirm check: a y/n gate is
|
|
||||||
// armed by her own prompt and is the narrower claim on the utterance.
|
|
||||||
if reply, handled := h.resolveClarifyAnswer(ctx, text); handled {
|
|
||||||
return h.reply(ctx, reply, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1c. quiet-hours toggle — keyword match, not classifier-dependent.
|
// 1c. quiet-hours toggle — keyword match, not classifier-dependent.
|
||||||
// "тихий режим" / "quiet on" would route through the classifier
|
// "тихий режим" / "quiet on" would route through the classifier
|
||||||
// unreliably (it's a command, not a free-form query), so we match it
|
// unreliably (it's a command, not a free-form query), so we match it
|
||||||
@@ -426,16 +407,34 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
|
|||||||
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
||||||
dec = followUpMerge(prev, dec, now)
|
dec = followUpMerge(prev, dec, now)
|
||||||
if !dec.Clarify {
|
if !dec.Clarify {
|
||||||
h.rememberTurn(prev, dec, now)
|
// Build history: carry over up to 4 prior turns for cross-intent
|
||||||
}
|
// reference. The most recent prior turn is prepended to history.
|
||||||
}
|
var history []dialogue.Turn
|
||||||
|
if prev != nil {
|
||||||
// 2c. clarify — she is not sure. If one named thing is missing, ask about it
|
history = append(history, dialogue.Turn{
|
||||||
// and park the request (clarify.go); otherwise the replier's canned reply
|
Intent: prev.Intent,
|
||||||
// stands.
|
Slots: prev.Slots,
|
||||||
if dec.Clarify {
|
Text: prev.Slots.Text, // the prior turn's utterance
|
||||||
if question, asked := h.askClarify(dec); asked {
|
})
|
||||||
return h.reply(ctx, question, nil)
|
// Cap history depth so one long conversation can't grow
|
||||||
|
// the session unboundedly.
|
||||||
|
maxHist := len(prev.History)
|
||||||
|
if maxHist > 3 {
|
||||||
|
maxHist = 3
|
||||||
|
}
|
||||||
|
history = append(history, prev.History[:maxHist]...)
|
||||||
|
}
|
||||||
|
ttl := time.Duration(0) // use default (2 min)
|
||||||
|
if dec.Intent == router.IntentChat {
|
||||||
|
ttl = 15 * time.Minute // conversational turns should last longer
|
||||||
|
}
|
||||||
|
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{
|
||||||
|
Intent: dialogue.Intent(dec.Intent),
|
||||||
|
Slots: toDialogueSlots(dec.Slots),
|
||||||
|
Timestamp: now,
|
||||||
|
TTL: ttl,
|
||||||
|
History: history,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,11 +465,6 @@ func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
|
|||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1b2. clarify answer — same check as HandlePushToTalk.
|
|
||||||
if reply, handled := h.resolveClarifyAnswer(ctx, text); handled {
|
|
||||||
return reply
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. router — classify the utterance.
|
// 2. router — classify the utterance.
|
||||||
dec, err := h.router.Route(ctx, text, h.now())
|
dec, err := h.router.Route(ctx, text, h.now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -488,14 +482,30 @@ func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
|
|||||||
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
||||||
dec = followUpMerge(prev, dec, now)
|
dec = followUpMerge(prev, dec, now)
|
||||||
if !dec.Clarify {
|
if !dec.Clarify {
|
||||||
h.rememberTurn(prev, dec, now)
|
var history []dialogue.Turn
|
||||||
}
|
if prev != nil {
|
||||||
}
|
history = append(history, dialogue.Turn{
|
||||||
|
Intent: prev.Intent,
|
||||||
// 2c. clarify — same as HandlePushToTalk: ask about the one missing thing.
|
Slots: prev.Slots,
|
||||||
if dec.Clarify {
|
Text: prev.Slots.Text,
|
||||||
if question, asked := h.askClarify(dec); asked {
|
})
|
||||||
return question
|
maxHist := len(prev.History)
|
||||||
|
if maxHist > 3 {
|
||||||
|
maxHist = 3
|
||||||
|
}
|
||||||
|
history = append(history, prev.History[:maxHist]...)
|
||||||
|
}
|
||||||
|
ttl := time.Duration(0)
|
||||||
|
if dec.Intent == router.IntentChat {
|
||||||
|
ttl = 15 * time.Minute
|
||||||
|
}
|
||||||
|
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{
|
||||||
|
Intent: dialogue.Intent(dec.Intent),
|
||||||
|
Slots: toDialogueSlots(dec.Slots),
|
||||||
|
Timestamp: now,
|
||||||
|
TTL: ttl,
|
||||||
|
History: history,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -639,11 +639,11 @@ func TestDispatchRecurringReminderReschedules(t *testing.T) {
|
|||||||
// ----------------------------- durable outbox --------------------------------
|
// ----------------------------- durable outbox --------------------------------
|
||||||
|
|
||||||
type outboxAttempt struct {
|
type outboxAttempt struct {
|
||||||
kind, rule string
|
kind, rule string
|
||||||
reminderID int64
|
reminderID int64
|
||||||
channel, hash string
|
channel, hash string
|
||||||
status string
|
status string
|
||||||
begunAt, doneAt time.Time
|
begunAt, doneAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// fakeOutbox — an in-memory Outbox that also lets a test simulate a crash
|
// fakeOutbox — an in-memory Outbox that also lets a test simulate a crash
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// panicSink — a sink that dies mid-send. Models the ugly case: the process is
|
||||||
|
// still alive, so startup reconciliation will not run, but the attempt row was
|
||||||
|
// already begun.
|
||||||
|
type panicSink struct{ calls int }
|
||||||
|
|
||||||
|
func (p *panicSink) Send(_ context.Context, _ Sendable) error {
|
||||||
|
p.calls++
|
||||||
|
panic("sink exploded mid-send")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- voice fallthrough, per severity -------------------
|
||||||
|
|
||||||
|
// TestVoiceNoSessionFallthroughLeavesOutboxTrail — the fallthrough must be
|
||||||
|
// visible in the ledger too: the voice attempt closes as failed and the away
|
||||||
|
// attempt is a separate row, so an operator can see the reroute happened.
|
||||||
|
func TestVoiceNoSessionFallthroughLeavesOutboxTrail(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
sev loop.Severity
|
||||||
|
wantAt []string // channel per outbox attempt, in order
|
||||||
|
wantEnd []string // status per attempt, in order
|
||||||
|
}{
|
||||||
|
{"sev3 falls through to ntfy", loop.Sev3,
|
||||||
|
[]string{"voice", "ntfy"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
||||||
|
{"sev4 falls through to telegram", loop.Sev4,
|
||||||
|
[]string{"voice", "telegram"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
||||||
|
{"sev1 does not fall through", loop.Sev1,
|
||||||
|
[]string{"voice"}, []string{store.DeliveryFailed}},
|
||||||
|
{"sev2 does not fall through", loop.Sev2,
|
||||||
|
[]string{"voice"}, []string{store.DeliveryFailed}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
voice := &fakeSink{err: ErrVoiceNoSession}
|
||||||
|
ntfy, telegram := &fakeSink{}, &fakeSink{}
|
||||||
|
ob := &fakeOutbox{}
|
||||||
|
d := NewDispatcher(Config{
|
||||||
|
Voice: voice, Ntfy: ntfy, Telegram: telegram,
|
||||||
|
Ack: newFakeAck(), Outbox: ob,
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||||
|
Candidate: candidate("some_rule", c.sev, store.Present),
|
||||||
|
Body: "detail", Summary: "short",
|
||||||
|
}, refNow()); err != nil {
|
||||||
|
t.Fatalf("dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if len(ob.attempts) != len(c.wantAt) {
|
||||||
|
t.Fatalf("want %d outbox attempts, got %d (%+v)", len(c.wantAt), len(ob.attempts), ob.attempts)
|
||||||
|
}
|
||||||
|
for i, a := range ob.attempts {
|
||||||
|
if a.channel != c.wantAt[i] || a.status != c.wantEnd[i] {
|
||||||
|
t.Fatalf("attempt %d: want %s/%s, got %s/%s", i, c.wantAt[i], c.wantEnd[i], a.channel, a.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// care severities must not reach an away channel — that would
|
||||||
|
// defeat the drop rule.
|
||||||
|
if c.sev <= loop.Sev2 && (len(ntfy.sends) != 0 || len(telegram.sends) != 0) {
|
||||||
|
t.Fatalf("care nudge escaped to an away channel: ntfy=%d telegram=%d",
|
||||||
|
len(ntfy.sends), len(telegram.sends))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- crash between Begin and Complete ------------------
|
||||||
|
|
||||||
|
// openTestStore — a real store on a temp file. The reconciliation promise is a
|
||||||
|
// SQL promise, so a fake would only test the fake.
|
||||||
|
func openTestStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "maven.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// attemptStatus reads one attempt row back. Returns ok=false when the row is
|
||||||
|
// gone, which would itself be a broken promise (a dropped attempt).
|
||||||
|
func attemptStatus(t *testing.T, st *store.Store, id int64) (status string, completed bool, ok bool) {
|
||||||
|
t.Helper()
|
||||||
|
tx, err := st.DB(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read tx: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
var completedTS *int64
|
||||||
|
err = tx.QueryRowContext(context.Background(),
|
||||||
|
`SELECT status, completed_ts FROM delivery_attempts WHERE id = ?`, id).Scan(&status, &completedTS)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
return status, completedTS != nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCrashBetweenBeginAndCompleteBecomesUnknown — simulate the crash window:
|
||||||
|
// Begin lands, the process dies before Complete. Startup reconciliation must
|
||||||
|
// turn that row into "unknown" — neither resent nor dropped, because Maven
|
||||||
|
// cannot know whether the message left the box.
|
||||||
|
func TestCrashBetweenBeginAndCompleteBecomesUnknown(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
sink := &fakeSink{}
|
||||||
|
|
||||||
|
// the crash: intent recorded, no completion.
|
||||||
|
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "telegram", "hash", refNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("begin: %v", err)
|
||||||
|
}
|
||||||
|
if s, _, ok := attemptStatus(t, st, id); !ok || s != store.DeliveryPending {
|
||||||
|
t.Fatalf("before reconcile: want pending, got %q ok=%v", s, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// restart.
|
||||||
|
n, err := st.ReconcileStaleDeliveryAttempts(ctx, refNow().Add(time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reconcile: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("want 1 row reconciled, got %d", n)
|
||||||
|
}
|
||||||
|
s, completed, ok := attemptStatus(t, st, id)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("reconciliation dropped the row; the promise is it is never dropped")
|
||||||
|
}
|
||||||
|
if s != store.DeliveryUnknown {
|
||||||
|
t.Fatalf("want status unknown, got %q", s)
|
||||||
|
}
|
||||||
|
if !completed {
|
||||||
|
t.Fatal("reconciled row should carry a completed_ts")
|
||||||
|
}
|
||||||
|
// not resent: reconciliation is bookkeeping only, it must never push.
|
||||||
|
if len(sink.sends) != 0 {
|
||||||
|
t.Fatalf("reconciliation must not resend, got %d sends", len(sink.sends))
|
||||||
|
}
|
||||||
|
|
||||||
|
// idempotent: a second restart must not churn the row again.
|
||||||
|
n2, err := st.ReconcileStaleDeliveryAttempts(ctx, refNow().Add(2*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reconcile again: %v", err)
|
||||||
|
}
|
||||||
|
if n2 != 0 {
|
||||||
|
t.Fatalf("second reconcile should find nothing, got %d", n2)
|
||||||
|
}
|
||||||
|
if s2, _, _ := attemptStatus(t, st, id); s2 != store.DeliveryUnknown {
|
||||||
|
t.Fatalf("unknown must stay unknown, got %q", s2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownIsNeverResolvedToSentOrFailed — the "never guess" half of the
|
||||||
|
// promise: nothing may quietly turn an unknown into a definite outcome.
|
||||||
|
func TestUnknownIsNeverResolvedToSentOrFailed(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "telegram", "hash", refNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("begin: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.ReconcileStaleDeliveryAttempts(ctx, refNow()); err != nil {
|
||||||
|
t.Fatalf("reconcile: %v", err)
|
||||||
|
}
|
||||||
|
// a late Complete from the old in-flight send must not win.
|
||||||
|
if err := st.CompleteDeliveryAttempt(ctx, id, store.DeliverySent, refNow().Add(time.Minute)); err != nil {
|
||||||
|
t.Fatalf("late complete: %v", err)
|
||||||
|
}
|
||||||
|
if s, _, _ := attemptStatus(t, st, id); s != store.DeliveryUnknown {
|
||||||
|
t.Fatalf("late complete overwrote an unknown outcome: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- the boring failure modes --------------------------
|
||||||
|
|
||||||
|
// TestSendTimeoutResolvesTheAttempt — a send that times out is a definite
|
||||||
|
// failure from Maven's side, so the row must not be left pending.
|
||||||
|
func TestSendTimeoutResolvesTheAttempt(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // the deadline already blew
|
||||||
|
ob := &fakeOutbox{}
|
||||||
|
d := NewDispatcher(Config{Ntfy: &fakeSink{err: context.DeadlineExceeded}, Outbox: ob})
|
||||||
|
|
||||||
|
if _, err := d.DispatchNudge(ctx, PhrasedNudge{
|
||||||
|
Candidate: candidate("cert_expiring", loop.Sev3, store.Away),
|
||||||
|
Body: "detail", Summary: "short",
|
||||||
|
}, refNow()); err == nil {
|
||||||
|
t.Fatal("want a timeout error to propagate")
|
||||||
|
}
|
||||||
|
if len(ob.attempts) != 1 || ob.attempts[0].status != store.DeliveryFailed {
|
||||||
|
t.Fatalf("timed-out send must close the attempt as failed, got %+v", ob.attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteFailureLeavesRowPendingForReconciliation — if Complete itself
|
||||||
|
// fails, the row stays pending on purpose. That is the correct ambiguous state
|
||||||
|
// and startup reconciliation is what resolves it.
|
||||||
|
func TestCompleteFailureLeavesRowPendingForReconciliation(t *testing.T) {
|
||||||
|
ob := &fakeOutbox{completeErr: errors.New("db busy")}
|
||||||
|
d := NewDispatcher(Config{Ntfy: &fakeSink{}, Outbox: ob})
|
||||||
|
|
||||||
|
if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||||
|
Candidate: candidate("cert_expiring", loop.Sev3, store.Away),
|
||||||
|
Body: "detail", Summary: "short",
|
||||||
|
}, refNow()); err != nil {
|
||||||
|
t.Fatalf("a failed outbox complete must not fail the dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if len(ob.attempts) != 1 || ob.attempts[0].status != store.DeliveryPending {
|
||||||
|
t.Fatalf("want the row left pending, got %+v", ob.attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPanicMidSendResolvesTheAttempt — a sink that panics leaves the attempt
|
||||||
|
// pending forever while the process keeps running: the dispatcher has no
|
||||||
|
// recover, and reconciliation only runs at startup. Written to the promise
|
||||||
|
// ("never silently resent or dropped" implies every attempt gets resolved),
|
||||||
|
// skipped because the code does not keep it.
|
||||||
|
func TestPanicMidSendResolvesTheAttempt(t *testing.T) {
|
||||||
|
t.Skip("real gap: dispatcher.go:168 has no recover around Send, so a panicking sink leaves a permanent pending row (reconciliation only runs at startup, cmd/mavend/main.go:330)")
|
||||||
|
|
||||||
|
ob := &fakeOutbox{}
|
||||||
|
d := NewDispatcher(Config{Ntfy: &panicSink{}, Outbox: ob})
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
|
_, _ = d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||||
|
Candidate: candidate("cert_expiring", loop.Sev3, store.Away),
|
||||||
|
Body: "detail", Summary: "short",
|
||||||
|
}, refNow())
|
||||||
|
}()
|
||||||
|
if len(ob.attempts) != 1 || ob.attempts[0].status == store.DeliveryPending {
|
||||||
|
t.Fatalf("a panic mid-send must still resolve the attempt, got %+v", ob.attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file walks every cell of the DESIGN.md § "Delivery / channel routing"
|
||||||
|
// table, once as the pure table and once through the dispatcher, so a change
|
||||||
|
// to either side has to break a named cell.
|
||||||
|
//
|
||||||
|
// present away
|
||||||
|
// sev1-2 (care) voice drop
|
||||||
|
// sev3 (soft) voice ntfy, once
|
||||||
|
// sev4 (hard) voice + ntfy telegram, repeat til ack
|
||||||
|
|
||||||
|
type tableCell struct {
|
||||||
|
name string
|
||||||
|
sev loop.Severity
|
||||||
|
presence store.Bucket
|
||||||
|
want []Channel
|
||||||
|
}
|
||||||
|
|
||||||
|
func allTableCells() []tableCell {
|
||||||
|
return []tableCell{
|
||||||
|
{"sev1 present", loop.Sev1, store.Present, []Channel{ChannelVoice}},
|
||||||
|
{"sev2 present", loop.Sev2, store.Present, []Channel{ChannelVoice}},
|
||||||
|
{"sev3 present", loop.Sev3, store.Present, []Channel{ChannelVoice}},
|
||||||
|
{"sev4 present", loop.Sev4, store.Present, []Channel{ChannelVoice, ChannelNtfy}},
|
||||||
|
{"sev1 away", loop.Sev1, store.Away, []Channel{ChannelDrop}},
|
||||||
|
{"sev2 away", loop.Sev2, store.Away, []Channel{ChannelDrop}},
|
||||||
|
{"sev3 away", loop.Sev3, store.Away, []Channel{ChannelNtfy}},
|
||||||
|
{"sev4 away", loop.Sev4, store.Away, []Channel{ChannelTelegram}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameChannels(got, want []Channel) bool {
|
||||||
|
if len(got) != len(want) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelsForEveryTableCell(t *testing.T) {
|
||||||
|
for _, c := range allTableCells() {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := ChannelsFor(c.sev, c.presence)
|
||||||
|
if !sameChannels(got, c.want) {
|
||||||
|
t.Fatalf("%s: want %v, got %v", c.name, c.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDispatchNudgeEveryTableCell — the same eight cells end to end: exactly
|
||||||
|
// the wanted channels get a send, and every other channel gets none.
|
||||||
|
func TestDispatchNudgeEveryTableCell(t *testing.T) {
|
||||||
|
for _, c := range allTableCells() {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
voice, ntfy, telegram := &fakeSink{}, &fakeSink{}, &fakeSink{}
|
||||||
|
rec := &fakeNudgeRecorder{}
|
||||||
|
d := NewDispatcher(Config{
|
||||||
|
Voice: voice, Ntfy: ntfy, Telegram: telegram,
|
||||||
|
Ack: newFakeAck(), Nudges: rec,
|
||||||
|
})
|
||||||
|
|
||||||
|
out, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||||
|
Candidate: candidate("some_rule", c.sev, c.presence),
|
||||||
|
Body: "full detail body",
|
||||||
|
Summary: "short form",
|
||||||
|
}, refNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dispatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := map[Channel]int{
|
||||||
|
ChannelVoice: len(voice.sends),
|
||||||
|
ChannelNtfy: len(ntfy.sends),
|
||||||
|
ChannelTelegram: len(telegram.sends),
|
||||||
|
}
|
||||||
|
for ch, n := range sent {
|
||||||
|
want := 0
|
||||||
|
for _, w := range c.want {
|
||||||
|
if w == ch {
|
||||||
|
want = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != want {
|
||||||
|
t.Fatalf("%s: channel %s got %d sends, want %d", c.name, ch, n, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// one dispatch and one nudge row per real (non-drop) channel.
|
||||||
|
wantDispatches := 0
|
||||||
|
for _, w := range c.want {
|
||||||
|
if w != ChannelDrop {
|
||||||
|
wantDispatches++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) != wantDispatches {
|
||||||
|
t.Fatalf("%s: want %d dispatches, got %d", c.name, wantDispatches, len(out))
|
||||||
|
}
|
||||||
|
if len(rec.rows) != wantDispatches {
|
||||||
|
t.Fatalf("%s: want %d nudge rows, got %d", c.name, wantDispatches, len(rec.rows))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSev3AwayIsNtfyExactlyOnce — "ntfy, once": one send, and nothing on the
|
||||||
|
// sendable asks for a repeat, so the daemon's repeat driver has no reason to
|
||||||
|
// pick it up.
|
||||||
|
func TestSev3AwayIsNtfyExactlyOnce(t *testing.T) {
|
||||||
|
ntfy := &fakeSink{}
|
||||||
|
ack := newFakeAck()
|
||||||
|
d := NewDispatcher(Config{Ntfy: ntfy, Telegram: &fakeSink{}, Ack: ack})
|
||||||
|
|
||||||
|
out, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||||
|
Candidate: candidate("cert_expiring", loop.Sev3, store.Away),
|
||||||
|
Body: "cert detail", Summary: "cert expiring",
|
||||||
|
}, refNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if len(ntfy.sends) != 1 {
|
||||||
|
t.Fatalf("sev3 away: want exactly 1 ntfy send, got %d", len(ntfy.sends))
|
||||||
|
}
|
||||||
|
if out[0].Sendable.RepeatUntilAck {
|
||||||
|
t.Fatalf("sev3 away must not repeat til ack")
|
||||||
|
}
|
||||||
|
if _, ok := ack.lastSent["cert_expiring"]; ok {
|
||||||
|
t.Fatalf("sev3 away must not enter the ack/repeat tracker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSev4AwayRepeatsUntilAcked — "telegram, repeat til ack": the initial send
|
||||||
|
// arms the ack clock, the repeat driver re-sends while un-acked, and an ack
|
||||||
|
// stops it.
|
||||||
|
func TestSev4AwayRepeatsUntilAcked(t *testing.T) {
|
||||||
|
telegram := &fakeSink{}
|
||||||
|
ack := newFakeAck()
|
||||||
|
d := NewDispatcher(Config{Telegram: telegram, Ack: ack})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := d.DispatchNudge(ctx, PhrasedNudge{
|
||||||
|
Candidate: candidate("disk_low", loop.Sev4, store.Away),
|
||||||
|
Body: "disk detail", Summary: "disk low on homesrv",
|
||||||
|
}, refNow()); err != nil {
|
||||||
|
t.Fatalf("dispatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// two intervals pass, still un-acked → two more sends.
|
||||||
|
for i := 1; i <= 2; i++ {
|
||||||
|
at := refNow().Add(time.Duration(i) * 10 * time.Minute)
|
||||||
|
if _, err := d.RepeatUnacked(ctx, []string{"disk_low"}, at, 5*time.Minute, "disk detail", "disk low on homesrv"); err != nil {
|
||||||
|
t.Fatalf("repeat %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(telegram.sends) != 3 {
|
||||||
|
t.Fatalf("want 1 initial + 2 repeats = 3 telegram sends, got %d", len(telegram.sends))
|
||||||
|
}
|
||||||
|
|
||||||
|
// acked → no further sends, however long we wait.
|
||||||
|
_ = ack.MarkAcked(ctx, "disk_low")
|
||||||
|
if _, err := d.RepeatUnacked(ctx, []string{"disk_low"}, refNow().Add(time.Hour), 5*time.Minute, "b", "s"); err != nil {
|
||||||
|
t.Fatalf("repeat after ack: %v", err)
|
||||||
|
}
|
||||||
|
if len(telegram.sends) != 3 {
|
||||||
|
t.Fatalf("ack must stop the repeat; got %d sends", len(telegram.sends))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAwayChannelsGetMinimalBody — what leaves the box is the short form, for
|
||||||
|
// every away cell of the table. messageForChannel is the last-mile choice both
|
||||||
|
// away sinks make too.
|
||||||
|
func TestAwayChannelsGetMinimalBody(t *testing.T) {
|
||||||
|
detail := "disk /mnt/hdd1 on homesrv at 97% — 12GB free, biggest offender /var/lib/docker"
|
||||||
|
short := "disk low on homesrv"
|
||||||
|
|
||||||
|
for _, ch := range []Channel{ChannelNtfy, ChannelTelegram} {
|
||||||
|
t.Run(string(ch), func(t *testing.T) {
|
||||||
|
msg := messageForChannel(Sendable{Channel: ch, Body: detail, Summary: short})
|
||||||
|
if msg != short {
|
||||||
|
t.Fatalf("%s message: want %q, got %q", ch, short, msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if got := messageForChannel(Sendable{Channel: ChannelVoice, Body: detail, Summary: short}); got != detail {
|
||||||
|
t.Fatalf("voice is local and gets the full body, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSev4AwaySendableCarriesNoDetail — DESIGN.md § Delivery: away channels
|
||||||
|
// leave the box, so a sev4-away message must not carry detail beyond the short
|
||||||
|
// form. Today the dispatcher hands the away sink the FULL Body as well as the
|
||||||
|
// Summary (dispatcher.go:153-162 copies pn.Body into every Sendable) and
|
||||||
|
// trusts each sink to pick Summary. That works for the two sinks in-tree, but
|
||||||
|
// the minimal body is not enforced at the dispatcher, so a new away sink that
|
||||||
|
// reads Body exfils by default.
|
||||||
|
func TestSev4AwaySendableCarriesNoDetail(t *testing.T) {
|
||||||
|
t.Skip("not enforced: dispatcher.go:159 puts the full Body on away sendables; minimal body is only enforced per-sink (ntfysink.go:77, telegramsink.go:148)")
|
||||||
|
|
||||||
|
telegram := &fakeSink{}
|
||||||
|
d := NewDispatcher(Config{Telegram: telegram, Ack: newFakeAck()})
|
||||||
|
detail := "disk /mnt/hdd1 at 97%, biggest offender /var/lib/docker"
|
||||||
|
|
||||||
|
if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||||
|
Candidate: candidate("disk_low", loop.Sev4, store.Away),
|
||||||
|
Body: detail, Summary: "disk low on homesrv",
|
||||||
|
}, refNow()); err != nil {
|
||||||
|
t.Fatalf("dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(telegram.sends[0].Body, "/var/lib/docker") {
|
||||||
|
t.Fatalf("away sendable carries detail: %q", telegram.sends[0].Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAwayFallsBackToFullBodyWhenSummaryEmpty — the other half of the same
|
||||||
|
// gap: with no Summary, the full body leaves the box. The code chooses that on
|
||||||
|
// purpose ("a terse full message is better than no message",
|
||||||
|
// dispatcher.go:345-357), which contradicts the spec's minimal-body rule.
|
||||||
|
// Written to the spec, skipped because the code disagrees.
|
||||||
|
func TestAwayFallsBackToFullBodyWhenSummaryEmpty(t *testing.T) {
|
||||||
|
t.Skip("by design today: dispatcher.go:356 and ntfysink.go:79 fall back to the full Body when Summary is empty, so detail can leave the box")
|
||||||
|
|
||||||
|
msg := messageForChannel(Sendable{
|
||||||
|
Channel: ChannelNtfy,
|
||||||
|
Body: "internal detail that should never leave the box",
|
||||||
|
})
|
||||||
|
if msg != "" {
|
||||||
|
t.Fatalf("empty summary must not fall back to body, got %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCareAwayDropIsRecorded — DESIGN.md's drop is a decision ("a missed water
|
||||||
|
// nudge is noise, a missed backup failure isn't"), so it should be visible
|
||||||
|
// rather than vanish. Today drop is a bare `continue`: no nudge row, no outbox
|
||||||
|
// attempt, no log — nothing an operator can see afterwards.
|
||||||
|
func TestCareAwayDropIsRecorded(t *testing.T) {
|
||||||
|
t.Skip("not implemented: dispatcher.go:149-151 skips a Drop channel with no record; there is no 'dropped' outcome in store/delivery.go:16-21")
|
||||||
|
|
||||||
|
ob := &fakeOutbox{}
|
||||||
|
d := NewDispatcher(Config{Voice: &fakeSink{}, Nudges: &fakeNudgeRecorder{}, Outbox: ob})
|
||||||
|
|
||||||
|
if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||||
|
Candidate: candidate("water", loop.Sev1, store.Away),
|
||||||
|
Body: "drink water", Summary: "water",
|
||||||
|
}, refNow()); err != nil {
|
||||||
|
t.Fatalf("dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if len(ob.attempts) != 1 || ob.attempts[0].channel != string(ChannelDrop) {
|
||||||
|
t.Fatalf("care-away drop should leave a visible record, got %+v", ob.attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
package dialogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Slot names one field of Slots. Named type, not a free string, so a missing
|
|
||||||
// slot cannot be misspelled — the question phrasing switches on these.
|
|
||||||
type Slot string
|
|
||||||
|
|
||||||
const (
|
|
||||||
SlotTime Slot = "time" // Slots.Time / HasTime
|
|
||||||
SlotKey Slot = "key" // Slots.Key / HasKey
|
|
||||||
SlotValue Slot = "value" // Slots.Value (paired with Key)
|
|
||||||
SlotFn Slot = "fn" // Slots.Fn / HasFn
|
|
||||||
SlotText Slot = "text" // Slots.Text
|
|
||||||
)
|
|
||||||
|
|
||||||
// MaxAttempts is 1 because Maven is not a nag (DESIGN.md § Non-goals). She asks
|
|
||||||
// one clarifying question. If the answer still leaves the slot empty she drops
|
|
||||||
// the request instead of asking again.
|
|
||||||
const MaxAttempts = 1
|
|
||||||
|
|
||||||
// PendingQuestion is what Maven holds while she waits for an answer to an open
|
|
||||||
// question. Unlike the yes/no confirms in cmd/mavend/voice.go, the answer here
|
|
||||||
// is free text that fills a missing slot rather than a verdict.
|
|
||||||
type PendingQuestion struct {
|
|
||||||
Intent Intent // what the router already guessed
|
|
||||||
Slots Slots // what it already filled
|
|
||||||
Missing []Slot // what is still empty, in the order to ask about
|
|
||||||
Utterance string // the user's original raw words
|
|
||||||
Asked time.Time
|
|
||||||
TTL time.Duration
|
|
||||||
Attempts int // questions already asked; capped by MaxAttempts
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *PendingQuestion) IsExpired(now time.Time) bool {
|
|
||||||
return now.After(q.Asked.Add(q.TTL))
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanAsk reports whether Maven may ask another question about this request.
|
|
||||||
func (q *PendingQuestion) CanAsk() bool {
|
|
||||||
return q.Attempts < MaxAttempts
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: the daemon will phrase the question text from Missing (one short ru
|
|
||||||
// question per Slot, feminine self-reference) and speak it here.
|
|
||||||
|
|
||||||
// ClarifyStore holds the parked questions. Same shape and locking as
|
|
||||||
// SessionStore: keyed by dialogue id, expired entries dropped on read.
|
|
||||||
type ClarifyStore struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
questions map[string]*PendingQuestion
|
|
||||||
defaultTTL time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore {
|
|
||||||
if defaultTTL <= 0 {
|
|
||||||
// Short, like confirmTTL in voice.go: a clarifying question is a
|
|
||||||
// same-breath gesture, a stale one should not eat a later utterance.
|
|
||||||
defaultTTL = 90 * time.Second
|
|
||||||
}
|
|
||||||
return &ClarifyStore{
|
|
||||||
questions: make(map[string]*PendingQuestion),
|
|
||||||
defaultTTL: defaultTTL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: the daemon will Put a question here when Decision.Clarify fires, in
|
|
||||||
// place of the flat "не разобрала" reply (cmd/mavend/voice.go).
|
|
||||||
func (s *ClarifyStore) Put(id string, q *PendingQuestion) {
|
|
||||||
if q.TTL <= 0 {
|
|
||||||
q.TTL = s.defaultTTL
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
s.questions[id] = q
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: the daemon will Get on the next turn, parse that turn into Slots, call
|
|
||||||
// Answer, and Delete — the open-question twin of resolveConfirm.
|
|
||||||
func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion {
|
|
||||||
s.mu.RLock()
|
|
||||||
q, ok := s.questions[id]
|
|
||||||
s.mu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if q.IsExpired(now) {
|
|
||||||
s.Delete(id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return q
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ClarifyStore) Delete(id string) {
|
|
||||||
s.mu.Lock()
|
|
||||||
delete(s.questions, id)
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Answer merges the slots parsed from the user's answer into the parked ones.
|
|
||||||
// Only the slots listed in Missing are filled, and an already filled slot is
|
|
||||||
// never overwritten — the answer completes the original request, it does not
|
|
||||||
// restate it. Parsing the answer text into `answer` is the caller's job; this
|
|
||||||
// package must stay free of internal/router.
|
|
||||||
func (q *PendingQuestion) Answer(text string, answer Slots) Slots {
|
|
||||||
out := q.Slots
|
|
||||||
for _, slot := range q.Missing {
|
|
||||||
switch slot {
|
|
||||||
case SlotTime:
|
|
||||||
if !out.HasTime && answer.HasTime {
|
|
||||||
out.Time = answer.Time
|
|
||||||
out.HasTime = true
|
|
||||||
}
|
|
||||||
case SlotKey:
|
|
||||||
if !out.HasKey && answer.HasKey {
|
|
||||||
out.Key = answer.Key
|
|
||||||
out.HasKey = true
|
|
||||||
}
|
|
||||||
case SlotValue:
|
|
||||||
if out.Value == "" && answer.Value != "" {
|
|
||||||
out.Value = answer.Value
|
|
||||||
}
|
|
||||||
case SlotFn:
|
|
||||||
if !out.HasFn && answer.HasFn {
|
|
||||||
out.Fn = answer.Fn
|
|
||||||
out.HasFn = true
|
|
||||||
if len(out.Args) == 0 {
|
|
||||||
out.Args = append([]string(nil), answer.Args...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case SlotText:
|
|
||||||
if out.Text == "" {
|
|
||||||
if answer.Text != "" {
|
|
||||||
out.Text = answer.Text
|
|
||||||
} else {
|
|
||||||
// No parse for a text slot — the raw answer IS the text.
|
|
||||||
out.Text = text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// StillMissing lists the slots that are empty in s, out of the ones asked for.
|
|
||||||
// The caller uses it to decide between acting and dropping the request.
|
|
||||||
func StillMissing(want []Slot, s Slots) []Slot {
|
|
||||||
var out []Slot
|
|
||||||
for _, slot := range want {
|
|
||||||
empty := false
|
|
||||||
switch slot {
|
|
||||||
case SlotTime:
|
|
||||||
empty = !s.HasTime
|
|
||||||
case SlotKey:
|
|
||||||
empty = !s.HasKey
|
|
||||||
case SlotValue:
|
|
||||||
empty = s.Value == ""
|
|
||||||
case SlotFn:
|
|
||||||
empty = !s.HasFn
|
|
||||||
case SlotText:
|
|
||||||
empty = s.Text == ""
|
|
||||||
}
|
|
||||||
if empty {
|
|
||||||
out = append(out, slot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
package dialogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var base = time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
func TestPendingQuestionIsExpired(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
ttl time.Duration
|
|
||||||
now time.Time
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"fresh", time.Minute, base.Add(10 * time.Second), false},
|
|
||||||
{"exactly at ttl", time.Minute, base.Add(time.Minute), false},
|
|
||||||
{"past ttl", time.Minute, base.Add(2 * time.Minute), true},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
q := &PendingQuestion{Asked: base, TTL: tc.ttl}
|
|
||||||
if got := q.IsExpired(tc.now); got != tc.want {
|
|
||||||
t.Fatalf("IsExpired = %v, want %v", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClarifyStoreGetPutDelete(t *testing.T) {
|
|
||||||
s := NewClarifyStore(time.Minute)
|
|
||||||
|
|
||||||
if got := s.Get("voice", base); got != nil {
|
|
||||||
t.Fatalf("empty store returned %+v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
q := &PendingQuestion{Intent: IntentReminder, Missing: []Slot{SlotTime}, Asked: base}
|
|
||||||
s.Put("voice", q)
|
|
||||||
if q.TTL != time.Minute {
|
|
||||||
t.Fatalf("Put did not apply the default TTL, got %v", q.TTL)
|
|
||||||
}
|
|
||||||
if got := s.Get("voice", base.Add(time.Second)); got != q {
|
|
||||||
t.Fatalf("Get returned %+v, want the parked question", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expired questions are dropped on read, not returned.
|
|
||||||
if got := s.Get("voice", base.Add(2*time.Minute)); got != nil {
|
|
||||||
t.Fatalf("expired Get returned %+v", got)
|
|
||||||
}
|
|
||||||
if got := s.Get("voice", base); got != nil {
|
|
||||||
t.Fatalf("expired question was not deleted: %+v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Put("voice", &PendingQuestion{Asked: base, TTL: time.Hour})
|
|
||||||
s.Delete("voice")
|
|
||||||
if got := s.Get("voice", base); got != nil {
|
|
||||||
t.Fatalf("Delete left %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewClarifyStoreDefaultTTL(t *testing.T) {
|
|
||||||
s := NewClarifyStore(0)
|
|
||||||
q := &PendingQuestion{Asked: base}
|
|
||||||
s.Put("voice", q)
|
|
||||||
if q.TTL != 90*time.Second {
|
|
||||||
t.Fatalf("TTL = %v, want 90s", q.TTL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnswerFillsOnlyMissingSlots(t *testing.T) {
|
|
||||||
answerTime := base.Add(3 * time.Hour)
|
|
||||||
other := base.Add(9 * time.Hour)
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
parked Slots
|
|
||||||
missing []Slot
|
|
||||||
text string
|
|
||||||
answer Slots
|
|
||||||
want Slots
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "fills the missing time",
|
|
||||||
parked: Slots{Text: "напомни позвонить"},
|
|
||||||
missing: []Slot{SlotTime},
|
|
||||||
text: "в три",
|
|
||||||
answer: Slots{Time: answerTime, HasTime: true},
|
|
||||||
want: Slots{Text: "напомни позвонить", Time: answerTime, HasTime: true},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "does not overwrite a filled time",
|
|
||||||
parked: Slots{Time: other, HasTime: true},
|
|
||||||
missing: []Slot{SlotTime},
|
|
||||||
text: "в три",
|
|
||||||
answer: Slots{Time: answerTime, HasTime: true},
|
|
||||||
want: Slots{Time: other, HasTime: true},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ignores slots that were not missing",
|
|
||||||
parked: Slots{Key: "water", HasKey: true},
|
|
||||||
missing: []Slot{SlotValue},
|
|
||||||
text: "два литра",
|
|
||||||
answer: Slots{Key: "sleep", HasKey: true, Value: "2l"},
|
|
||||||
want: Slots{Key: "water", HasKey: true, Value: "2l"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fills key when empty",
|
|
||||||
parked: Slots{},
|
|
||||||
missing: []Slot{SlotKey, SlotValue},
|
|
||||||
text: "воды",
|
|
||||||
answer: Slots{Key: "water", HasKey: true, Value: `"drank"`},
|
|
||||||
want: Slots{Key: "water", HasKey: true, Value: `"drank"`},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fills fn and its args",
|
|
||||||
parked: Slots{},
|
|
||||||
missing: []Slot{SlotFn},
|
|
||||||
text: "перезапусти nginx",
|
|
||||||
answer: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
||||||
want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "keeps existing args when fn was already known",
|
|
||||||
parked: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
||||||
missing: []Slot{SlotFn},
|
|
||||||
text: "останови postgres",
|
|
||||||
answer: Slots{Fn: "stop", Args: []string{"postgres"}, HasFn: true},
|
|
||||||
want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "raw answer becomes the text when nothing was parsed",
|
|
||||||
parked: Slots{},
|
|
||||||
missing: []Slot{SlotText},
|
|
||||||
text: "купить хлеб",
|
|
||||||
answer: Slots{},
|
|
||||||
want: Slots{Text: "купить хлеб"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "parsed text wins over the raw answer",
|
|
||||||
parked: Slots{},
|
|
||||||
missing: []Slot{SlotText},
|
|
||||||
text: "запиши купить хлеб",
|
|
||||||
answer: Slots{Text: "купить хлеб"},
|
|
||||||
want: Slots{Text: "купить хлеб"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty answer leaves the slot missing",
|
|
||||||
parked: Slots{Text: "напомни"},
|
|
||||||
missing: []Slot{SlotTime},
|
|
||||||
text: "не знаю",
|
|
||||||
answer: Slots{},
|
|
||||||
want: Slots{Text: "напомни"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
q := &PendingQuestion{Slots: tc.parked, Missing: tc.missing, Asked: base}
|
|
||||||
got := q.Answer(tc.text, tc.answer)
|
|
||||||
if got.Time != tc.want.Time || got.HasTime != tc.want.HasTime ||
|
|
||||||
got.Key != tc.want.Key || got.HasKey != tc.want.HasKey ||
|
|
||||||
got.Value != tc.want.Value || got.Text != tc.want.Text ||
|
|
||||||
got.Fn != tc.want.Fn || got.HasFn != tc.want.HasFn {
|
|
||||||
t.Fatalf("Answer = %+v, want %+v", got, tc.want)
|
|
||||||
}
|
|
||||||
if len(got.Args) != len(tc.want.Args) {
|
|
||||||
t.Fatalf("Args = %v, want %v", got.Args, tc.want.Args)
|
|
||||||
}
|
|
||||||
for i := range got.Args {
|
|
||||||
if got.Args[i] != tc.want.Args[i] {
|
|
||||||
t.Fatalf("Args = %v, want %v", got.Args, tc.want.Args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanAskCapsAtOneQuestion(t *testing.T) {
|
|
||||||
if MaxAttempts != 1 {
|
|
||||||
t.Fatalf("MaxAttempts = %d, want 1 (Maven asks once, she is not a nag)", MaxAttempts)
|
|
||||||
}
|
|
||||||
q := &PendingQuestion{Asked: base}
|
|
||||||
if !q.CanAsk() {
|
|
||||||
t.Fatal("a fresh question should be askable")
|
|
||||||
}
|
|
||||||
q.Attempts = MaxAttempts
|
|
||||||
if q.CanAsk() {
|
|
||||||
t.Fatal("the question should not be asked twice")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStillMissing(t *testing.T) {
|
|
||||||
want := []Slot{SlotTime, SlotKey, SlotValue, SlotFn, SlotText}
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
slots Slots
|
|
||||||
want []Slot
|
|
||||||
}{
|
|
||||||
{"all empty", Slots{}, want},
|
|
||||||
{
|
|
||||||
name: "all filled",
|
|
||||||
slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Value: "1l", Fn: "restart", HasFn: true, Text: "t"},
|
|
||||||
want: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "only value left",
|
|
||||||
slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Fn: "restart", HasFn: true, Text: "t"},
|
|
||||||
want: []Slot{SlotValue},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
got := StillMissing(want, tc.slots)
|
|
||||||
if len(got) != len(tc.want) {
|
|
||||||
t.Fatalf("StillMissing = %v, want %v", got, tc.want)
|
|
||||||
}
|
|
||||||
for i := range got {
|
|
||||||
if got[i] != tc.want[i] {
|
|
||||||
t.Fatalf("StillMissing = %v, want %v", got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ type Slots struct {
|
|||||||
Time time.Time
|
Time time.Time
|
||||||
HasTime bool
|
HasTime bool
|
||||||
Key string
|
Key string
|
||||||
Value string // payload for a fact key, mirrors router.Slots.Value
|
|
||||||
HasKey bool
|
HasKey bool
|
||||||
Text string
|
Text string
|
||||||
Fn string
|
Fn string
|
||||||
@@ -105,9 +104,6 @@ func InheritSlots(prev, cur Slots) Slots {
|
|||||||
out.Key = prev.Key
|
out.Key = prev.Key
|
||||||
out.HasKey = true
|
out.HasKey = true
|
||||||
}
|
}
|
||||||
if out.Value == "" && prev.Value != "" {
|
|
||||||
out.Value = prev.Value
|
|
||||||
}
|
|
||||||
if out.Text == "" && prev.Text != "" {
|
if out.Text == "" && prev.Text != "" {
|
||||||
out.Text = prev.Text
|
out.Text = prev.Text
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,14 +113,4 @@ func TestInheritSlots(t *testing.T) {
|
|||||||
if inherited6.Text != "какая погода в москве" {
|
if inherited6.Text != "какая погода в москве" {
|
||||||
t.Error("should inherit text when current is empty")
|
t.Error("should inherit text when current is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
prevValue := Slots{Key: "water", HasKey: true, Value: `"drank"`}
|
|
||||||
inherited7 := InheritSlots(prevValue, Slots{})
|
|
||||||
if inherited7.Value != `"drank"` {
|
|
||||||
t.Error("should inherit value when current is empty")
|
|
||||||
}
|
|
||||||
kept := InheritSlots(prevValue, Slots{Value: "2l"})
|
|
||||||
if kept.Value != "2l" {
|
|
||||||
t.Error("should keep current value")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user