Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc7c72a3d7 | |||
| 766ca091a7 | |||
| c5317eb2b4 | |||
| 9190f897a3 | |||
| d29e7ba813 |
+19
-2
@@ -169,6 +169,7 @@ func run(args []string) error {
|
|||||||
coreAPI ipc.CoreAPI
|
coreAPI ipc.CoreAPI
|
||||||
eco *ecosystemWiring
|
eco *ecosystemWiring
|
||||||
factWorker *factEnrichmentWorker
|
factWorker *factEnrichmentWorker
|
||||||
|
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
|
||||||
)
|
)
|
||||||
|
|
||||||
if !locked {
|
if !locked {
|
||||||
@@ -261,8 +262,9 @@ func run(args []string) error {
|
|||||||
tickInterval := time.Duration(cfg.TickInterval)
|
tickInterval := time.Duration(cfg.TickInterval)
|
||||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
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))
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||||
|
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||||
|
|
||||||
coreAPI = &daemonAPI{
|
coreAPI = &daemonAPI{
|
||||||
CoreAPI: ipc.NewStoreAPI(st),
|
CoreAPI: ipc.NewStoreAPI(st),
|
||||||
@@ -438,8 +440,9 @@ func run(args []string) error {
|
|||||||
tickInterval := time.Duration(cfg.TickInterval)
|
tickInterval := time.Duration(cfg.TickInterval)
|
||||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
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))
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||||
|
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||||
|
|
||||||
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
||||||
newAPI := &daemonAPI{
|
newAPI := &daemonAPI{
|
||||||
@@ -476,6 +479,13 @@ func run(args []string) error {
|
|||||||
factWorker.run(ctx)
|
factWorker.run(ctx)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Start background memory evaluation (nil unless configured).
|
||||||
|
if evalWorker != nil {
|
||||||
|
go func() {
|
||||||
|
evalWorker.run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
dl.unlock()
|
dl.unlock()
|
||||||
log.Printf("mavend: unlocked via passkey assertion")
|
log.Printf("mavend: unlocked via passkey assertion")
|
||||||
return nil
|
return nil
|
||||||
@@ -514,6 +524,13 @@ func run(args []string) error {
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
factWorker.run(ctx)
|
factWorker.run(ctx)
|
||||||
}()
|
}()
|
||||||
|
if evalWorker != nil {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
evalWorker.run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// mavend/memoryeval.go — the driver for background memory evaluation
|
||||||
|
// (Vikunja #248). The evaluator itself is pure-ish and lives in
|
||||||
|
// internal/memeval; this is the one impure part: a ticker, the store, and the
|
||||||
|
// resident model's base URL.
|
||||||
|
//
|
||||||
|
// It is its own goroutine and NOT a step on the main tick, deliberately. The
|
||||||
|
// tick runs every 60s and has a delivery deadline behind it; an evaluation is
|
||||||
|
// a multi-second LLM round-trip on the same llama-server that answers voice
|
||||||
|
// turns, and it happens hourly at most. Bolting it onto the tick would make
|
||||||
|
// every hour's tick the slow one for no benefit.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
"github.com/kami/maven/internal/memeval"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memoryEvalWorker — ticker + evaluator.
|
||||||
|
type memoryEvalWorker struct {
|
||||||
|
eval *memeval.Evaluator
|
||||||
|
interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMemoryEvalWorker wires the evaluation loop, or returns nil when it should
|
||||||
|
// not run at all. nil is the normal case and every caller must handle it:
|
||||||
|
//
|
||||||
|
// - no memory_eval config block ⇒ off (a capability is off unless configured);
|
||||||
|
// - no LLM phraser ⇒ nothing to evaluate with. There is no template fallback
|
||||||
|
// here on purpose: a "memory evaluation" assembled from string templates
|
||||||
|
// would be a fixed sentence pretending to be an observation.
|
||||||
|
func newMemoryEvalWorker(st *store.Store, phr phraser.Phraser, cfg *config.Config) *memoryEvalWorker {
|
||||||
|
if cfg.MemoryEval == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lp, ok := phr.(*phraser.LLMPhraser)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("memory eval: configured but no llama-server phraser — evaluation disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
interval := time.Duration(cfg.MemoryEval.Interval)
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = config.DefaultMemoryEvalInterval
|
||||||
|
}
|
||||||
|
// A generous per-request timeout: this is a long prompt to a Thinking model
|
||||||
|
// and nobody is waiting on the answer.
|
||||||
|
client := llm.New(lp.BaseURL(), 5*time.Minute)
|
||||||
|
ev := memeval.NewEvaluator(st, st, client, memeval.Config{
|
||||||
|
MaxItems: cfg.MemoryEval.MaxItems,
|
||||||
|
MinConfidence: cfg.MemoryEval.MinConfidence,
|
||||||
|
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||||
|
})
|
||||||
|
log.Printf("memory eval: enabled, every %s", interval)
|
||||||
|
return &memoryEvalWorker{eval: ev, interval: interval}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run evaluates every interval until ctx is canceled.
|
||||||
|
//
|
||||||
|
// The first evaluation waits a full interval rather than firing at startup, the
|
||||||
|
// opposite of the tick loop's cold-start behaviour. A tick that fires late is a
|
||||||
|
// nudge that arrives late; an evaluation that fires late is nothing at all, and
|
||||||
|
// the alternative is a heavy LLM call competing with startup — including with
|
||||||
|
// the first voice turn after a restart.
|
||||||
|
func (w *memoryEvalWorker) run(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(w.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-ticker.C:
|
||||||
|
obs, err := w.eval.Evaluate(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("memory eval: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, o := range obs {
|
||||||
|
log.Printf("memory eval: noted (%.2f, %s): %s", o.Conf, o.Action, o.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-1
@@ -1,6 +1,6 @@
|
|||||||
// mavend/patterns.go — the shared detect+propose step of pattern inference
|
// mavend/patterns.go — the shared detect+propose step of pattern inference
|
||||||
// (Vikunja #43). Event *extraction* (fact -> action/object) happens at fact-
|
// (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
|
// 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
|
// 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 "напоминать?"
|
// runs both right after a voice fact-write (for the immediate "напоминать?"
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/pattern"
|
"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
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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"
|
"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
|
// TestTickDetectsPatternFromStoredEvents proves the tick notices a pattern on
|
||||||
// its own, reading straight from the store — not as a side effect of a live
|
// 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
|
// utterance (Vikunja #43). MinEvents weekly events with no voice turn in
|
||||||
// must produce exactly one proposed routine.
|
// sight must produce exactly one proposed routine.
|
||||||
func TestTickDetectsPatternFromStoredEvents(t *testing.T) {
|
func TestTickDetectsPatternFromStoredEvents(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := refNow()
|
now := refNow()
|
||||||
seedRefillEvents(t, st, ctx, now, 3)
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
|
||||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||||
tl.detectPatterns(ctx, now)
|
tl.detectPatterns(ctx, now, loop.State{})
|
||||||
|
|
||||||
rows, err := st.ListProposedRoutines(ctx)
|
rows, err := st.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -58,11 +63,11 @@ func TestTickPatternDetectionIsIdempotent(t *testing.T) {
|
|||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := refNow()
|
now := refNow()
|
||||||
seedRefillEvents(t, st, ctx, now, 3)
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
|
||||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||||
tl.detectPatterns(ctx, now)
|
tl.detectPatterns(ctx, now, loop.State{})
|
||||||
tl.detectPatterns(ctx, now.Add(time.Hour))
|
tl.detectPatterns(ctx, now.Add(time.Hour), loop.State{})
|
||||||
|
|
||||||
rows, err := st.ListProposedRoutines(ctx)
|
rows, err := st.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,10 +86,10 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
|
|||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := refNow()
|
now := refNow()
|
||||||
seedRefillEvents(t, st, ctx, now, 3)
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
|
||||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||||
tl.detectPatterns(ctx, now)
|
tl.detectPatterns(ctx, now, loop.State{})
|
||||||
|
|
||||||
rows, err := st.ListProposedRoutines(ctx)
|
rows, err := st.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
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
|
// More events for the same pair arrive, and the tick runs again — a
|
||||||
// dismissed pattern must not resurface.
|
// dismissed pattern must not resurface.
|
||||||
seedRefillEvents(t, st, ctx, now.Add(30*24*time.Hour), 3)
|
seedRefillEvents(t, st, ctx, now.Add(30*24*time.Hour), pattern.MinEvents)
|
||||||
tl.detectPatterns(ctx, now.Add(60*24*time.Hour))
|
tl.detectPatterns(ctx, now.Add(60*24*time.Hour), loop.State{})
|
||||||
|
|
||||||
proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,3 +125,161 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
|
|||||||
t.Errorf("status = %s, want dismissed", all[0].Status)
|
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/ipc"
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
"github.com/kami/maven/internal/morning"
|
"github.com/kami/maven/internal/morning"
|
||||||
|
"github.com/kami/maven/internal/pattern"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/routine"
|
"github.com/kami/maven/internal/routine"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -67,6 +68,14 @@ type tickLoop struct {
|
|||||||
morningRoutines []morning.Routine
|
morningRoutines []morning.Routine
|
||||||
morningLast map[string]time.Time
|
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.
|
// digestQ — in-memory queue of eligible nudges waiting for batch flush.
|
||||||
// populated when digestCfg != nil && digestCfg.Enabled.
|
// populated when digestCfg != nil && digestCfg.Enabled.
|
||||||
digestQ []QueuedNudge
|
digestQ []QueuedNudge
|
||||||
@@ -92,6 +101,7 @@ func newTickLoop(
|
|||||||
digestCfg *config.DigestConfig,
|
digestCfg *config.DigestConfig,
|
||||||
routines []routine.Routine,
|
routines []routine.Routine,
|
||||||
morningRoutines []morning.Routine,
|
morningRoutines []morning.Routine,
|
||||||
|
proposalCfg *config.PatternProposalConfig,
|
||||||
) *tickLoop {
|
) *tickLoop {
|
||||||
return &tickLoop{
|
return &tickLoop{
|
||||||
store: st,
|
store: st,
|
||||||
@@ -108,6 +118,7 @@ func newTickLoop(
|
|||||||
routineLast: make(map[string]time.Time),
|
routineLast: make(map[string]time.Time),
|
||||||
morningRoutines: morningRoutines,
|
morningRoutines: morningRoutines,
|
||||||
morningLast: make(map[string]time.Time),
|
morningLast: make(map[string]time.Time),
|
||||||
|
proposalCfg: proposalCfg,
|
||||||
lastPhrase: make(map[string]delivery.PhrasedNudge),
|
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
|
// path, so a pattern already sitting in history went unnoticed until he
|
||||||
// happened to mention it again by voice. See patterns.go and
|
// happened to mention it again by voice. See patterns.go and
|
||||||
// detectPatterns below for how idempotence and dismissal are respected.
|
// 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
|
// reminders: gate-bypassing class. fired once, marked after a successful
|
||||||
// delivery. a failed send leaves the reminder pending — the next tick
|
// 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
|
// resurrecting — there is nothing tick-specific to get right here beyond
|
||||||
// calling the same shared path the voice route already used.
|
// 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
|
// By default this only creates a row for the /routines page to show: it does
|
||||||
// notify, ring, or speak — Maven is "not a nag, not autonomous" (CLAUDE.md),
|
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
||||||
// and detection is not the same act as disturbing him about it. A proposal
|
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
||||||
// only starts producing nudges once he accepts it (fireAcceptedRoutines).
|
// is opt-in through the pattern_proposals config block — see announceProposal
|
||||||
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time) {
|
// 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)
|
pairs, err := t.store.DistinctEventPairs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("tick: distinct event pairs: %v", err)
|
log.Printf("tick: distinct event pairs: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
announced := false
|
||||||
for _, p := range pairs {
|
for _, p := range pairs {
|
||||||
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
||||||
if err != nil {
|
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
|
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)
|
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
|
// digestExpiry — how long a gate-suppressed care nudge stays worth
|
||||||
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
|
// 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
|
// 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,
|
Nudges: st,
|
||||||
Reminders: 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) {
|
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
||||||
@@ -63,7 +63,7 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
|||||||
sink := &fakeSink{}
|
sink := &fakeSink{}
|
||||||
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
|
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}}
|
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.
|
// first tick: seeds, does not fire the routine.
|
||||||
tl.tick(ctx, now)
|
tl.tick(ctx, now)
|
||||||
|
|||||||
@@ -50,13 +50,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
"github.com/kami/maven/internal/dialogue"
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
"github.com/kami/maven/internal/pattern"
|
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -277,181 +275,6 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
|
|||||||
return ""
|
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
|
// replySystem answers system-observable queries using the handler's clock
|
||||||
// and (in future) system interfaces. The decision's utterance is parsed
|
// and (in future) system interfaces. The decision's utterance is parsed
|
||||||
// for keywords to determine what the user is asking about.
|
// for keywords to determine what the user is asking about.
|
||||||
|
|||||||
@@ -63,6 +63,18 @@ type fakeCore struct {
|
|||||||
// for handleTrace tests
|
// for handleTrace tests
|
||||||
tickTrace ipc.TickTrace
|
tickTrace ipc.TickTrace
|
||||||
traceErr error
|
traceErr error
|
||||||
|
|
||||||
|
// for handleChatAPI tests
|
||||||
|
chatText string
|
||||||
|
chatErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCore) Chat(_ context.Context, text string) (string, error) {
|
||||||
|
f.chatText = text
|
||||||
|
if f.chatErr != nil {
|
||||||
|
return "", f.chatErr
|
||||||
|
}
|
||||||
|
return "поняла", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error {
|
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error {
|
||||||
@@ -1045,3 +1057,64 @@ func TestHandleRoutines_NilCore_503(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 503", rr.Code)
|
t.Fatalf("status = %d, want 503", rr.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- handleChatAPI step-up gate (Vikunja #317) ---
|
||||||
|
//
|
||||||
|
// POST /api/chat reaches the router, the LLM and the act path, so it carries
|
||||||
|
// the same gate as POST /tools and POST /api/revert.
|
||||||
|
|
||||||
|
func postChat(text string) *http.Request {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader("text="+url.QueryEscape(text)))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatAPI_RequireStepUp_FailsClosed(t *testing.T) {
|
||||||
|
core := &fakeCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleChatAPI(rr, postChat("выключи свет"), core, nil, true)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if core.chatText != "" {
|
||||||
|
t.Errorf("core.Chat called with %q, but -require-stepup should deny", core.chatText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatAPI_UnassertedSession_Denied(t *testing.T) {
|
||||||
|
core := &fakeCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleChatAPI(rr, postChat("выключи свет"), core, webauthn.NewPasskeySession(5*time.Minute), false)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
if core.chatText != "" {
|
||||||
|
t.Errorf("core.Chat called with %q despite an unasserted session", core.chatText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatAPI_AssertedSession_PassesGate(t *testing.T) {
|
||||||
|
core := &fakeCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleChatAPI(rr, postChat("привет"), core, stepUpSession(), true)
|
||||||
|
if rr.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303; body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if core.chatText != "привет" {
|
||||||
|
t.Errorf("core.Chat text = %q, want %q", core.chatText, "привет")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default deploy: WebAuthn unconfigured and -require-stepup off ⇒ chat keeps
|
||||||
|
// working, resting on the transport-level auth in front of mavweb.
|
||||||
|
func TestHandleChatAPI_FailOpenByDefault(t *testing.T) {
|
||||||
|
core := &fakeCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleChatAPI(rr, postChat("привет"), core, nil, false)
|
||||||
|
if rr.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", rr.Code)
|
||||||
|
}
|
||||||
|
if core.chatText != "привет" {
|
||||||
|
t.Errorf("core.Chat text = %q, want %q", core.chatText, "привет")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+31
-8
@@ -329,7 +329,7 @@ func main() {
|
|||||||
coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)")
|
coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)")
|
||||||
pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)")
|
pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)")
|
||||||
pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)")
|
pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)")
|
||||||
requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (/tools POST, /api/revert) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour")
|
requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /api/revert, /api/chat) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour")
|
||||||
pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)")
|
pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)")
|
||||||
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
|
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
|
||||||
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
|
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
|
||||||
@@ -434,9 +434,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
if stepUpSession == nil {
|
if stepUpSession == nil {
|
||||||
if *requireStepUp {
|
if *requireStepUp {
|
||||||
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv) and POST /api/revert will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
|
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv), POST /routines (accepting schedules recurring firing), POST /api/revert and POST /api/chat (reaches the router, the LLM and the act path) will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
|
||||||
} else {
|
} else {
|
||||||
log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES) and POST /api/revert (voids the latest fact for a key). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.")
|
log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES), POST /routines (accepting schedules recurring firing), POST /api/revert (voids the latest fact for a key) and POST /api/chat (reaches the router, the LLM and, through applyAction, the act path). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,14 +454,26 @@ func main() {
|
|||||||
handleRoutines(w, r, core, stepUpSession, *requireStepUp)
|
handleRoutines(w, r, core, stepUpSession, *requireStepUp)
|
||||||
})
|
})
|
||||||
|
|
||||||
// /api/revert voids the latest fact for a key — a store mutation, so it
|
// State-changing routes on this server, and their gate (Vikunja #317):
|
||||||
// sits behind the same passkey step-up as tool enable (nil session ⇒
|
//
|
||||||
// WebAuthn unconfigured ⇒ transport-level auth only, same as /tools).
|
// POST /tools step-up — defines argv that internal/tool executes
|
||||||
|
// POST /routines step-up — accepting schedules recurring firing
|
||||||
|
// POST /api/revert step-up — voids the latest fact for a key
|
||||||
|
// POST /api/chat step-up — reaches the router, LLM and the act path
|
||||||
|
// POST /api/signal none — appends a presence fact, no argv, no act
|
||||||
|
// POST /api/ptt, /ws none — proxy audio to mavend's voice port, which
|
||||||
|
// is itself only reachable inside the deploy
|
||||||
|
//
|
||||||
|
// "step-up" means stepUpOK: asserted passkey when WebAuthn is configured,
|
||||||
|
// otherwise fail-open unless -require-stepup, which denies.
|
||||||
|
//
|
||||||
|
// GET /chat only renders the page and echoes back the q/r query params the
|
||||||
|
// POST redirect set — nothing to gate.
|
||||||
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleChatPage(w, r, core)
|
handleChatPage(w, r, core)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleChatAPI(w, r, core)
|
handleChatAPI(w, r, core, stepUpSession, *requireStepUp)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleRevert(w, r, core, stepUpSession, *requireStepUp)
|
handleRevert(w, r, core, stepUpSession, *requireStepUp)
|
||||||
@@ -1258,7 +1270,14 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleChatAPI processes a chat message POST and redirects back to /chat.
|
// handleChatAPI processes a chat message POST and redirects back to /chat.
|
||||||
func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
//
|
||||||
|
// State-changing, and the widest surface on this server: the text reaches the
|
||||||
|
// router, the LLM, and through mavend's applyAction the whole action path
|
||||||
|
// including `act` — so it is gated on the same step-up as POST /tools and
|
||||||
|
// POST /api/revert (Vikunja #317). With WebAuthn unconfigured the gate is
|
||||||
|
// fail-open exactly like the others (see stepUpOK); with -require-stepup it
|
||||||
|
// denies, which is the point of that flag.
|
||||||
|
func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -1267,6 +1286,10 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable)
|
http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !stepUpOK(session, requireStepUp) {
|
||||||
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
text := strings.TrimSpace(r.FormValue("text"))
|
text := strings.TrimSpace(r.FormValue("text"))
|
||||||
if text == "" {
|
if text == "" {
|
||||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||||
|
|||||||
@@ -8,6 +8,18 @@
|
|||||||
#
|
#
|
||||||
# Maven's own compose joins this same network (add `ecosystem` as an external
|
# Maven's own compose joins this same network (add `ecosystem` as an external
|
||||||
# network there) to reach nexus:9740 / praxis:8989 / hexis:9741 directly.
|
# network there) to reach nexus:9740 / praxis:8989 / hexis:9741 directly.
|
||||||
|
#
|
||||||
|
# NO RELEASE PINNING (Vikunja #354): each `build:` below points at a sibling
|
||||||
|
# WORKING TREE, so `up --build` ships whatever is checked out there, including
|
||||||
|
# uncommitted edits. Before bringing this up, check what you are about to
|
||||||
|
# deploy:
|
||||||
|
#
|
||||||
|
# for r in nexus praxis hexis; do git -C ../../../$r status --short; \
|
||||||
|
# git -C ../../../$r log -1 --oneline; done
|
||||||
|
#
|
||||||
|
# The host nginx that fronts these is deploy/ecosystem/nginx.conf — it binds
|
||||||
|
# the wg and LAN addresses only, with allow/deny. Keep it that way: none of
|
||||||
|
# these containers has auth of its own.
|
||||||
name: ecosystem
|
name: ecosystem
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Reverse-proxy the three sibling admin UIs. Drop into your nginx sites (or the
|
# Reverse-proxy Maven's own web UI plus the three sibling admin UIs. Drop into
|
||||||
# nginx-panel app) and reload. Assumes the compose publishes each service on
|
# your nginx sites (or the nginx-panel app) and reload. Assumes the compose
|
||||||
# 127.0.0.1:<port>. Add TLS (certbot / your existing cert block) per server.
|
# publishes each service on 127.0.0.1:<port>. Add TLS (certbot / your existing
|
||||||
|
# cert block) per server.
|
||||||
#
|
#
|
||||||
# NOTE: hexis.<domain> previously pointed at the MCP tool — repoint that
|
# NOTE: hexis.<domain> previously pointed at the MCP tool — repoint that
|
||||||
# elsewhere first (the app now owns hexis.*).
|
# elsewhere first (the app now owns hexis.*).
|
||||||
@@ -12,6 +13,50 @@
|
|||||||
# Do NOT "fix" a failed bind by reverting to `listen 80` (all interfaces) —
|
# Do NOT "fix" a failed bind by reverting to `listen 80` (all interfaces) —
|
||||||
# that removes the only access control these containers have.
|
# that removes the only access control these containers have.
|
||||||
|
|
||||||
|
# maven.<domain> → mavweb (docker-compose.yml publishes it on 127.0.0.1:9201).
|
||||||
|
# Same bind + ACL as the siblings, and for a stronger reason: mavweb serves
|
||||||
|
# POST /tools, which defines argv that internal/tool EXECUTES, plus POST
|
||||||
|
# /routines, /api/revert and /api/chat (Vikunja #317). Without
|
||||||
|
# -webauthn-origin/-webauthn-rpid mavweb has no auth of its own, so this block
|
||||||
|
# is the auth. If you add TLS and a basic-auth/oauth2-proxy layer, keep the
|
||||||
|
# allow/deny anyway — belt and braces on an RCE surface.
|
||||||
|
#
|
||||||
|
# WebSocket upgrade matters here: /ws carries push-to-talk audio, so the
|
||||||
|
# Upgrade/Connection headers below are required, not decoration. The map keeps
|
||||||
|
# `Connection: upgrade` off plain requests; it sits in the http context, which
|
||||||
|
# is where sites-available files are included — if your nginx already defines
|
||||||
|
# $connection_upgrade, drop this block.
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 10.42.0.1:80;
|
||||||
|
listen 192.168.1.104:80;
|
||||||
|
server_name maven.kvmx.ru;
|
||||||
|
|
||||||
|
allow 10.42.0.0/24;
|
||||||
|
allow 192.168.1.0/24;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
# push-to-talk uploads raw PCM; the default 1m is enough for a short
|
||||||
|
# utterance but not for a long one.
|
||||||
|
client_max_body_size 32m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:9201;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 300s; # an LLM turn can take minutes on the iGPU
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 10.42.0.1:80;
|
listen 10.42.0.1:80;
|
||||||
listen 192.168.1.104:80;
|
listen 192.168.1.104:80;
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
"severity_ceiling": 2
|
"severity_ceiling": 2
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"pattern_proposals": {
|
||||||
|
"notify": false,
|
||||||
|
"cooldown": "24h"
|
||||||
|
},
|
||||||
|
|
||||||
"nexus": { "url": "http://nexus:9740" },
|
"nexus": { "url": "http://nexus:9740" },
|
||||||
"praxis": { "url": "http://praxis:8989" },
|
"praxis": { "url": "http://praxis:8989" },
|
||||||
"hexis": { "url": "http://hexis:9741" },
|
"hexis": { "url": "http://hexis:9741" },
|
||||||
|
|||||||
@@ -25,3 +25,46 @@
|
|||||||
6. Add `/eval` API method to `ipc.CoreAPI` (or reuse `Chat` with system context) so mavweb can show evaluation history
|
6. Add `/eval` API method to `ipc.CoreAPI` (or reuse `Chat` with system context) so mavweb can show evaluation history
|
||||||
7. Add `memory_eval` block to `deploy/mavend.json`
|
7. Add `memory_eval` block to `deploy/mavend.json`
|
||||||
8. Test with synthetic store state — verify observations match expected patterns
|
8. Test with synthetic store state — verify observations match expected patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status 2026-08-01 — foundation shipped (Vikunja #248)
|
||||||
|
|
||||||
|
**Shipped:** `internal/memeval` (not `internal/memory/eval.go` — `internal/store`
|
||||||
|
imports `internal/memory` for the vector backend, so an evaluator that reads
|
||||||
|
`store.Fact` there would close an import cycle). `Evaluator.Evaluate` reads
|
||||||
|
`RecentFacts` / `RecentNotes` / `RecentNudges`, prompts the resident model under
|
||||||
|
a GBNF grammar for at most three `{observation, confidence, suggested_action}`
|
||||||
|
objects, drops anything under `min_confidence`, deduplicates against what earlier
|
||||||
|
evaluations wrote, and records the rest as notes with source `infer:memory-eval`.
|
||||||
|
Driver: `cmd/mavend/memoryeval.go`, its own goroutine on its own ticker. Config:
|
||||||
|
the `memory_eval` block — **absent ⇒ the loop does not run**. Visibility: `/dash`
|
||||||
|
already renders notes with their source, so evaluation output is visible with no
|
||||||
|
UI change.
|
||||||
|
|
||||||
|
**Deliberately not shipped — this is policy, not an unfinished edge:**
|
||||||
|
|
||||||
|
- *Dispatching observations as care nudges (plan step 4).* An hourly LLM loop
|
||||||
|
with permission to speak is a machine for generating interruptions, and the
|
||||||
|
content is model-generated text about his own life. The evaluator has no
|
||||||
|
dispatcher reference at all, so it cannot reach a channel by accident. Wiring
|
||||||
|
it to `delivery.Dispatcher` is a separate decision with its own opt-in.
|
||||||
|
- *Acting on `suggested_action`.* It is recorded inside the note text and
|
||||||
|
interpreted by nobody. No reminder, routine or fact is created.
|
||||||
|
- *Writing observation embeddings.* Notes are written with a nil embedding, so
|
||||||
|
they stay out of the RAG recall pool. Feeding generated text back into the pool
|
||||||
|
it came from is how a small model starts citing its own guesses as evidence.
|
||||||
|
|
||||||
|
**Deferred, wants a decision or another capability:**
|
||||||
|
|
||||||
|
- *Plan step 6, the `/eval` IPC method and an evaluation-history view.* `/dash`
|
||||||
|
covers reading the output; a dedicated trace surface is worth building once
|
||||||
|
there is real output to look at, and it should probably show the prompt too.
|
||||||
|
- *`RecentEvents`.* The plan lists it; the evaluator reads facts, notes and
|
||||||
|
nudges. Detected action/object events already drive pattern proposals (#43), and
|
||||||
|
duplicating them here would mostly re-derive that.
|
||||||
|
- *Output quality is unmeasured.* There is no fixture for "did she notice
|
||||||
|
something true". The tests cover the machinery — empty store, confidence floor,
|
||||||
|
dedupe, own-notes exclusion, error handling — not the observations. Until
|
||||||
|
someone reads a week of real output on `/dash`, treat the wording and the
|
||||||
|
`min_confidence` default as unvalidated.
|
||||||
|
|||||||
@@ -140,6 +140,16 @@ type Config struct {
|
|||||||
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
|
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
|
||||||
MorningRoutines []MorningRoutineConfig `json:"morning_routines,omitempty"`
|
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"`
|
||||||
|
|
||||||
|
// MemoryEval — background memory evaluation (internal/memeval). nil /
|
||||||
|
// absent ⇒ no evaluation loop at all. See MemoryEvalConfig.
|
||||||
|
MemoryEval *MemoryEvalConfig `json:"memory_eval,omitempty"`
|
||||||
|
|
||||||
// Praxis — the ecosystem attention-state service. When configured, maven
|
// Praxis — the ecosystem attention-state service. When configured, maven
|
||||||
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
||||||
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
||||||
@@ -352,6 +362,62 @@ type DigestConfig struct {
|
|||||||
SeverityCeiling int `json:"severity_ceiling,omitempty"` // max sev batched
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryEvalConfig — the background memory-evaluation loop (Vikunja #248).
|
||||||
|
// Absent ⇒ off, like every other capability that costs something the owner did
|
||||||
|
// not ask for. Each evaluation is a full LLM round-trip on the one resident
|
||||||
|
// model, which is the same model answering him; running it hourly by default
|
||||||
|
// would put a multi-second stall in front of an occasional voice turn for a
|
||||||
|
// feature he may not want.
|
||||||
|
//
|
||||||
|
// The loop only ever writes notes (source infer:memory-eval, visible on
|
||||||
|
// /dash). It cannot speak — see internal/memeval.
|
||||||
|
type MemoryEvalConfig struct {
|
||||||
|
// Interval — how often to evaluate. 0 ⇒ DefaultMemoryEvalInterval.
|
||||||
|
Interval Duration `json:"interval,omitempty"`
|
||||||
|
|
||||||
|
// MaxItems — recent facts / notes / nudges fed into one evaluation.
|
||||||
|
// 0 ⇒ memeval.DefaultMaxItems.
|
||||||
|
MaxItems int `json:"max_items,omitempty"`
|
||||||
|
|
||||||
|
// MinConfidence — observations the model scores below this are dropped.
|
||||||
|
// 0 ⇒ memeval.DefaultMinConfidence.
|
||||||
|
MinConfidence float64 `json:"min_confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
|
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
|
||||||
// as a managed subprocess and sends chat-completion requests to phrase nudge
|
// as a managed subprocess and sends chat-completion requests to phrase nudge
|
||||||
// and reminder messages. nil ⇒ the template-based Stub is used instead.
|
// and reminder messages. nil ⇒ the template-based Stub is used instead.
|
||||||
@@ -448,6 +514,15 @@ const (
|
|||||||
DefaultLLMRouter = true
|
DefaultLLMRouter = true
|
||||||
|
|
||||||
DefaultFactEnrichmentInterval = 30 * time.Second
|
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
|
||||||
|
|
||||||
|
// DefaultMemoryEvalInterval — the plan's cadence (1h) for the memory
|
||||||
|
// evaluation loop, applied only when the block is present at all.
|
||||||
|
DefaultMemoryEvalInterval = time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load reads the JSON config at path and applies defaults. A missing file is
|
// Load reads the JSON config at path and applies defaults. A missing file is
|
||||||
@@ -520,6 +595,18 @@ func (c *Config) applyDefaults() {
|
|||||||
c.Digest.SeverityCeiling = 2
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same rule: absent stays nil (⇒ no evaluation loop), present gets defaults
|
||||||
|
// so `{}` is a valid "on with the plan's cadence".
|
||||||
|
if c.MemoryEval != nil && c.MemoryEval.Interval <= 0 {
|
||||||
|
c.MemoryEval.Interval = Duration(DefaultMemoryEvalInterval)
|
||||||
|
}
|
||||||
|
|
||||||
if c.Voice != nil {
|
if c.Voice != nil {
|
||||||
if c.Voice.RouterThreshold <= 0 {
|
if c.Voice.RouterThreshold <= 0 {
|
||||||
c.Voice.RouterThreshold = DefaultRouterThreshold
|
c.Voice.RouterThreshold = DefaultRouterThreshold
|
||||||
|
|||||||
@@ -243,3 +243,53 @@ func TestDurationRoundTrip(t *testing.T) {
|
|||||||
t.Errorf("round-trip = %v, want %v", d2, d)
|
t.Errorf("round-trip = %v, want %v", d2, d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both new opt-in capabilities follow the same rule: absent block ⇒ nil ⇒ the
|
||||||
|
// behaviour does not exist. Presence is the enable act, so a bare `{}` block is
|
||||||
|
// valid and gets the defaults filled in.
|
||||||
|
func TestOptInBlocksAbsentStayNil(t *testing.T) {
|
||||||
|
c, err := Load(writeConfig(t, `{}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if c.PatternProposals != nil {
|
||||||
|
t.Errorf("pattern_proposals absent but got %+v", c.PatternProposals)
|
||||||
|
}
|
||||||
|
if c.PatternProposals.AnnounceProposals() {
|
||||||
|
t.Error("AnnounceProposals() true with no config block")
|
||||||
|
}
|
||||||
|
if c.MemoryEval != nil {
|
||||||
|
t.Errorf("memory_eval absent but got %+v", c.MemoryEval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptInBlocksGetDefaultsWhenPresent(t *testing.T) {
|
||||||
|
c, err := Load(writeConfig(t, `{"pattern_proposals":{"notify":true},"memory_eval":{}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if !c.PatternProposals.AnnounceProposals() {
|
||||||
|
t.Error("notify:true did not enable announcements")
|
||||||
|
}
|
||||||
|
if time.Duration(c.PatternProposals.Cooldown) != DefaultProposalCooldown {
|
||||||
|
t.Errorf("proposal cooldown = %v, want %v", c.PatternProposals.Cooldown, DefaultProposalCooldown)
|
||||||
|
}
|
||||||
|
if time.Duration(c.MemoryEval.Interval) != DefaultMemoryEvalInterval {
|
||||||
|
t.Errorf("memory eval interval = %v, want %v", c.MemoryEval.Interval, DefaultMemoryEvalInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify is off even when the block exists — the block is where you tune it,
|
||||||
|
// notify:true is the act that lets her speak.
|
||||||
|
func TestPatternProposalNotifyDefaultsOff(t *testing.T) {
|
||||||
|
c, err := Load(writeConfig(t, `{"pattern_proposals":{"cooldown":"6h"}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if c.PatternProposals.AnnounceProposals() {
|
||||||
|
t.Error("notify defaulted to on")
|
||||||
|
}
|
||||||
|
if time.Duration(c.PatternProposals.Cooldown) != 6*time.Hour {
|
||||||
|
t.Errorf("cooldown = %v, want 6h", c.PatternProposals.Cooldown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
// Package memeval is background memory evaluation (Vikunja #248,
|
||||||
|
// docs/plans/03-memory-evaluation.md).
|
||||||
|
//
|
||||||
|
// It lives beside internal/memory rather than inside it because
|
||||||
|
// internal/store imports internal/memory for the vector-store backend, and an
|
||||||
|
// evaluator has to read store.Fact / store.Note / store.Nudge — putting it in
|
||||||
|
// internal/memory would close that import cycle.
|
||||||
|
//
|
||||||
|
// Every so often Maven reads back her own recent memory — facts, notes, the
|
||||||
|
// nudges she sent — and asks the resident model what it notices: a habit that
|
||||||
|
// stopped, a gap, something worth saying later. What comes back is written as
|
||||||
|
// notes with source EvalNoteSource and nothing else happens. That restraint is
|
||||||
|
// the design, not an unfinished edge:
|
||||||
|
//
|
||||||
|
// - She does not speak here. There is no dispatcher, no channel, no nudge.
|
||||||
|
// An observation is a thought she wrote down; he reads it on /dash when he
|
||||||
|
// wants to. "Not a nag, not autonomous" (CLAUDE.md) is easy to violate with
|
||||||
|
// exactly this feature — an hourly loop with an LLM in it and permission to
|
||||||
|
// talk is a machine for generating interruptions — so the loop has no way
|
||||||
|
// to reach him at all. Turning observations into nudges is a separate
|
||||||
|
// decision with a separate opt-in, and it is deliberately NOT in this file.
|
||||||
|
// - She does not act. No reminder is created, no routine proposed, no fact
|
||||||
|
// written. The model's suggested_action is recorded as text inside the note
|
||||||
|
// and interpreted by nobody.
|
||||||
|
// - She says nothing about an empty store. No memory ⇒ no LLM call ⇒ no
|
||||||
|
// "observations" invented out of two facts. A 1.7B asked to find a pattern
|
||||||
|
// will always find one; the defence is not asking.
|
||||||
|
//
|
||||||
|
// Everything the evaluator writes is attributable: source is EvalNoteSource, so
|
||||||
|
// an inferred observation can never be mistaken for something he said, and the
|
||||||
|
// whole batch is one SQL delete away if the output turns out to be noise.
|
||||||
|
package memeval
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
"github.com/kami/maven/internal/persona"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EvalNoteSource — the source stamped on every note the evaluator writes.
|
||||||
|
// Same infer:* convention as the rest of the derived facts.
|
||||||
|
const EvalNoteSource = "infer:memory-eval"
|
||||||
|
|
||||||
|
// DefaultMinConfidence — an observation below this is dropped. The model is
|
||||||
|
// asked for its own confidence and small models are badly calibrated, so this
|
||||||
|
// is a coarse filter, not a probability: it exists to throw away the guesses
|
||||||
|
// the model itself hedged on.
|
||||||
|
const DefaultMinConfidence = 0.7
|
||||||
|
|
||||||
|
// DefaultMaxItems — how much recent memory goes into one evaluation, per
|
||||||
|
// store. 30 facts + 30 notes + 30 nudges is a few thousand tokens of the 4096
|
||||||
|
// context the resident Thinking model runs with, which leaves room for its
|
||||||
|
// reasoning tokens. Raising this trades reasoning room for history.
|
||||||
|
const DefaultMaxItems = 30
|
||||||
|
|
||||||
|
// MaxObservations — the model may return at most this many observations per
|
||||||
|
// evaluation, enforced by the grammar. A cap here is also a noise cap: an
|
||||||
|
// evaluation that "notices" ten things has noticed nothing.
|
||||||
|
const MaxObservations = 3
|
||||||
|
|
||||||
|
// Observation — one thing the evaluator noticed.
|
||||||
|
type Observation struct {
|
||||||
|
Text string `json:"observation"`
|
||||||
|
Conf float64 `json:"confidence"`
|
||||||
|
// Action — what the model thinks should happen with this. Recorded, never
|
||||||
|
// executed: see the file comment. One of "note", "propose", "notify".
|
||||||
|
Action string `json:"suggested_action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completer — the llama-server seam, same shape router.Completer uses so the
|
||||||
|
// one resident model serves this caller too.
|
||||||
|
type Completer interface {
|
||||||
|
Complete(ctx context.Context, r llm.Req) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reader — the slice of the store an evaluation reads. Narrow on purpose: the
|
||||||
|
// evaluator gets recent memory and nothing else. No entity graph, no presence,
|
||||||
|
// no config facts.
|
||||||
|
type Reader interface {
|
||||||
|
RecentFacts(ctx context.Context, n int) ([]store.Fact, error)
|
||||||
|
RecentNotes(ctx context.Context, n int) ([]store.Note, error)
|
||||||
|
RecentNudges(ctx context.Context, n int) ([]store.Nudge, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteWriter — where observations land. Embeddings are passed nil: an
|
||||||
|
// observation is written for a human to read on /dash, not to be recalled by
|
||||||
|
// similarity. Feeding LLM-generated text back into the RAG pool it was
|
||||||
|
// generated from is how a small model starts citing its own guesses as
|
||||||
|
// evidence.
|
||||||
|
type NoteWriter interface {
|
||||||
|
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config — evaluator tuning. Zero values are replaced by the Default*
|
||||||
|
// constants, so the zero Config is the sane one.
|
||||||
|
type Config struct {
|
||||||
|
MaxItems int
|
||||||
|
MinConfidence float64
|
||||||
|
|
||||||
|
// ContextBlock — the shared persona block (internal/persona), re-evaluated
|
||||||
|
// per call so the clock in it is current. Prepended to the system prompt so
|
||||||
|
// observations come out in Maven's voice: feminine self-reference, informal
|
||||||
|
// "ты". nil is allowed; the base prompt still carries the address rules.
|
||||||
|
ContextBlock func() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluator reads recent memory and records what the model notices.
|
||||||
|
type Evaluator struct {
|
||||||
|
read Reader
|
||||||
|
write NoteWriter
|
||||||
|
llm Completer
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEvaluator(r Reader, w NoteWriter, c Completer, cfg Config) *Evaluator {
|
||||||
|
if cfg.MaxItems <= 0 {
|
||||||
|
cfg.MaxItems = DefaultMaxItems
|
||||||
|
}
|
||||||
|
if cfg.MinConfidence <= 0 {
|
||||||
|
cfg.MinConfidence = DefaultMinConfidence
|
||||||
|
}
|
||||||
|
return &Evaluator{read: r, write: w, llm: c, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evalGrammar — GBNF pinning the reply to a bounded JSON array of fixed-shape
|
||||||
|
// observations. Same reasoning as the router's routeGrammar: the enum and the
|
||||||
|
// length bound are what stop a small model from drifting into free text or
|
||||||
|
// filling the token budget with one repeated field.
|
||||||
|
const evalGrammar = `
|
||||||
|
root ::= "[" ws (obs ("," ws obs){0,2})? ws "]"
|
||||||
|
obs ::= "{" ws "\"observation\"" ws ":" ws text "," ws "\"confidence\"" ws ":" ws conf "," ws "\"suggested_action\"" ws ":" ws act ws "}"
|
||||||
|
text ::= "\"" ([^"\\] | "\\" .){1,200} "\""
|
||||||
|
conf ::= "0" "." [0-9]{1,2} | "1" ("." "0")?
|
||||||
|
act ::= "\"note\"" | "\"propose\"" | "\"notify\""
|
||||||
|
ws ::= [ \t\n]*
|
||||||
|
`
|
||||||
|
|
||||||
|
// evalSystem — the evaluation prompt. Two things it insists on, both learned
|
||||||
|
// from the phraser: state the observation as something she noticed rather than
|
||||||
|
// an instruction, and say nothing when there is nothing (the model is given an
|
||||||
|
// explicit way to return an empty array, because a model with no exit returns
|
||||||
|
// filler).
|
||||||
|
const evalSystem = `Ты просматриваешь свою собственную память: недавние факты, заметки и напоминания, которые ты отправляла.
|
||||||
|
Найди то, что действительно заметно: привычка, которая прервалась; пробел в записях; повторяющаяся закономерность.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- Отвечай ТОЛЬКО массивом JSON. Каждый элемент: {"observation": "...", "confidence": 0.0-1.0, "suggested_action": "note"|"propose"|"notify"}.
|
||||||
|
- observation — короткая фраза по-русски о том, что ты заметила. О себе — в женском роде ("я заметила"). К нему — на "ты".
|
||||||
|
- Не выдумывай. Если в памяти нет ничего заметного, верни пустой массив [].
|
||||||
|
- Не давай советов и не приказывай. Ты замечаешь, а не требуешь.
|
||||||
|
- confidence — насколько ты уверена, что это настоящая закономерность, а не совпадение.
|
||||||
|
- Максимум три наблюдения. Лучше одно точное, чем три общих.`
|
||||||
|
|
||||||
|
// Evaluate runs one evaluation and returns the observations it recorded.
|
||||||
|
//
|
||||||
|
// Returns (nil, nil) — not an error — for every ordinary "nothing to say"
|
||||||
|
// outcome: an empty store, an empty array from the model, everything below the
|
||||||
|
// confidence floor, or every observation already recorded earlier. Only a real
|
||||||
|
// read/LLM/write failure is an error, and the caller (a background ticker) logs
|
||||||
|
// it and waits for the next interval.
|
||||||
|
func (e *Evaluator) Evaluate(ctx context.Context, now time.Time) ([]Observation, error) {
|
||||||
|
snap, err := e.snapshot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if snap == "" {
|
||||||
|
return nil, nil // nothing recorded ⇒ nothing to notice, and no LLM call
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := e.llm.Complete(ctx, llm.Req{
|
||||||
|
System: persona.Prepend(e.cfg.ContextBlock, evalSystem),
|
||||||
|
User: snap,
|
||||||
|
Grammar: evalGrammar,
|
||||||
|
MaxTokens: 512,
|
||||||
|
RepeatPenalty: 1.1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("memory eval: complete: %w", err)
|
||||||
|
}
|
||||||
|
obs, err := parseObservations(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("memory eval: parse %q: %w", truncate(raw, 120), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe against what earlier evaluations already wrote. Without this an
|
||||||
|
// hourly loop over a slowly-changing store writes the same sentence every
|
||||||
|
// hour until /dash is nothing but the evaluator talking to itself.
|
||||||
|
seen, err := e.recordedTexts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var kept []Observation
|
||||||
|
for _, o := range obs {
|
||||||
|
o.Text = strings.TrimSpace(o.Text)
|
||||||
|
if o.Text == "" || o.Conf < e.cfg.MinConfidence {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
norm := normalizeObservation(o.Text)
|
||||||
|
if seen[norm] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[norm] = true
|
||||||
|
if _, err := e.write.WriteNote(ctx, now, formatNote(o), nil, EvalNoteSource); err != nil {
|
||||||
|
return kept, fmt.Errorf("memory eval: write note: %w", err)
|
||||||
|
}
|
||||||
|
kept = append(kept, o)
|
||||||
|
}
|
||||||
|
return kept, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatNote — the stored text. The suggested action is kept as a visible
|
||||||
|
// suffix rather than a column: it is the model's opinion about what to do next,
|
||||||
|
// and the only consumer is a human reading /dash.
|
||||||
|
func formatNote(o Observation) string {
|
||||||
|
if o.Action == "" {
|
||||||
|
return o.Text
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s [%s]", o.Text, o.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordedTexts — the normalized text of every observation earlier evaluations
|
||||||
|
// wrote, for dedupe. Reads a wider window than MaxItems because the point is to
|
||||||
|
// remember saying it, not to summarize it.
|
||||||
|
func (e *Evaluator) recordedTexts(ctx context.Context) (map[string]bool, error) {
|
||||||
|
notes, err := e.read.RecentNotes(ctx, 200)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("memory eval: recent notes: %w", err)
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool, len(notes))
|
||||||
|
for _, n := range notes {
|
||||||
|
if n.Source != EvalNoteSource {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := n.Text
|
||||||
|
// Strip the "[action]" suffix formatNote appended.
|
||||||
|
if i := strings.LastIndex(text, " ["); i > 0 && strings.HasSuffix(text, "]") {
|
||||||
|
text = text[:i]
|
||||||
|
}
|
||||||
|
seen[normalizeObservation(text)] = true
|
||||||
|
}
|
||||||
|
return seen, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeObservation — dedupe key. Case- and whitespace-insensitive, which
|
||||||
|
// catches the realistic repeat (the model re-emitting the same sentence with a
|
||||||
|
// different comma) without pretending to do semantic dedupe.
|
||||||
|
func normalizeObservation(s string) string {
|
||||||
|
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshot renders recent memory as the user turn. Returns "" when there is
|
||||||
|
// nothing in any store — the caller treats that as "do not ask the model".
|
||||||
|
//
|
||||||
|
// Notes written by earlier evaluations are excluded. Feeding her own
|
||||||
|
// observations back in is how "я заметила, что ты не записывал еду" becomes
|
||||||
|
// evidence for noticing it again, three evaluations deep.
|
||||||
|
func (e *Evaluator) snapshot(ctx context.Context) (string, error) {
|
||||||
|
n := e.cfg.MaxItems
|
||||||
|
facts, err := e.read.RecentFacts(ctx, n)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("memory eval: recent facts: %w", err)
|
||||||
|
}
|
||||||
|
notes, err := e.read.RecentNotes(ctx, n)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("memory eval: recent notes: %w", err)
|
||||||
|
}
|
||||||
|
nudges, err := e.read.RecentNudges(ctx, n)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("memory eval: recent nudges: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
wrote := false
|
||||||
|
if len(facts) > 0 {
|
||||||
|
b.WriteString("Факты:\n")
|
||||||
|
for _, f := range facts {
|
||||||
|
fmt.Fprintf(&b, "- %s %s=%s (%s)\n", f.Ts.Format("2006-01-02 15:04"), f.Key, truncate(f.Value, 80), f.Source)
|
||||||
|
wrote = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
own := 0
|
||||||
|
var noteLines []string
|
||||||
|
for _, nt := range notes {
|
||||||
|
if nt.Source == EvalNoteSource {
|
||||||
|
own++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
noteLines = append(noteLines, fmt.Sprintf("- %s %s\n", nt.Ts.Format("2006-01-02 15:04"), truncate(nt.Text, 160)))
|
||||||
|
}
|
||||||
|
if len(noteLines) > 0 {
|
||||||
|
b.WriteString("\nЗаметки:\n")
|
||||||
|
for _, l := range noteLines {
|
||||||
|
b.WriteString(l)
|
||||||
|
wrote = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(nudges) > 0 {
|
||||||
|
b.WriteString("\nНапоминания, которые ты отправляла:\n")
|
||||||
|
for _, nd := range nudges {
|
||||||
|
outcome := nd.Outcome
|
||||||
|
if outcome == "" {
|
||||||
|
outcome = "?"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "- %s %s → %s (%s)\n", nd.Ts.Format("2006-01-02 15:04"), nd.Rule, outcome, nd.Channel)
|
||||||
|
wrote = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !wrote {
|
||||||
|
// Only her own past observations, or nothing at all. Either way there is
|
||||||
|
// no new memory to evaluate.
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
b.WriteString("\nЧто ты замечаешь?")
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseObservations reads the model's array. Tolerates the leading/trailing
|
||||||
|
// prose a Thinking model sometimes emits around JSON by taking the outermost
|
||||||
|
// bracketed span, the same tolerance the router's parser has.
|
||||||
|
func parseObservations(raw string) ([]Observation, error) {
|
||||||
|
s := strings.TrimSpace(raw)
|
||||||
|
if i := strings.Index(s, "["); i >= 0 {
|
||||||
|
if j := strings.LastIndex(s, "]"); j > i {
|
||||||
|
s = s[i : j+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var obs []Observation
|
||||||
|
if err := json.Unmarshal([]byte(s), &obs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(obs) > MaxObservations {
|
||||||
|
// The grammar bounds this; a grammar-less server or a future prompt
|
||||||
|
// change must not be able to flood /dash.
|
||||||
|
sort.SliceStable(obs, func(i, j int) bool { return obs[i].Conf > obs[j].Conf })
|
||||||
|
obs = obs[:MaxObservations]
|
||||||
|
}
|
||||||
|
return obs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:n]) + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package memeval
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeLLM — canned replies, one per call, and a record of what it was asked.
|
||||||
|
type fakeLLM struct {
|
||||||
|
replies []string
|
||||||
|
calls []llm.Req
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLLM) Complete(_ context.Context, r llm.Req) (string, error) {
|
||||||
|
f.calls = append(f.calls, r)
|
||||||
|
if f.err != nil {
|
||||||
|
return "", f.err
|
||||||
|
}
|
||||||
|
if len(f.replies) == 0 {
|
||||||
|
return "[]", nil
|
||||||
|
}
|
||||||
|
out := f.replies[0]
|
||||||
|
f.replies = f.replies[1:]
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "memeval_test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func refNow() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
// seedMemory writes a little of everything the evaluator reads.
|
||||||
|
func seedMemory(t *testing.T, st *store.Store, ctx context.Context, now time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
ts := now.Add(-time.Duration(i+1) * 24 * time.Hour)
|
||||||
|
if _, err := st.WriteFact(ctx, ts, store.KindSelf, "water_ml", "500", "tap:desk", 1.0, sql.NullInt64{}); err != nil {
|
||||||
|
t.Fatalf("write fact: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := st.WriteNote(ctx, now.Add(-2*time.Hour), "купить корм для кота", nil, "tap:voice"); err != nil {
|
||||||
|
t.Fatalf("write note: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.RecordNudge(ctx, "water", "voice", "пора выпить воды", now.Add(-time.Hour)); err != nil {
|
||||||
|
t.Fatalf("record nudge: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateEmptyStoreAsksNothing — the "shuts up when uncertain" floor. An
|
||||||
|
// empty store must not even reach the model: a small model asked to find a
|
||||||
|
// pattern in nothing will invent one.
|
||||||
|
func TestEvaluateEmptyStoreAsksNothing(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
f := &fakeLLM{}
|
||||||
|
ev := NewEvaluator(st, st, f, Config{})
|
||||||
|
|
||||||
|
obs, err := ev.Evaluate(ctx, refNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Evaluate: %v", err)
|
||||||
|
}
|
||||||
|
if len(obs) != 0 {
|
||||||
|
t.Fatalf("observations on an empty store = %d, want 0", len(obs))
|
||||||
|
}
|
||||||
|
if len(f.calls) != 0 {
|
||||||
|
t.Fatalf("LLM called %d times on an empty store, want 0", len(f.calls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateWritesHighConfidenceObservations — the happy path. Confident
|
||||||
|
// observations are written as notes stamped infer:memory-eval, and the low
|
||||||
|
// ones are dropped.
|
||||||
|
func TestEvaluateWritesHighConfidenceObservations(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedMemory(t, st, ctx, now)
|
||||||
|
|
||||||
|
f := &fakeLLM{replies: []string{`[
|
||||||
|
{"observation":"ты три дня не записывал еду","confidence":0.9,"suggested_action":"notify"},
|
||||||
|
{"observation":"может быть, ты стал меньше пить воды","confidence":0.3,"suggested_action":"note"}
|
||||||
|
]`}}
|
||||||
|
ev := NewEvaluator(st, st, f, Config{})
|
||||||
|
|
||||||
|
obs, err := ev.Evaluate(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Evaluate: %v", err)
|
||||||
|
}
|
||||||
|
if len(obs) != 1 {
|
||||||
|
t.Fatalf("kept %d observations, want 1 (the 0.3 one is below the floor): %+v", len(obs), obs)
|
||||||
|
}
|
||||||
|
if obs[0].Text != "ты три дня не записывал еду" {
|
||||||
|
t.Errorf("kept the wrong observation: %q", obs[0].Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
notes, err := st.RecentNotes(ctx, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecentNotes: %v", err)
|
||||||
|
}
|
||||||
|
var written []store.Note
|
||||||
|
for _, n := range notes {
|
||||||
|
if n.Source == EvalNoteSource {
|
||||||
|
written = append(written, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(written) != 1 {
|
||||||
|
t.Fatalf("notes with source %s = %d, want 1", EvalNoteSource, len(written))
|
||||||
|
}
|
||||||
|
if !strings.Contains(written[0].Text, "ты три дня не записывал еду") {
|
||||||
|
t.Errorf("note text = %q", written[0].Text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(written[0].Text, "[notify]") {
|
||||||
|
t.Errorf("note text = %q, want the suggested action recorded", written[0].Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The prompt must carry the memory it is evaluating, and must not carry a
|
||||||
|
// grammar-free request.
|
||||||
|
if len(f.calls) != 1 {
|
||||||
|
t.Fatalf("LLM calls = %d, want 1", len(f.calls))
|
||||||
|
}
|
||||||
|
if !strings.Contains(f.calls[0].User, "water_ml") {
|
||||||
|
t.Errorf("prompt does not mention the seeded facts:\n%s", f.calls[0].User)
|
||||||
|
}
|
||||||
|
if f.calls[0].Grammar == "" {
|
||||||
|
t.Error("evaluation ran without a grammar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateDeduplicatesAcrossRuns — the failure mode that would make this
|
||||||
|
// feature unusable: an hourly loop over a store that barely changes writing the
|
||||||
|
// same sentence every hour until /dash is nothing but the evaluator.
|
||||||
|
func TestEvaluateDeduplicatesAcrossRuns(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedMemory(t, st, ctx, now)
|
||||||
|
|
||||||
|
same := `[{"observation":"ты три дня не записывал еду","confidence":0.9,"suggested_action":"note"}]`
|
||||||
|
spaced := `[{"observation":"Ты три дня не записывал еду","confidence":0.95,"suggested_action":"note"}]`
|
||||||
|
f := &fakeLLM{replies: []string{same, same, spaced}}
|
||||||
|
ev := NewEvaluator(st, st, f, Config{})
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, err := ev.Evaluate(ctx, now.Add(time.Duration(i)*time.Hour)); err != nil {
|
||||||
|
t.Fatalf("Evaluate %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notes, err := st.RecentNotes(ctx, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecentNotes: %v", err)
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, nt := range notes {
|
||||||
|
if nt.Source == EvalNoteSource {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("eval notes after three identical evaluations = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateIgnoresOwnNotes — her own observations must not become input.
|
||||||
|
// Otherwise "я заметила X" is evidence for noticing X again, three evaluations
|
||||||
|
// deep. With nothing but eval notes in the store there is no new memory, so the
|
||||||
|
// model is not asked at all.
|
||||||
|
func TestEvaluateIgnoresOwnNotes(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
if _, err := st.WriteNote(ctx, now.Add(-time.Hour), "я заметила, что ты мало пьёшь [note]", nil, EvalNoteSource); err != nil {
|
||||||
|
t.Fatalf("write note: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f := &fakeLLM{}
|
||||||
|
ev := NewEvaluator(st, st, f, Config{})
|
||||||
|
obs, err := ev.Evaluate(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Evaluate: %v", err)
|
||||||
|
}
|
||||||
|
if len(obs) != 0 || len(f.calls) != 0 {
|
||||||
|
t.Fatalf("observations=%d llm calls=%d, want 0/0 — own notes are not memory to evaluate", len(obs), len(f.calls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateEmptyArrayIsNotAnError — "nothing to say" is the expected outcome
|
||||||
|
// most of the time and must not be logged as a failure.
|
||||||
|
func TestEvaluateEmptyArrayIsNotAnError(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedMemory(t, st, ctx, now)
|
||||||
|
|
||||||
|
ev := NewEvaluator(st, st, &fakeLLM{replies: []string{"[]"}}, Config{})
|
||||||
|
obs, err := ev.Evaluate(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Evaluate: %v", err)
|
||||||
|
}
|
||||||
|
if len(obs) != 0 {
|
||||||
|
t.Fatalf("observations = %d, want 0", len(obs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluateLLMErrorIsReported — a broken llama-server is an error the caller
|
||||||
|
// logs; it must not silently write anything.
|
||||||
|
func TestEvaluateLLMErrorIsReported(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedMemory(t, st, ctx, now)
|
||||||
|
|
||||||
|
ev := NewEvaluator(st, st, &fakeLLM{err: errors.New("connection refused")}, Config{})
|
||||||
|
if _, err := ev.Evaluate(ctx, now); err == nil {
|
||||||
|
t.Fatal("want an error when the model is unreachable")
|
||||||
|
}
|
||||||
|
notes, err := st.RecentNotes(ctx, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecentNotes: %v", err)
|
||||||
|
}
|
||||||
|
for _, n := range notes {
|
||||||
|
if n.Source == EvalNoteSource {
|
||||||
|
t.Fatalf("wrote a note despite an LLM failure: %q", n.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseObservationsTolerantAndBounded — Thinking models wrap JSON in prose,
|
||||||
|
// and no reply may exceed MaxObservations even if the grammar is bypassed.
|
||||||
|
func TestParseObservationsTolerantAndBounded(t *testing.T) {
|
||||||
|
obs, err := parseObservations(`<think>hmm</think> вот: [{"observation":"a","confidence":0.9,"suggested_action":"note"}] всё`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(obs) != 1 || obs[0].Text != "a" {
|
||||||
|
t.Fatalf("got %+v, want one observation 'a'", obs)
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("[")
|
||||||
|
for i := 0; i < MaxObservations+3; i++ {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(",")
|
||||||
|
}
|
||||||
|
b.WriteString(`{"observation":"x","confidence":0.5,"suggested_action":"note"}`)
|
||||||
|
}
|
||||||
|
b.WriteString("]")
|
||||||
|
obs, err = parseObservations(b.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(obs) != MaxObservations {
|
||||||
|
t.Fatalf("parsed %d observations, want the %d cap", len(obs), MaxObservations)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,9 +20,19 @@ type ProposedRoutine struct {
|
|||||||
const MaxIntervalRatio = 1.5
|
const MaxIntervalRatio = 1.5
|
||||||
|
|
||||||
// MinEvents is the minimum number of events needed to detect a pattern.
|
// 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
|
// With N events there are N-1 intervals, so 4 events means 3 intervals.
|
||||||
// before proposing anything.
|
//
|
||||||
const MinEvents = 3
|
// 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
|
// Detect checks whether a sequence of events for the same action+object
|
||||||
// forms a stable recurring pattern. Returns a ProposedRoutine when:
|
// forms a stable recurring pattern. Returns a ProposedRoutine when:
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestDetectEnoughEvents(t *testing.T) {
|
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)
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
events := []Event{
|
events := []Event{
|
||||||
{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(7 * 24 * time.Hour)},
|
||||||
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 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)
|
r, err := Detect(events)
|
||||||
@@ -24,8 +25,8 @@ func TestDetectEnoughEvents(t *testing.T) {
|
|||||||
if r.Action != "refill" || r.Object != "cat_water" {
|
if r.Action != "refill" || r.Object != "cat_water" {
|
||||||
t.Fatalf("action/object: want refill/cat_water, got %s/%s", r.Action, r.Object)
|
t.Fatalf("action/object: want refill/cat_water, got %s/%s", r.Action, r.Object)
|
||||||
}
|
}
|
||||||
if r.N != 3 {
|
if r.N != 4 {
|
||||||
t.Fatalf("want N=3, got %d", r.N)
|
t.Fatalf("want N=4, got %d", r.N)
|
||||||
}
|
}
|
||||||
// ~7 days
|
// ~7 days
|
||||||
if r.IntervalDays < 6.9 || r.IntervalDays > 7.1 {
|
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) {
|
func TestDetectNotEnoughEvents(t *testing.T) {
|
||||||
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
events := []Event{
|
for _, n := range []int{1, 2, MinEvents - 1} {
|
||||||
{Action: "refill", Object: "cat_water", Ts: base},
|
events := make([]Event, n)
|
||||||
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
|
for i := range events {
|
||||||
}
|
events[i] = Event{
|
||||||
|
Action: "refill",
|
||||||
r, err := Detect(events)
|
Object: "cat_water",
|
||||||
if err != nil {
|
Ts: base.Add(time.Duration(i) * 7 * 24 * time.Hour),
|
||||||
t.Fatalf("Detect: %v", err)
|
}
|
||||||
}
|
}
|
||||||
if r != nil {
|
r, err := Detect(events)
|
||||||
t.Fatal("want nil for <3 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) {
|
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)
|
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
events := []Event{
|
events := []Event{
|
||||||
{Action: "refill", Object: "cat_water", Ts: base},
|
{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(1 * 24 * time.Hour)},
|
||||||
{Action: "refill", Object: "cat_water", Ts: base.Add(15 * 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)
|
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},
|
{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(7 * 24 * time.Hour)},
|
||||||
|
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 24 * time.Hour)},
|
||||||
}
|
}
|
||||||
|
|
||||||
r, err := Detect(events)
|
r, err := Detect(events)
|
||||||
|
|||||||
Reference in New Issue
Block a user