67563ed1f6
detectPattern only ever fired as a side effect of a voice fact-write, so a recurring pattern already sitting in history went unnoticed until he happened to mention it again by voice — the opposite of proactive. Split the pipeline: extraction (fact -> normalized event) stays where a fact is written, in voice.go, since it's tied to that write regardless of who's talking. Detection (events -> stable pattern -> proposed_routines row) moves into shared code (patterns.go's detectAndPropose) that both the voice path and the new tick.go:detectPatterns call. The tick runs it every cycle over every action+object pair on record (store.DistinctEventPairs, added), so a pattern gets noticed on the daemon's own schedule. Idempotence and the dismiss-must-stick requirement turned out to already be handled by the store, not something the tick needs to reinvent: proposed_routines has UNIQUE(action, object) and CreateProposedRoutine does ON CONFLICT DO NOTHING, and DismissProposedRoutine flips status in place without deleting the row. So a pair already proposed, accepted, OR dismissed is a silent no-op on every later tick — a dismissed pattern can never resurface, and re-running the scan never spams the /routines page. Kept the voice-path call (immediate spoken confirmation is a nice feature UX-wise and is now redundant-but-harmless with the tick, since both paths share the same guarded detectAndPropose). Tick-side detection only ever writes a row; it does not notify, ring, or speak, keeping Maven "not a nag, not autonomous" — the /routines page is still the only place a proposal becomes visible, and only accepting it starts producing nudges (fireAcceptedRoutines). Also fixed the stale vikunja#46 reference in proposed_routines.go — the TODO it named is what this commit does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
123 lines
4.0 KiB
Go
123 lines
4.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// seedRefillEvents writes N weekly "refill/cat_water" events straight to the
|
|
// events table — this is what the tick reads, independent of any utterance.
|
|
func seedRefillEvents(t *testing.T, st *store.Store, ctx context.Context, base time.Time, n int) {
|
|
t.Helper()
|
|
for i := 0; i < n; i++ {
|
|
factID, err := st.WriteFact(ctx, base.Add(time.Duration(i)*7*24*time.Hour), store.KindSelf,
|
|
"cat_water", "refill", "test", 1.0, sql.NullInt64{})
|
|
if err != nil {
|
|
t.Fatalf("write fact %d: %v", i, err)
|
|
}
|
|
if _, err := st.CreateEvent(ctx, factID, "refill", "cat_water", base.Add(time.Duration(i)*7*24*time.Hour)); err != nil {
|
|
t.Fatalf("create event %d: %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTickDetectsPatternFromStoredEvents proves the tick notices a pattern on
|
|
// its own, reading straight from the store — not as a side effect of a live
|
|
// utterance (Vikunja #43). Three weekly events with no voice turn in sight
|
|
// must produce exactly one proposed routine.
|
|
func TestTickDetectsPatternFromStoredEvents(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := refNow()
|
|
seedRefillEvents(t, st, ctx, now, 3)
|
|
|
|
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
|
tl.detectPatterns(ctx, now)
|
|
|
|
rows, err := st.ListProposedRoutines(ctx)
|
|
if err != nil {
|
|
t.Fatalf("list proposed routines: %v", err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("proposed routines = %d, want 1: %+v", len(rows), rows)
|
|
}
|
|
if rows[0].Action != "refill" || rows[0].Object != "cat_water" {
|
|
t.Errorf("proposed routine = %s/%s, want refill/cat_water", rows[0].Action, rows[0].Object)
|
|
}
|
|
}
|
|
|
|
// TestTickPatternDetectionIsIdempotent proves running the tick's pattern scan
|
|
// twice does not spam a second proposal for the same pair, and that the store
|
|
// itself is what stops the duplicate (not tick-local state) — the whole point
|
|
// of the guard, since the tick has no memory of what it proposed last time.
|
|
func TestTickPatternDetectionIsIdempotent(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := refNow()
|
|
seedRefillEvents(t, st, ctx, now, 3)
|
|
|
|
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
|
tl.detectPatterns(ctx, now)
|
|
tl.detectPatterns(ctx, now.Add(time.Hour))
|
|
|
|
rows, err := st.ListProposedRoutines(ctx)
|
|
if err != nil {
|
|
t.Fatalf("list proposed routines: %v", err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("proposed routines after two ticks = %d, want 1 (no duplicate): %+v", len(rows), rows)
|
|
}
|
|
}
|
|
|
|
// TestTickPatternDetectionRespectsDismissal proves the single worst failure
|
|
// mode here — a proposal the owner already said no to coming back on the next
|
|
// tick — cannot happen. Dismissal flips the row's status in place; it must
|
|
// still be there to block re-proposal.
|
|
func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := refNow()
|
|
seedRefillEvents(t, st, ctx, now, 3)
|
|
|
|
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
|
tl.detectPatterns(ctx, now)
|
|
|
|
rows, err := st.ListProposedRoutines(ctx)
|
|
if err != nil {
|
|
t.Fatalf("list proposed routines: %v", err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("setup: proposed routines = %d, want 1", len(rows))
|
|
}
|
|
if err := st.DismissProposedRoutine(ctx, rows[0].ID); err != nil {
|
|
t.Fatalf("dismiss: %v", err)
|
|
}
|
|
|
|
// More events for the same pair arrive, and the tick runs again — a
|
|
// dismissed pattern must not resurface.
|
|
seedRefillEvents(t, st, ctx, now.Add(30*24*time.Hour), 3)
|
|
tl.detectPatterns(ctx, now.Add(60*24*time.Hour))
|
|
|
|
proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
|
if err != nil {
|
|
t.Fatalf("list proposed: %v", err)
|
|
}
|
|
if len(proposed) != 0 {
|
|
t.Fatalf("a dismissed pattern came back: %+v", proposed)
|
|
}
|
|
all, err := st.ListProposedRoutinesByStatus(ctx, "")
|
|
if err != nil {
|
|
t.Fatalf("list all: %v", err)
|
|
}
|
|
if len(all) != 1 {
|
|
t.Fatalf("total rows for the pair = %d, want 1 (still dismissed, not duplicated): %+v", len(all), all)
|
|
}
|
|
if all[0].Status != store.RoutineDismissed {
|
|
t.Errorf("status = %s, want dismissed", all[0].Status)
|
|
}
|
|
}
|