Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 766ca091a7 | |||
| c5317eb2b4 |
+2
-2
@@ -261,7 +261,7 @@ func run(args []string) error {
|
||||
tickInterval := time.Duration(cfg.TickInterval)
|
||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
|
||||
coreAPI = &daemonAPI{
|
||||
@@ -438,7 +438,7 @@ func run(args []string) error {
|
||||
tickInterval := time.Duration(cfg.TickInterval)
|
||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
|
||||
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
||||
|
||||
+47
-1
@@ -1,6 +1,6 @@
|
||||
// mavend/patterns.go — the shared detect+propose step of pattern inference
|
||||
// (Vikunja #43). Event *extraction* (fact -> action/object) happens at fact-
|
||||
// write time in voice.go's detectPattern, tied to whichever channel wrote the
|
||||
// write time in detectPattern below, tied to whichever channel wrote the
|
||||
// fact. Detection — turning a run of events into a proposed routine — is
|
||||
// channel-agnostic: it only needs what's already in the events table, so it
|
||||
// runs both right after a voice fact-write (for the immediate "напоминать?"
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
@@ -72,3 +73,48 @@ func detectAndPropose(ctx context.Context, ds *store.Store, action, object strin
|
||||
}
|
||||
return r, id, nil
|
||||
}
|
||||
|
||||
// detectPattern extracts an event from the written fact and runs the pattern
|
||||
// detector. If a stable recurring pattern is found and no proposed routine
|
||||
// exists for this action+object yet, one is created and the user is prompted
|
||||
// to confirm via the park() mechanism. Returns the suggestion phrase when a
|
||||
// new proposal was created and parked; "" otherwise.
|
||||
func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, value string, ts time.Time) string {
|
||||
ev := pattern.Extract(factID, key, value, ts)
|
||||
if ev == nil {
|
||||
return "" // not an actionable event
|
||||
}
|
||||
if _, err := h.dataStore.CreateEvent(ctx, factID, ev.Action, ev.Object, ts); err != nil {
|
||||
log.Printf("voice: create event: %v", err)
|
||||
return ""
|
||||
}
|
||||
// Detect+propose (Vikunja #43) is shared with the digestion tick's
|
||||
// proactive scan — see detectAndPropose above. Event *extraction* stays
|
||||
// here, tied to this fact write; detection over the accumulated history does
|
||||
// not need to happen right now for the voice path to have already done
|
||||
// its job — it's dedupe-safe to also let the next tick find the same
|
||||
// pattern independently.
|
||||
r, id, err := detectAndPropose(ctx, h.dataStore, ev.Action, ev.Object, ts)
|
||||
if err != nil {
|
||||
log.Printf("voice: detect pattern %s/%s: %v", ev.Action, ev.Object, err)
|
||||
return ""
|
||||
}
|
||||
if r == nil {
|
||||
return "" // not enough data, too irregular, or already proposed/decided
|
||||
}
|
||||
log.Printf("voice: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||
|
||||
// Park the proposal for voice confirmation.
|
||||
phrase := pattern.PhraseRoutine(r)
|
||||
h.mu.Lock()
|
||||
h.pendingRoutine = &pendingRoutineConfirm{
|
||||
routineID: id,
|
||||
action: r.Action,
|
||||
object: r.Object,
|
||||
interval: r.IntervalDays,
|
||||
phrase: phrase,
|
||||
expiry: ts.Add(confirmTTL),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return phrase
|
||||
}
|
||||
|
||||
+174
-11
@@ -3,9 +3,14 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -27,16 +32,16 @@ func seedRefillEvents(t *testing.T, st *store.Store, ctx context.Context, base t
|
||||
|
||||
// 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.
|
||||
// utterance (Vikunja #43). MinEvents 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)
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
tl.detectPatterns(ctx, now)
|
||||
tl.detectPatterns(ctx, now, loop.State{})
|
||||
|
||||
rows, err := st.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
@@ -58,11 +63,11 @@ func TestTickPatternDetectionIsIdempotent(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedRefillEvents(t, st, ctx, now, 3)
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
tl.detectPatterns(ctx, now)
|
||||
tl.detectPatterns(ctx, now.Add(time.Hour))
|
||||
tl.detectPatterns(ctx, now, loop.State{})
|
||||
tl.detectPatterns(ctx, now.Add(time.Hour), loop.State{})
|
||||
|
||||
rows, err := st.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
@@ -81,10 +86,10 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedRefillEvents(t, st, ctx, now, 3)
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
tl.detectPatterns(ctx, now)
|
||||
tl.detectPatterns(ctx, now, loop.State{})
|
||||
|
||||
rows, err := st.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
@@ -99,8 +104,8 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
|
||||
|
||||
// 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))
|
||||
seedRefillEvents(t, st, ctx, now.Add(30*24*time.Hour), pattern.MinEvents)
|
||||
tl.detectPatterns(ctx, now.Add(60*24*time.Hour), loop.State{})
|
||||
|
||||
proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||
if err != nil {
|
||||
@@ -120,3 +125,161 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
|
||||
t.Errorf("status = %s, want dismissed", all[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
// proposalRule — the rule name announceProposal uses for the seeded pair.
|
||||
const proposalRule = "proposal:refill cat_water"
|
||||
|
||||
// TestTickProposalSilentByDefault — detection is always on, announcing is not.
|
||||
// With no pattern_proposals block the tick still records the proposal, and says
|
||||
// nothing about it: Maven is not autonomous, so a behaviour that speaks without
|
||||
// being asked stays off until it is configured.
|
||||
func TestTickProposalSilentByDefault(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||
markPresent(t, st, ctx, now)
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newTestTickLoop(t, st, sink, nil)
|
||||
tl.tick(ctx, now)
|
||||
|
||||
if n := countSends(sink, proposalRule); n != 0 {
|
||||
t.Fatalf("announced %d proposals with no config, want 0", n)
|
||||
}
|
||||
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||
if err != nil {
|
||||
t.Fatalf("list proposed: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("proposed routines = %d, want 1 (silent, but recorded)", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTickAnnouncesProposalWhenConfigured — with notify on, the proposal goes
|
||||
// out once through the ordinary delivery path, worded by the detector itself.
|
||||
// Later ticks stay quiet because the pair is already proposed: one pattern is
|
||||
// one announcement, ever.
|
||||
func TestTickAnnouncesProposalWhenConfigured(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||
markPresent(t, st, ctx, now)
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newTestTickLoop(t, st, sink, nil)
|
||||
tl.proposalCfg = &config.PatternProposalConfig{Notify: true}
|
||||
tl.tick(ctx, now)
|
||||
|
||||
var got *delivery.Sendable
|
||||
for i := range sink.sends {
|
||||
if sink.sends[i].RuleName == proposalRule {
|
||||
got = &sink.sends[i]
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("proposal was not announced; sends=%+v", sink.sends)
|
||||
}
|
||||
if !strings.Contains(got.Body, "напоминать?") {
|
||||
t.Errorf("body = %q, want the detector's own question", got.Body)
|
||||
}
|
||||
if got.Channel != delivery.ChannelVoice {
|
||||
t.Errorf("channel = %v, want voice (sev1, present)", got.Channel)
|
||||
}
|
||||
|
||||
// A month of further ticks: the pair already has a row, so there is
|
||||
// nothing new to detect and nothing more to say.
|
||||
sink.sends = nil
|
||||
later := now.Add(40 * 24 * time.Hour)
|
||||
markPresent(t, st, ctx, later)
|
||||
tl.tick(ctx, later)
|
||||
if n := countSends(sink, proposalRule); n != 0 {
|
||||
t.Fatalf("re-announced an existing proposal %d times, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTickProposalRespectsGate — a proposal is the least urgent thing Maven can
|
||||
// say, so it is sev1 and the restraint gate suppresses it. Away presence means
|
||||
// it is not announced at all: it is not held, not retried, it just lives on
|
||||
// /routines. The proposal row is still written — noticing is never gated.
|
||||
func TestTickProposalRespectsGate(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||
// no presence probes ⇒ away ⇒ care-class gate blocks.
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newTestTickLoop(t, st, sink, nil)
|
||||
tl.proposalCfg = &config.PatternProposalConfig{Notify: true}
|
||||
tl.tick(ctx, now)
|
||||
|
||||
if n := countSends(sink, proposalRule); n != 0 {
|
||||
t.Fatalf("away: announced %d proposals, want 0", n)
|
||||
}
|
||||
if !tl.lastProposalAt.IsZero() {
|
||||
t.Error("cooldown clock advanced on a suppressed announcement")
|
||||
}
|
||||
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||
if err != nil {
|
||||
t.Fatalf("list proposed: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("proposed routines = %d, want 1 (detection is never gated)", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTickProposalCooldownSpacesAnnouncements — two patterns detected on the
|
||||
// same tick must not become two interruptions. The second one waits for the
|
||||
// cooldown, and is on /routines meanwhile.
|
||||
func TestTickProposalCooldownSpacesAnnouncements(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||
for i := 0; i < pattern.MinEvents; i++ {
|
||||
ts := now.Add(time.Duration(i) * 3 * 24 * time.Hour)
|
||||
factID, err := st.WriteFact(ctx, ts, store.KindSelf, "litter_box", "clean", "test", 1.0, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("write fact: %v", err)
|
||||
}
|
||||
if _, err := st.CreateEvent(ctx, factID, "clean", "litter_box", ts); err != nil {
|
||||
t.Fatalf("create event: %v", err)
|
||||
}
|
||||
}
|
||||
markPresent(t, st, ctx, now)
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newTestTickLoop(t, st, sink, nil)
|
||||
tl.proposalCfg = &config.PatternProposalConfig{Notify: true, Cooldown: config.Duration(24 * time.Hour)}
|
||||
tl.tick(ctx, now)
|
||||
|
||||
announced := 0
|
||||
for _, s := range sink.sends {
|
||||
if strings.HasPrefix(s.RuleName, "proposal:") {
|
||||
announced++
|
||||
}
|
||||
}
|
||||
if announced != 1 {
|
||||
t.Fatalf("announced %d proposals on one tick, want exactly 1", announced)
|
||||
}
|
||||
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||
if err != nil {
|
||||
t.Fatalf("list proposed: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("proposed routines = %d, want 2 (both recorded, one announced)", len(rows))
|
||||
}
|
||||
|
||||
// Still inside the cooldown: silence, even though a proposal is pending.
|
||||
sink.sends = nil
|
||||
soon := now.Add(time.Hour)
|
||||
markPresent(t, st, ctx, soon)
|
||||
tl.tick(ctx, soon)
|
||||
for _, s := range sink.sends {
|
||||
if strings.HasPrefix(s.RuleName, "proposal:") {
|
||||
t.Fatalf("announced %q inside the cooldown", s.RuleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// Quiet-mode toggle recognition — the pre-route keyword check that lets
|
||||
// "тихий режим" flip the daemon-wide quiet_hours config without going through
|
||||
// the router. Moved out of voice.go unchanged (Vikunja #321); the tests live in
|
||||
// quiet_toggle_test.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
||||
// the utterance is a quiet-on/off command; ("", false) otherwise. Called from
|
||||
// runTurn BEFORE the router so a classifier miscue can't drop it — which means
|
||||
// both the voice path and the text path (mavweb /api/chat, telegram) reach it,
|
||||
// so a false positive here is a network-reachable way to flip a daemon-wide
|
||||
// setting. See classifyQuietToggle for the matching rule.
|
||||
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) {
|
||||
on, off := classifyQuietToggle(text)
|
||||
if !on && !off {
|
||||
return "", false
|
||||
}
|
||||
val := "false"
|
||||
reply := "тихий режим выключен."
|
||||
if on {
|
||||
val = "true"
|
||||
reply = "тихий режим включён. буду реже напоминать."
|
||||
}
|
||||
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: h.now(),
|
||||
Kind: "config",
|
||||
Key: "quiet_hours",
|
||||
Value: val,
|
||||
Source: "tap:voice",
|
||||
Confidence: 1.0,
|
||||
}); err != nil {
|
||||
log.Printf("voice: write quiet_hours: %v", err)
|
||||
return "не получилось переключить тихий режим.", true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// quietInflections — the inflectional endings a stem may carry and still be
|
||||
// the same word. Adjective/adverb/noun/verb endings, all ≤3 letters. This is
|
||||
// what separates "тихий"/"тихом"/"тихо" (stem "тих" + a real ending) from
|
||||
// "тихонько"/"потихоньку", which are different words: "онько" is not an
|
||||
// ending, and "потихоньку" doesn't start with the stem at all.
|
||||
var quietInflections = []string{
|
||||
"", "а", "е", "и", "й", "о", "у", "ы", "ю", "я",
|
||||
"ая", "ее", "ей", "ем", "ие", "ий", "им", "их", "ия", "ию", "ое", "ой", "ом", "ую", "ые", "ый", "ым", "ых", "ья",
|
||||
"ами", "ого", "ому", "ыми", "ать", "ить", "ять",
|
||||
}
|
||||
|
||||
// quietStem reports whether tok is the given stem carrying at most one
|
||||
// inflectional ending. Word boundaries come from tokenisation (see
|
||||
// quietTokens), not from a regexp — Go's \b is ASCII-oriented and treats every
|
||||
// Cyrillic letter as a non-word character, so `\bтих\b` would happily match
|
||||
// inside "тихонько". Comparing whole tokens sidesteps that entirely.
|
||||
func quietStem(tok, stem string) bool {
|
||||
if !strings.HasPrefix(tok, stem) {
|
||||
return false
|
||||
}
|
||||
suffix := tok[len(stem):]
|
||||
for _, e := range quietInflections {
|
||||
if suffix == e {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// quietTokens splits an utterance into lowercase word tokens, dropping
|
||||
// punctuation and spacing. Unicode-aware, so Cyrillic words tokenise the same
|
||||
// way ASCII ones do.
|
||||
func quietTokens(text string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(strings.TrimSpace(text)), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
|
||||
// quietPhrase matches a pattern (a sequence of stems) against the token list.
|
||||
// Multi-word patterns match any contiguous run of tokens — "включи тихий
|
||||
// режим" carries "тихий режим". Single-word patterns match ONLY when they are
|
||||
// the whole utterance: bare "тихо" is a command, but "в комнате тихо" is a
|
||||
// remark about the room and must not flip a daemon-wide setting.
|
||||
func quietPhrase(tokens, pattern []string) bool {
|
||||
if len(pattern) == 0 || len(tokens) < len(pattern) {
|
||||
return false
|
||||
}
|
||||
if len(pattern) == 1 {
|
||||
return len(tokens) == 1 && quietStem(tokens[0], pattern[0])
|
||||
}
|
||||
for i := 0; i+len(pattern) <= len(tokens); i++ {
|
||||
hit := true
|
||||
for j, stem := range pattern {
|
||||
if !quietStem(tokens[i+j], stem) {
|
||||
hit = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if hit {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
|
||||
var (
|
||||
quietOffPhrases = [][]string{
|
||||
{"quiet", "off"}, {"quiet", "end"},
|
||||
{"громк", "режим"}, {"шумн", "режим"},
|
||||
{"отмен", "тих"}, {"выключ", "тих"}, {"не", "тих"},
|
||||
}
|
||||
quietOnPhrases = [][]string{
|
||||
{"quiet", "on"}, {"quiet", "mode"},
|
||||
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
|
||||
{"тих"},
|
||||
}
|
||||
)
|
||||
|
||||
// classifyQuietToggle reads an utterance as a quiet-mode command. OFF is
|
||||
// resolved before ON for the same reason classifyConfirm checks negatives
|
||||
// first: the OFF phrases are built out of the ON words ("выключи тихий"
|
||||
// contains "тихий"), so scanning ON first would shadow them and "выключи
|
||||
// тихий режим" would turn quiet mode on. Negation wins.
|
||||
func classifyQuietToggle(text string) (on, off bool) {
|
||||
tokens := quietTokens(text)
|
||||
for _, p := range quietOffPhrases {
|
||||
if quietPhrase(tokens, p) {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
for _, p := range quietOnPhrases {
|
||||
if quietPhrase(tokens, p) {
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
+76
-6
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/routine"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -67,6 +68,14 @@ type tickLoop struct {
|
||||
morningRoutines []morning.Routine
|
||||
morningLast map[string]time.Time
|
||||
|
||||
// proposalCfg — announcement policy for routines the tick inferred itself.
|
||||
// nil ⇒ detect silently, never announce (the default). lastProposalAt is
|
||||
// the cooldown clock, in-memory on purpose: a restart is allowed to permit
|
||||
// one more announcement, and a restart-per-day loop is a bigger problem
|
||||
// than a duplicate proposal notice.
|
||||
proposalCfg *config.PatternProposalConfig
|
||||
lastProposalAt time.Time
|
||||
|
||||
// digestQ — in-memory queue of eligible nudges waiting for batch flush.
|
||||
// populated when digestCfg != nil && digestCfg.Enabled.
|
||||
digestQ []QueuedNudge
|
||||
@@ -92,6 +101,7 @@ func newTickLoop(
|
||||
digestCfg *config.DigestConfig,
|
||||
routines []routine.Routine,
|
||||
morningRoutines []morning.Routine,
|
||||
proposalCfg *config.PatternProposalConfig,
|
||||
) *tickLoop {
|
||||
return &tickLoop{
|
||||
store: st,
|
||||
@@ -108,6 +118,7 @@ func newTickLoop(
|
||||
routineLast: make(map[string]time.Time),
|
||||
morningRoutines: morningRoutines,
|
||||
morningLast: make(map[string]time.Time),
|
||||
proposalCfg: proposalCfg,
|
||||
lastPhrase: make(map[string]delivery.PhrasedNudge),
|
||||
}
|
||||
}
|
||||
@@ -212,7 +223,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
// path, so a pattern already sitting in history went unnoticed until he
|
||||
// happened to mention it again by voice. See patterns.go and
|
||||
// detectPatterns below for how idempotence and dismissal are respected.
|
||||
t.detectPatterns(ctx, now)
|
||||
t.detectPatterns(ctx, now, state)
|
||||
|
||||
// reminders: gate-bypassing class. fired once, marked after a successful
|
||||
// delivery. a failed send leaves the reminder pending — the next tick
|
||||
@@ -381,16 +392,19 @@ func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.St
|
||||
// resurrecting — there is nothing tick-specific to get right here beyond
|
||||
// calling the same shared path the voice route already used.
|
||||
//
|
||||
// This only ever creates a row for the /routines page to show. It does not
|
||||
// notify, ring, or speak — Maven is "not a nag, not autonomous" (CLAUDE.md),
|
||||
// and detection is not the same act as disturbing him about it. A proposal
|
||||
// only starts producing nudges once he accepts it (fireAcceptedRoutines).
|
||||
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time) {
|
||||
// By default this only creates a row for the /routines page to show: it does
|
||||
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
||||
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
||||
// is opt-in through the pattern_proposals config block — see announceProposal
|
||||
// for the restraints that apply even then. A proposal only starts producing
|
||||
// recurring nudges once he accepts it (fireAcceptedRoutines).
|
||||
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
|
||||
pairs, err := t.store.DistinctEventPairs(ctx)
|
||||
if err != nil {
|
||||
log.Printf("tick: distinct event pairs: %v", err)
|
||||
return
|
||||
}
|
||||
announced := false
|
||||
for _, p := range pairs {
|
||||
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
||||
if err != nil {
|
||||
@@ -401,9 +415,65 @@ func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time) {
|
||||
continue // no stable pattern, or already proposed/accepted/dismissed
|
||||
}
|
||||
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||
// One announcement per tick at most, whatever the scan turned up. The
|
||||
// rest are on /routines; they are not lost, they are just not shouted.
|
||||
if announced {
|
||||
continue
|
||||
}
|
||||
announced = t.announceProposal(ctx, r, now, state)
|
||||
}
|
||||
}
|
||||
|
||||
// announceProposal offers a freshly inferred routine through the ordinary
|
||||
// care-delivery path, if announcing is switched on at all. Returns true when
|
||||
// something was actually sent.
|
||||
//
|
||||
// Everything here is restraint. The feature is off unless configured; when on
|
||||
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
|
||||
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
|
||||
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
|
||||
// dropped announcement is NOT retried — the cooldown clock advances only on a
|
||||
// real send, but the proposal row already exists, so the next tick will not
|
||||
// re-detect it and nothing queues up behind it. A missed announcement means
|
||||
// he reads it on /routines instead, which is the whole point of the page.
|
||||
//
|
||||
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
|
||||
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
|
||||
// an inferred routine cannot arrive worded as something Maven never observed.
|
||||
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
|
||||
if !t.proposalCfg.AnnounceProposals() {
|
||||
return false
|
||||
}
|
||||
cooldown := time.Duration(t.proposalCfg.Cooldown)
|
||||
if cooldown <= 0 {
|
||||
cooldown = config.DefaultProposalCooldown
|
||||
}
|
||||
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
|
||||
return false
|
||||
}
|
||||
|
||||
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
|
||||
if !loop.Gate(state, rule) {
|
||||
return false
|
||||
}
|
||||
body := pattern.PhraseRoutine(r)
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
||||
Body: body,
|
||||
Summary: body,
|
||||
}
|
||||
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
|
||||
return false
|
||||
}
|
||||
if len(sent) == 0 {
|
||||
return false // routing dropped it — /routines still has it.
|
||||
}
|
||||
t.lastProposalAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
// digestExpiry — how long a gate-suppressed care nudge stays worth
|
||||
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
|
||||
// hour-scale cooldowns and re-derive from facts that reset every day), so a
|
||||
|
||||
@@ -46,7 +46,7 @@ func newTestTickLoop(t *testing.T, st *store.Store, sink delivery.Sink, digestCf
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
})
|
||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil)
|
||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil, nil)
|
||||
}
|
||||
|
||||
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
||||
@@ -63,7 +63,7 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
|
||||
rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}}
|
||||
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil)
|
||||
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil, nil)
|
||||
|
||||
// first tick: seeds, does not fire the routine.
|
||||
tl.tick(ctx, now)
|
||||
|
||||
@@ -50,13 +50,11 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -277,181 +275,6 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
|
||||
return ""
|
||||
}
|
||||
|
||||
// detectPattern extracts an event from the written fact and runs the pattern
|
||||
// detector. If a stable recurring pattern is found and no proposed routine
|
||||
// exists for this action+object yet, one is created and the user is prompted
|
||||
// to confirm via the park() mechanism. Returns the suggestion phrase when a
|
||||
// new proposal was created and parked; "" otherwise.
|
||||
func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, value string, ts time.Time) string {
|
||||
ev := pattern.Extract(factID, key, value, ts)
|
||||
if ev == nil {
|
||||
return "" // not an actionable event
|
||||
}
|
||||
if _, err := h.dataStore.CreateEvent(ctx, factID, ev.Action, ev.Object, ts); err != nil {
|
||||
log.Printf("voice: create event: %v", err)
|
||||
return ""
|
||||
}
|
||||
// Detect+propose (Vikunja #43) is shared with the digestion tick's
|
||||
// proactive scan — see patterns.go. Event *extraction* above stays here,
|
||||
// tied to this fact write; detection over the accumulated history does
|
||||
// not need to happen right now for the voice path to have already done
|
||||
// its job — it's dedupe-safe to also let the next tick find the same
|
||||
// pattern independently.
|
||||
r, id, err := detectAndPropose(ctx, h.dataStore, ev.Action, ev.Object, ts)
|
||||
if err != nil {
|
||||
log.Printf("voice: detect pattern %s/%s: %v", ev.Action, ev.Object, err)
|
||||
return ""
|
||||
}
|
||||
if r == nil {
|
||||
return "" // not enough data, too irregular, or already proposed/decided
|
||||
}
|
||||
log.Printf("voice: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||
|
||||
// Park the proposal for voice confirmation.
|
||||
phrase := pattern.PhraseRoutine(r)
|
||||
h.mu.Lock()
|
||||
h.pendingRoutine = &pendingRoutineConfirm{
|
||||
routineID: id,
|
||||
action: r.Action,
|
||||
object: r.Object,
|
||||
interval: r.IntervalDays,
|
||||
phrase: phrase,
|
||||
expiry: ts.Add(confirmTTL),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return phrase
|
||||
}
|
||||
|
||||
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
||||
// the utterance is a quiet-on/off command; ("", false) otherwise. Called from
|
||||
// runTurn BEFORE the router so a classifier miscue can't drop it — which means
|
||||
// both the voice path and the text path (mavweb /api/chat, telegram) reach it,
|
||||
// so a false positive here is a network-reachable way to flip a daemon-wide
|
||||
// setting. See classifyQuietToggle for the matching rule.
|
||||
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) {
|
||||
on, off := classifyQuietToggle(text)
|
||||
if !on && !off {
|
||||
return "", false
|
||||
}
|
||||
val := "false"
|
||||
reply := "тихий режим выключен."
|
||||
if on {
|
||||
val = "true"
|
||||
reply = "тихий режим включён. буду реже напоминать."
|
||||
}
|
||||
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: h.now(),
|
||||
Kind: "config",
|
||||
Key: "quiet_hours",
|
||||
Value: val,
|
||||
Source: "tap:voice",
|
||||
Confidence: 1.0,
|
||||
}); err != nil {
|
||||
log.Printf("voice: write quiet_hours: %v", err)
|
||||
return "не получилось переключить тихий режим.", true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// quietInflections — the inflectional endings a stem may carry and still be
|
||||
// the same word. Adjective/adverb/noun/verb endings, all ≤3 letters. This is
|
||||
// what separates "тихий"/"тихом"/"тихо" (stem "тих" + a real ending) from
|
||||
// "тихонько"/"потихоньку", which are different words: "онько" is not an
|
||||
// ending, and "потихоньку" doesn't start with the stem at all.
|
||||
var quietInflections = []string{
|
||||
"", "а", "е", "и", "й", "о", "у", "ы", "ю", "я",
|
||||
"ая", "ее", "ей", "ем", "ие", "ий", "им", "их", "ия", "ию", "ое", "ой", "ом", "ую", "ые", "ый", "ым", "ых", "ья",
|
||||
"ами", "ого", "ому", "ыми", "ать", "ить", "ять",
|
||||
}
|
||||
|
||||
// quietStem reports whether tok is the given stem carrying at most one
|
||||
// inflectional ending. Word boundaries come from tokenisation (see
|
||||
// quietTokens), not from a regexp — Go's \b is ASCII-oriented and treats every
|
||||
// Cyrillic letter as a non-word character, so `\bтих\b` would happily match
|
||||
// inside "тихонько". Comparing whole tokens sidesteps that entirely.
|
||||
func quietStem(tok, stem string) bool {
|
||||
if !strings.HasPrefix(tok, stem) {
|
||||
return false
|
||||
}
|
||||
suffix := tok[len(stem):]
|
||||
for _, e := range quietInflections {
|
||||
if suffix == e {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// quietTokens splits an utterance into lowercase word tokens, dropping
|
||||
// punctuation and spacing. Unicode-aware, so Cyrillic words tokenise the same
|
||||
// way ASCII ones do.
|
||||
func quietTokens(text string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(strings.TrimSpace(text)), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
|
||||
// quietPhrase matches a pattern (a sequence of stems) against the token list.
|
||||
// Multi-word patterns match any contiguous run of tokens — "включи тихий
|
||||
// режим" carries "тихий режим". Single-word patterns match ONLY when they are
|
||||
// the whole utterance: bare "тихо" is a command, but "в комнате тихо" is a
|
||||
// remark about the room and must not flip a daemon-wide setting.
|
||||
func quietPhrase(tokens, pattern []string) bool {
|
||||
if len(pattern) == 0 || len(tokens) < len(pattern) {
|
||||
return false
|
||||
}
|
||||
if len(pattern) == 1 {
|
||||
return len(tokens) == 1 && quietStem(tokens[0], pattern[0])
|
||||
}
|
||||
for i := 0; i+len(pattern) <= len(tokens); i++ {
|
||||
hit := true
|
||||
for j, stem := range pattern {
|
||||
if !quietStem(tokens[i+j], stem) {
|
||||
hit = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if hit {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
|
||||
var (
|
||||
quietOffPhrases = [][]string{
|
||||
{"quiet", "off"}, {"quiet", "end"},
|
||||
{"громк", "режим"}, {"шумн", "режим"},
|
||||
{"отмен", "тих"}, {"выключ", "тих"}, {"не", "тих"},
|
||||
}
|
||||
quietOnPhrases = [][]string{
|
||||
{"quiet", "on"}, {"quiet", "mode"},
|
||||
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
|
||||
{"тих"},
|
||||
}
|
||||
)
|
||||
|
||||
// classifyQuietToggle reads an utterance as a quiet-mode command. OFF is
|
||||
// resolved before ON for the same reason classifyConfirm checks negatives
|
||||
// first: the OFF phrases are built out of the ON words ("выключи тихий"
|
||||
// contains "тихий"), so scanning ON first would shadow them and "выключи
|
||||
// тихий режим" would turn quiet mode on. Negation wins.
|
||||
func classifyQuietToggle(text string) (on, off bool) {
|
||||
tokens := quietTokens(text)
|
||||
for _, p := range quietOffPhrases {
|
||||
if quietPhrase(tokens, p) {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
for _, p := range quietOnPhrases {
|
||||
if quietPhrase(tokens, p) {
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// replySystem answers system-observable queries using the handler's clock
|
||||
// and (in future) system interfaces. The decision's utterance is parsed
|
||||
// for keywords to determine what the user is asking about.
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
"severity_ceiling": 2
|
||||
},
|
||||
|
||||
"pattern_proposals": {
|
||||
"notify": false,
|
||||
"cooldown": "24h"
|
||||
},
|
||||
|
||||
"nexus": { "url": "http://nexus:9740" },
|
||||
"praxis": { "url": "http://praxis:8989" },
|
||||
"hexis": { "url": "http://hexis:9741" },
|
||||
|
||||
@@ -140,6 +140,12 @@ type Config struct {
|
||||
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
|
||||
MorningRoutines []MorningRoutineConfig `json:"morning_routines,omitempty"`
|
||||
|
||||
// PatternProposals — whether a routine the digestion tick inferred on its
|
||||
// own may be announced, and how often. nil / absent ⇒ silent detection
|
||||
// only: proposals are written for /routines and never announced. See
|
||||
// PatternProposalConfig.
|
||||
PatternProposals *PatternProposalConfig `json:"pattern_proposals,omitempty"`
|
||||
|
||||
// Praxis — the ecosystem attention-state service. When configured, maven
|
||||
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
||||
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
||||
@@ -352,6 +358,40 @@ type DigestConfig struct {
|
||||
SeverityCeiling int `json:"severity_ceiling,omitempty"` // max sev batched
|
||||
}
|
||||
|
||||
// PatternProposalConfig — announcement policy for routines the digestion tick
|
||||
// inferred by itself (Vikunja #247, #43).
|
||||
//
|
||||
// Detection is always on and always silent by default: the tick writes a
|
||||
// proposed_routines row and the /routines page shows it. Notify is what turns
|
||||
// "she noticed" into "she said something", and it is OFF unless configured —
|
||||
// Maven is not a nag and not autonomous, so a behaviour that speaks without
|
||||
// being asked has to be switched on deliberately, like weather and telegram.
|
||||
//
|
||||
// When Notify is on, the announcement is still heavily restrained:
|
||||
// - at most one proposal per tick, however many were detected;
|
||||
// - at most one per Cooldown across all pairs (not per pair), so a batch of
|
||||
// freshly-detected patterns cannot turn into a queue of interruptions;
|
||||
// - through the ordinary care-class gate (quiet hours / away / snooze), at
|
||||
// sev1 — the lowest severity there is. A proposal is the least urgent
|
||||
// thing Maven can say.
|
||||
//
|
||||
// A pair is only ever announced once, because it is only ever proposed once:
|
||||
// proposed_routines is UNIQUE(action, object) and the row survives dismissal.
|
||||
type PatternProposalConfig struct {
|
||||
// Notify — announce newly inferred routines. Default false.
|
||||
Notify bool `json:"notify,omitempty"`
|
||||
|
||||
// Cooldown — minimum spacing between two proposal announcements. 0 ⇒
|
||||
// DefaultProposalCooldown (24h).
|
||||
Cooldown Duration `json:"cooldown,omitempty"`
|
||||
}
|
||||
|
||||
// AnnounceProposals reports whether inferred routines may be announced. Safe
|
||||
// on a nil receiver — an absent config block means silent detection.
|
||||
func (p *PatternProposalConfig) AnnounceProposals() bool {
|
||||
return p != nil && p.Notify
|
||||
}
|
||||
|
||||
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
|
||||
// as a managed subprocess and sends chat-completion requests to phrase nudge
|
||||
// and reminder messages. nil ⇒ the template-based Stub is used instead.
|
||||
@@ -448,6 +488,11 @@ const (
|
||||
DefaultLLMRouter = true
|
||||
|
||||
DefaultFactEnrichmentInterval = 30 * time.Second
|
||||
|
||||
// DefaultProposalCooldown — one inferred-routine announcement per day at
|
||||
// most. A proposal is never urgent; if two patterns surface in the same
|
||||
// hour, the second one waits, and the /routines page has it either way.
|
||||
DefaultProposalCooldown = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Load reads the JSON config at path and applies defaults. A missing file is
|
||||
@@ -520,6 +565,12 @@ func (c *Config) applyDefaults() {
|
||||
c.Digest.SeverityCeiling = 2
|
||||
}
|
||||
|
||||
// Absent block stays nil (⇒ silent detection). Present-but-partial gets the
|
||||
// cooldown default, so `{"notify": true}` is enough to switch it on.
|
||||
if c.PatternProposals != nil && c.PatternProposals.Cooldown <= 0 {
|
||||
c.PatternProposals.Cooldown = Duration(DefaultProposalCooldown)
|
||||
}
|
||||
|
||||
if c.Voice != nil {
|
||||
if c.Voice.RouterThreshold <= 0 {
|
||||
c.Voice.RouterThreshold = DefaultRouterThreshold
|
||||
|
||||
@@ -20,9 +20,19 @@ type ProposedRoutine struct {
|
||||
const MaxIntervalRatio = 1.5
|
||||
|
||||
// MinEvents is the minimum number of events needed to detect a pattern.
|
||||
// With N events, there are N-1 intervals; we need at least 2 intervals
|
||||
// before proposing anything.
|
||||
const MinEvents = 3
|
||||
// With N events there are N-1 intervals, so 4 events means 3 intervals.
|
||||
//
|
||||
// This used to be 3 (two intervals), which is not a pattern — it is a
|
||||
// coincidence with a mean. Two gaps of similar length happen constantly:
|
||||
// water the plants on a Sunday, again the next Sunday, once more the Sunday
|
||||
// after, and a detector with a ±50% band calls that a weekly routine. The
|
||||
// cost of being wrong is asymmetric now that the digestion tick scans all of
|
||||
// history on its own schedule and can announce what it finds: a false
|
||||
// positive is something the owner has to read and dismiss, and a dismissal
|
||||
// is permanent, so one bad guess burns that action+object pair forever.
|
||||
// Three intervals is the cheapest bar that makes a run distinguishable from
|
||||
// a repeat. False negatives cost one more observation and nothing else.
|
||||
const MinEvents = 4
|
||||
|
||||
// Detect checks whether a sequence of events for the same action+object
|
||||
// forms a stable recurring pattern. Returns a ProposedRoutine when:
|
||||
|
||||
@@ -6,12 +6,13 @@ import (
|
||||
)
|
||||
|
||||
func TestDetectEnoughEvents(t *testing.T) {
|
||||
// 3 events with 7-day intervals → stable pattern
|
||||
// MinEvents events with 7-day intervals → stable pattern
|
||||
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||
events := []Event{
|
||||
{Action: "refill", Object: "cat_water", Ts: base},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 24 * time.Hour)},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(21 * 24 * time.Hour)},
|
||||
}
|
||||
|
||||
r, err := Detect(events)
|
||||
@@ -24,8 +25,8 @@ func TestDetectEnoughEvents(t *testing.T) {
|
||||
if r.Action != "refill" || r.Object != "cat_water" {
|
||||
t.Fatalf("action/object: want refill/cat_water, got %s/%s", r.Action, r.Object)
|
||||
}
|
||||
if r.N != 3 {
|
||||
t.Fatalf("want N=3, got %d", r.N)
|
||||
if r.N != 4 {
|
||||
t.Fatalf("want N=4, got %d", r.N)
|
||||
}
|
||||
// ~7 days
|
||||
if r.IntervalDays < 6.9 || r.IntervalDays > 7.1 {
|
||||
@@ -33,19 +34,28 @@ func TestDetectEnoughEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectNotEnoughEvents — two intervals are a coincidence, not a routine
|
||||
// (Vikunja #43). Three same-day-of-week events used to be enough to propose a
|
||||
// weekly reminder; MinEvents is 4 now so a repeat has to happen a third time
|
||||
// before Maven calls it a pattern.
|
||||
func TestDetectNotEnoughEvents(t *testing.T) {
|
||||
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||
events := []Event{
|
||||
{Action: "refill", Object: "cat_water", Ts: base},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
|
||||
}
|
||||
|
||||
r, err := Detect(events)
|
||||
if err != nil {
|
||||
t.Fatalf("Detect: %v", err)
|
||||
}
|
||||
if r != nil {
|
||||
t.Fatal("want nil for <3 events")
|
||||
for _, n := range []int{1, 2, MinEvents - 1} {
|
||||
events := make([]Event, n)
|
||||
for i := range events {
|
||||
events[i] = Event{
|
||||
Action: "refill",
|
||||
Object: "cat_water",
|
||||
Ts: base.Add(time.Duration(i) * 7 * 24 * time.Hour),
|
||||
}
|
||||
}
|
||||
r, err := Detect(events)
|
||||
if err != nil {
|
||||
t.Fatalf("Detect(%d events): %v", n, err)
|
||||
}
|
||||
if r != nil {
|
||||
t.Fatalf("Detect(%d events) proposed %+v, want nil below MinEvents=%d", n, r, MinEvents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +78,13 @@ func TestDetectEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDetectIrregularRejects(t *testing.T) {
|
||||
// 3 events but wildly irregular: 1 day, then 14 days → ratio 14 > 1.5
|
||||
// wildly irregular: 1 day, then 14 days → ratio 14 > 1.5
|
||||
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||
events := []Event{
|
||||
{Action: "refill", Object: "cat_water", Ts: base},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(1 * 24 * time.Hour)},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(15 * 24 * time.Hour)},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(16 * 24 * time.Hour)},
|
||||
}
|
||||
|
||||
r, err := Detect(events)
|
||||
@@ -117,6 +128,7 @@ func TestDetectSameTimestamp(t *testing.T) {
|
||||
{Action: "refill", Object: "cat_water", Ts: base},
|
||||
{Action: "refill", Object: "cat_water", Ts: base},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 24 * time.Hour)},
|
||||
}
|
||||
|
||||
r, err := Detect(events)
|
||||
|
||||
Reference in New Issue
Block a user