Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b3b8d0a9e | |||
| fe0e654ab1 | |||
| a54ebac0cb | |||
| 39d83a33e8 | |||
| 925ce223a0 |
@@ -0,0 +1,180 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+43
-53
@@ -226,6 +226,8 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
|
||||
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
||||
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 := voice.Replier(voice.NewStubReplier())
|
||||
@@ -250,8 +252,10 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
memStore: memStore,
|
||||
dataStore: dataStore,
|
||||
dialogueSessions: dialogueSessions,
|
||||
clarifyStore: clarifyStore,
|
||||
extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},
|
||||
queryMinScore: cfg.Voice.QueryMinScore,
|
||||
timeParser: router.NewPythonDateParser(),
|
||||
timeParser: timeParser,
|
||||
ecosystem: eco,
|
||||
}
|
||||
|
||||
@@ -304,6 +308,14 @@ type reactiveHandler struct {
|
||||
// box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over.
|
||||
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
|
||||
// "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as
|
||||
// the y/n answer. ponytail: single slot, single-user box — a second act
|
||||
@@ -378,6 +390,13 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
|
||||
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.
|
||||
// "тихий режим" / "quiet on" would route through the classifier
|
||||
// unreliably (it's a command, not a free-form query), so we match it
|
||||
@@ -407,34 +426,16 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
|
||||
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
||||
dec = followUpMerge(prev, dec, now)
|
||||
if !dec.Clarify {
|
||||
// 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 {
|
||||
history = append(history, dialogue.Turn{
|
||||
Intent: prev.Intent,
|
||||
Slots: prev.Slots,
|
||||
Text: prev.Slots.Text, // the prior turn's utterance
|
||||
})
|
||||
// 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,
|
||||
})
|
||||
h.rememberTurn(prev, dec, now)
|
||||
}
|
||||
}
|
||||
|
||||
// 2c. clarify — she is not sure. If one named thing is missing, ask about it
|
||||
// and park the request (clarify.go); otherwise the replier's canned reply
|
||||
// stands.
|
||||
if dec.Clarify {
|
||||
if question, asked := h.askClarify(dec); asked {
|
||||
return h.reply(ctx, question, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +466,11 @@ func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
|
||||
return reply
|
||||
}
|
||||
|
||||
// 1b2. clarify answer — same check as HandlePushToTalk.
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, text); handled {
|
||||
return reply
|
||||
}
|
||||
|
||||
// 2. router — classify the utterance.
|
||||
dec, err := h.router.Route(ctx, text, h.now())
|
||||
if err != nil {
|
||||
@@ -482,30 +488,14 @@ func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
|
||||
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
||||
dec = followUpMerge(prev, dec, now)
|
||||
if !dec.Clarify {
|
||||
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)
|
||||
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,
|
||||
})
|
||||
h.rememberTurn(prev, dec, now)
|
||||
}
|
||||
}
|
||||
|
||||
// 2c. clarify — same as HandlePushToTalk: ask about the one missing thing.
|
||||
if dec.Clarify {
|
||||
if question, asked := h.askClarify(dec); asked {
|
||||
return question
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
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,6 +21,7 @@ type Slots struct {
|
||||
Time time.Time
|
||||
HasTime bool
|
||||
Key string
|
||||
Value string // payload for a fact key, mirrors router.Slots.Value
|
||||
HasKey bool
|
||||
Text string
|
||||
Fn string
|
||||
@@ -104,6 +105,9 @@ func InheritSlots(prev, cur Slots) Slots {
|
||||
out.Key = prev.Key
|
||||
out.HasKey = true
|
||||
}
|
||||
if out.Value == "" && prev.Value != "" {
|
||||
out.Value = prev.Value
|
||||
}
|
||||
if out.Text == "" && prev.Text != "" {
|
||||
out.Text = prev.Text
|
||||
}
|
||||
|
||||
@@ -113,4 +113,14 @@ func TestInheritSlots(t *testing.T) {
|
||||
if inherited6.Text != "какая погода в москве" {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Tests for the universal restraint gate.
|
||||
//
|
||||
// DESIGN.md § Trigger model: "the gate is universal, applied by the loop, never
|
||||
// per-rule — quiet-hours, presence, cooldown, snooze, calendar-busy all live in
|
||||
// one fires()." These tests pin the CONSERVATIVE side of that: the cases where
|
||||
// Maven must stay quiet. They exist so nobody loosens the gate by accident.
|
||||
//
|
||||
// Where the code does not yet do what DESIGN.md promises, the test is written to
|
||||
// show the gap and then skipped, with the file and line to fix. Behaviour is not
|
||||
// changed to make a test pass.
|
||||
|
||||
// testRule — a rule at the given severity that always wants to fire, so the
|
||||
// only thing under test is the gate.
|
||||
func testRule(name string, sev Severity) Rule {
|
||||
return Rule{
|
||||
Name: name,
|
||||
Severity: sev,
|
||||
Cooldown: Cooldown{Base: 30 * time.Minute, Min: time.Minute, Max: time.Hour},
|
||||
Predicate: func(State) bool { return true },
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- quiet hours ------------------------------------
|
||||
|
||||
// Quiet hours silence care and leave ops alone. A failed backup at 2am matters;
|
||||
// a water nudge at 2am does not.
|
||||
func TestGateQuietHoursSuppressesCareOnly(t *testing.T) {
|
||||
cases := []struct {
|
||||
sev Severity
|
||||
want bool
|
||||
}{
|
||||
{Sev1, false},
|
||||
{Sev2, false},
|
||||
{Sev3, true},
|
||||
{Sev4, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s := State{Now: refTime(), Presence: store.Present, QuietHours: true}
|
||||
if got := Gate(s, testRule("r", c.sev)); got != c.want {
|
||||
t.Errorf("quiet hours sev%d: want fire=%v, got %v", c.sev, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- presence ---------------------------------------
|
||||
|
||||
// DESIGN.md § Delivery: "sev <= 2 drops on away, sev >= 3 holds: a missed water
|
||||
// nudge is noise, a missed backup failure isn't."
|
||||
func TestGateAwayDropsCareHoldsOps(t *testing.T) {
|
||||
cases := []struct {
|
||||
sev Severity
|
||||
want bool
|
||||
}{
|
||||
{Sev1, false},
|
||||
{Sev2, false},
|
||||
{Sev3, true},
|
||||
{Sev4, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s := State{Now: refTime(), Presence: store.Away}
|
||||
if got := Gate(s, testRule("r", c.sev)); got != c.want {
|
||||
t.Errorf("away sev%d: want fire=%v, got %v", c.sev, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Care nudges are allowed through when the user is actually there and nothing
|
||||
// else is suppressing. Without this the "quiet" tests above could pass on a
|
||||
// gate that simply never fires.
|
||||
func TestGateAllowsCareWhenPresentAndClear(t *testing.T) {
|
||||
s := State{Now: refTime(), Presence: store.Present}
|
||||
if !Gate(s, testRule("r", Sev1)) {
|
||||
t.Fatal("present and clear: care nudge should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- calendar busy ----------------------------------
|
||||
|
||||
// "Don't nag mid-meeting" is an env predicate in the gate, not the LLM's call.
|
||||
// Ops still gets through — a service being down mid-meeting is worth the
|
||||
// interruption.
|
||||
func TestGateCalendarBusySuppressesCareOnly(t *testing.T) {
|
||||
care := State{Now: refTime(), Presence: store.Present, CalendarBusy: true}
|
||||
if Gate(care, testRule("r", Sev2)) {
|
||||
t.Error("calendar busy: care nudge should be suppressed")
|
||||
}
|
||||
if !Gate(care, testRule("r", Sev4)) {
|
||||
t.Error("calendar busy: ops hard should still fire")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- cooldown ---------------------------------------
|
||||
|
||||
// Cooldown holds for every severity — it is the anti-nag knob, so ops cannot
|
||||
// buy its way past it either.
|
||||
func TestGateCooldownHoldsForAllSeverities(t *testing.T) {
|
||||
now := refTime()
|
||||
for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} {
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
CooldownUntil: map[string]time.Time{"r": now.Add(10 * time.Minute)},
|
||||
}
|
||||
if Gate(s, testRule("r", sev)) {
|
||||
t.Errorf("cooldown sev%d: should be suppressed", sev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cooldown is per-rule: one rule cooling down must not mute another.
|
||||
func TestGateCooldownIsPerRule(t *testing.T) {
|
||||
now := refTime()
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
CooldownUntil: map[string]time.Time{"water": now.Add(10 * time.Minute)},
|
||||
}
|
||||
if Gate(s, testRule("water", Sev1)) {
|
||||
t.Error("water is cooling down and should be suppressed")
|
||||
}
|
||||
if !Gate(s, testRule("meal", Sev1)) {
|
||||
t.Error("meal has no cooldown and should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// The moment the cooldown expires the rule is free again — the gate compares
|
||||
// with Before, so "until" itself is already clear.
|
||||
func TestGateCooldownExpires(t *testing.T) {
|
||||
now := refTime()
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
CooldownUntil: map[string]time.Time{"r": now},
|
||||
}
|
||||
if !Gate(s, testRule("r", Sev1)) {
|
||||
t.Fatal("cooldown at exactly now should already be clear")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- snooze -----------------------------------------
|
||||
|
||||
// Snooze is the user saying "not about this". It beats everything, including
|
||||
// ops hard.
|
||||
func TestGateSnoozeHoldsForAllSeverities(t *testing.T) {
|
||||
now := refTime()
|
||||
for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} {
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
SnoozeUntil: map[string]time.Time{"r": now.Add(time.Hour)},
|
||||
}
|
||||
if Gate(s, testRule("r", sev)) {
|
||||
t.Errorf("snooze sev%d: should be suppressed", sev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- no-data backstop -------------------------------
|
||||
|
||||
// The gate enforces no-data inertness a second time, for any rule that declared
|
||||
// the keys it needs. A predicate that forgets the check still cannot fire.
|
||||
func TestGateNoDataBackstopBeatsAnEagerPredicate(t *testing.T) {
|
||||
now := refTime()
|
||||
eager := Rule{
|
||||
Name: "eager",
|
||||
Severity: Sev4, // even ops hard does not get past missing data
|
||||
Predicate: func(State) bool { return true },
|
||||
InertWhenNoData: []string{"water", "meal"},
|
||||
}
|
||||
// one of the two keys present is not enough.
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, time.Hour)},
|
||||
}
|
||||
if Gate(s, eager) {
|
||||
t.Fatal("a rule missing one of its keys must stay inert")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- one nudge per tick -----------------------------
|
||||
|
||||
// All five default rules want to fire at once. The tick must still emit exactly
|
||||
// one candidate, the loudest — never a dogpile.
|
||||
func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) {
|
||||
now := refTime()
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{
|
||||
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
|
||||
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
|
||||
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
||||
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
|
||||
},
|
||||
}
|
||||
// sanity: every rule really does want to fire, so the pick is a real choice.
|
||||
for _, r := range DefaultRules() {
|
||||
if !r.Predicate(s) {
|
||||
t.Fatalf("setup: rule %q does not want to fire", r.Name)
|
||||
}
|
||||
}
|
||||
got := Tick(s, DefaultRules())
|
||||
if got == nil {
|
||||
t.Fatal("all rules firing: want one candidate, got nil")
|
||||
}
|
||||
if got.Rule.Name != "service_down" || got.Severity != Sev4 {
|
||||
t.Fatalf("want the loudest (service_down/sev4), got %s/sev%d", got.Rule.Name, got.Severity)
|
||||
}
|
||||
}
|
||||
|
||||
// Tick returns a single Candidate by type, so "one per tick" cannot be violated
|
||||
// by count — what can drift is WHICH one. Equal severities tie-break by name so
|
||||
// the choice is deterministic across ticks.
|
||||
func TestTickTieBreaksByNameForDeterminism(t *testing.T) {
|
||||
s := State{Now: refTime(), Presence: store.Present}
|
||||
rules := []Rule{testRule("zebra", Sev2), testRule("apple", Sev2), testRule("mango", Sev2)}
|
||||
for i := 0; i < 5; i++ {
|
||||
got := Tick(s, rules)
|
||||
if got == nil || got.Rule.Name != "apple" {
|
||||
t.Fatalf("tie-break: want apple every time, got %+v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The loudest candidate wins even when the quiet one is listed first.
|
||||
func TestTickOrderOfRulesDoesNotMatter(t *testing.T) {
|
||||
s := State{Now: refTime(), Presence: store.Present}
|
||||
first := Tick(s, []Rule{testRule("care", Sev1), testRule("ops", Sev4)})
|
||||
second := Tick(s, []Rule{testRule("ops", Sev4), testRule("care", Sev1)})
|
||||
if first == nil || second == nil {
|
||||
t.Fatal("want a candidate from both orderings")
|
||||
}
|
||||
if first.Rule.Name != "ops" || second.Rule.Name != "ops" {
|
||||
t.Fatalf("order changed the pick: %s then %s", first.Rule.Name, second.Rule.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- reminders bypass the gate ----------------------
|
||||
|
||||
// DESIGN.md § User reminders: "bypasses the restraint gate — 'wake me 7' fires
|
||||
// in quiet hours; that's the point." Every suppressor set at once, and the
|
||||
// reminder still comes through.
|
||||
func TestRemindersBypassEverySuppressor(t *testing.T) {
|
||||
now := refTime()
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Away,
|
||||
QuietHours: true,
|
||||
CalendarBusy: true,
|
||||
CooldownUntil: map[string]time.Time{"reminder": now.Add(time.Hour)},
|
||||
}
|
||||
due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}}
|
||||
got := RemindDecisions(s, due)
|
||||
if len(got) != 1 || got[0].Reminder.ID != 7 {
|
||||
t.Fatalf("reminder must bypass the gate, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// GAP — DESIGN.md § User reminders ends "Snooze still applies." RemindDecisions
|
||||
// passes every due reminder straight through with no snooze check, so a snoozed
|
||||
// reminder fires anyway. The test below is what the contract asks for.
|
||||
func TestRemindersStillHonourSnooze(t *testing.T) {
|
||||
t.Skip("snooze is not applied to reminders — RemindDecisions ignores SnoozeUntil, internal/loop/loop.go:120")
|
||||
|
||||
now := refTime()
|
||||
s := State{
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
SnoozeUntil: map[string]time.Time{"reminder:7": now.Add(time.Hour)},
|
||||
}
|
||||
due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}}
|
||||
if got := RemindDecisions(s, due); len(got) != 0 {
|
||||
t.Fatalf("snoozed reminder should not be delivered, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// GAP — the gate reads State.SnoozeUntil, but the Gatherer hard-codes it to nil
|
||||
// (internal/loop/gather.go:153), so snooze is dead in the running daemon: the
|
||||
// unit tests above pass while nothing can ever populate the map. This asserts
|
||||
// the Gatherer actually produces a snooze map.
|
||||
func TestGathererPopulatesSnoozeUntil(t *testing.T) {
|
||||
t.Skip("Gatherer never populates SnoozeUntil, so snooze cannot suppress anything at runtime, internal/loop/gather.go:153")
|
||||
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir()+"/m.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
g := NewGatherer(st, DefaultRules())
|
||||
snap, _, err := g.GatherState(ctx, refTime())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snap.SnoozeUntil == nil {
|
||||
t.Fatal("Gatherer returned a nil SnoozeUntil map")
|
||||
}
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Direct tests for the five default rule predicates.
|
||||
//
|
||||
// A predicate is pure — (State) -> bool, no I/O — so these need no store and no
|
||||
// daemon. They test the predicate ALONE: the restraint gate is tested in
|
||||
// loop_test.go and gate_test.go, never here.
|
||||
//
|
||||
// Every rule gets the same three questions plus its own edges:
|
||||
// - does it fire when it should?
|
||||
// - does it stay quiet when it should?
|
||||
// - is it silent when the key it needs has no data at all?
|
||||
//
|
||||
// The last one is load-bearing. DESIGN.md: "since(key)==null → don't fire.
|
||||
// Silence on no-data is 'shuts up when uncertain'."
|
||||
|
||||
// stateWith builds a snapshot at refTime() holding just the given facts.
|
||||
// Presence and the env flags are left zero — the predicate must not read them.
|
||||
func stateWith(facts map[string]store.Fact) State {
|
||||
return State{Now: refTime(), Facts: facts}
|
||||
}
|
||||
|
||||
// ago is a fact for key written `d` before refTime().
|
||||
func ago(key, source, value string, d time.Duration) store.Fact {
|
||||
return factAt(key, source, value, refTime().Add(-d))
|
||||
}
|
||||
|
||||
// ---------------------------- since-based care rules -------------------------
|
||||
|
||||
// The three care rules share one shape: "fire when it has been at least N since
|
||||
// the last fact for key". One table drives all of them.
|
||||
func TestCareRulePredicates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rule Rule
|
||||
facts map[string]store.Fact
|
||||
want bool
|
||||
}{
|
||||
// water — threshold 3h.
|
||||
{
|
||||
name: "water fires at 4h",
|
||||
rule: WaterRule(),
|
||||
facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour)},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "water fires exactly at the 3h threshold",
|
||||
rule: WaterRule(),
|
||||
facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour)},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "water quiet just under 3h",
|
||||
rule: WaterRule(),
|
||||
facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour-time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "water quiet on no data",
|
||||
rule: WaterRule(),
|
||||
facts: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "water quiet on a zero-timestamp fact",
|
||||
rule: WaterRule(),
|
||||
facts: map[string]store.Fact{"water": {Key: "water", Source: "tap:water", Value: `"250ml"`}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "water quiet when the only fact is for another key",
|
||||
rule: WaterRule(),
|
||||
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 9*time.Hour)},
|
||||
want: false,
|
||||
},
|
||||
|
||||
// meal — threshold 6h.
|
||||
{
|
||||
name: "meal fires at 7h",
|
||||
rule: MealRule(),
|
||||
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour)},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "meal fires exactly at the 6h threshold",
|
||||
rule: MealRule(),
|
||||
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour)},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "meal quiet just under 6h",
|
||||
rule: MealRule(),
|
||||
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour-time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "meal quiet on no data",
|
||||
rule: MealRule(),
|
||||
facts: nil,
|
||||
want: false,
|
||||
},
|
||||
|
||||
// break — needs BOTH anchors: at the desk now, and no break for 90min.
|
||||
{
|
||||
name: "break fires when at desk and no break for 2h",
|
||||
rule: BreakRule(),
|
||||
facts: map[string]store.Fact{
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "break fires exactly at both thresholds",
|
||||
rule: BreakRule(),
|
||||
facts: map[string]store.Fact{
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 2*time.Minute),
|
||||
"break": ago("break", "voice", `"walk"`, 90*time.Minute),
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "break quiet when the desk signal is stale (user left)",
|
||||
rule: BreakRule(),
|
||||
facts: map[string]store.Fact{
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 10*time.Minute),
|
||||
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "break quiet when the last break was recent",
|
||||
rule: BreakRule(),
|
||||
facts: map[string]store.Fact{
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
"break": ago("break", "voice", `"walk"`, 20*time.Minute),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "break quiet with only the desk anchor",
|
||||
rule: BreakRule(),
|
||||
facts: map[string]store.Fact{
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "break quiet with only the break anchor",
|
||||
rule: BreakRule(),
|
||||
facts: map[string]store.Fact{
|
||||
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "break quiet on no data",
|
||||
rule: BreakRule(),
|
||||
facts: nil,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := c.rule.Predicate(stateWith(c.facts)); got != c.want {
|
||||
t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- ops rules --------------------------------------
|
||||
|
||||
// The two ops rules match on a value AND on which poller wrote it. DESIGN.md:
|
||||
// "a compromised poller must not be able to forge a trigger." Half of this
|
||||
// table is forgery attempts; all of them must be refused.
|
||||
func TestOpsRulePredicates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rule Rule
|
||||
facts map[string]store.Fact
|
||||
want bool
|
||||
}{
|
||||
// service_down — only poll:uptimekuma may say a service is down.
|
||||
{
|
||||
name: "service_down fires on a kuma down fact",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute)},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "service_down quiet when kuma says up",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"up"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down quiet on no data",
|
||||
rule: ServiceDownRule(),
|
||||
facts: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down quiet on a zero-timestamp fact",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": {Key: "service_down", Source: "poll:uptimekuma", Value: `"down"`}},
|
||||
want: false,
|
||||
},
|
||||
// forgery attempts — right value, wrong writer.
|
||||
{
|
||||
name: "service_down refuses a forgery from the netdata poller",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:netdata", `"down"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses a forgery from ambient audio",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "ambient:other", `"down"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses a forgery from the user's own voice",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "voice", `"down"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses a source that only looks like kuma",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma-staging", `"down"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses an unquoted down value",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `down`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
|
||||
// netdata_critical — only poll:netdata may raise a critical alarm.
|
||||
{
|
||||
name: "netdata_critical fires on a netdata critical alarm",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute)},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "netdata_critical quiet on a warning alarm",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"warning"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "netdata_critical quiet on a cleared alarm",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"clear"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "netdata_critical quiet on no data",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "netdata_critical quiet on a zero-timestamp fact",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: map[string]store.Fact{"netdata_alarm": {Key: "netdata_alarm", Source: "poll:netdata", Value: `"critical"`}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "netdata_critical refuses a forgery from the kuma poller",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:uptimekuma", `"critical"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "netdata_critical refuses a forgery from ambient audio",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "ambient:other", `"critical"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "netdata_critical reads netdata_alarm, not netdata_critical",
|
||||
rule: NetdataCriticalRule(),
|
||||
facts: map[string]store.Fact{"netdata_critical": ago("netdata_critical", "poll:netdata", `"critical"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := c.rule.Predicate(stateWith(c.facts)); got != c.want {
|
||||
t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- rule metadata ----------------------------------
|
||||
|
||||
// Every default rule must declare the keys it needs. The gate uses that list as
|
||||
// a second no-data backstop, so a rule that forgets it loses the safety net
|
||||
// even if its predicate happens to check.
|
||||
func TestDefaultRulesDeclareInertKeys(t *testing.T) {
|
||||
for _, r := range DefaultRules() {
|
||||
if len(r.InertWhenNoData) == 0 {
|
||||
t.Errorf("rule %q declares no InertWhenNoData keys", r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A no-data snapshot must make EVERY default rule quiet, predicate alone, with
|
||||
// the gate out of the picture. This is the whole-set version of the per-rule
|
||||
// no-data cases above.
|
||||
func TestNoDefaultRuleFiresOnEmptyState(t *testing.T) {
|
||||
empty := stateWith(nil)
|
||||
for _, r := range DefaultRules() {
|
||||
if r.Predicate(empty) {
|
||||
t.Errorf("rule %q fires on an empty snapshot", r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Severities are the delivery contract (DESIGN.md § Delivery / channel
|
||||
// routing): care is sev1-2 and drops when away, ops is sev3-4 and holds. Pin
|
||||
// them so a change to a rule's insistence has to be deliberate.
|
||||
func TestDefaultRuleSeverities(t *testing.T) {
|
||||
want := map[string]Severity{
|
||||
"water": Sev1,
|
||||
"meal": Sev1,
|
||||
"break": Sev2,
|
||||
"service_down": Sev4,
|
||||
"netdata_critical": Sev3,
|
||||
}
|
||||
got := map[string]Severity{}
|
||||
for _, r := range DefaultRules() {
|
||||
got[r.Name] = r.Severity
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("rule count changed: want %d, got %d", len(want), len(got))
|
||||
}
|
||||
for name, sev := range want {
|
||||
if got[name] != sev {
|
||||
t.Errorf("rule %q severity: want %d, got %d", name, sev, got[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cooldown bounds keep the feedback tuner honest — DESIGN.md wants
|
||||
// `cooldown in [min,max]` "so a weird week can't mutate Maven silent or
|
||||
// stalker". A base outside its own envelope would make that meaningless.
|
||||
func TestDefaultRuleCooldownsAreBounded(t *testing.T) {
|
||||
for _, r := range DefaultRules() {
|
||||
c := r.Cooldown
|
||||
if c.Min <= 0 || c.Base <= 0 || c.Max <= 0 {
|
||||
t.Errorf("rule %q has a non-positive cooldown: %+v", r.Name, c)
|
||||
continue
|
||||
}
|
||||
if c.Base < c.Min || c.Base > c.Max {
|
||||
t.Errorf("rule %q base %v outside envelope [%v, %v]", r.Name, c.Base, c.Min, c.Max)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A predicate must read only the snapshot it is handed. Same snapshot twice
|
||||
// (and a snapshot shared between two rules) must give the same answer — no
|
||||
// hidden state, no clock reads.
|
||||
func TestPredicatesArePure(t *testing.T) {
|
||||
s := stateWith(map[string]store.Fact{
|
||||
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
|
||||
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
||||
})
|
||||
for _, r := range DefaultRules() {
|
||||
first := r.Predicate(s)
|
||||
for i := 0; i < 3; i++ {
|
||||
if again := r.Predicate(s); again != first {
|
||||
t.Fatalf("rule %q predicate is not pure: %v then %v", r.Name, first, again)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user