dc7c72a3d7
Ships the real, local, testable part of the memory-evaluation plan
(docs/plans/03-memory-evaluation.md): Maven reads back her own recent
memory on a slow ticker, asks the resident model what it notices, and
records the confident answers as notes.
internal/memeval — not internal/memory/eval.go as the plan says, because
internal/store imports internal/memory for the vector backend and an
evaluator has to read store.Fact/Note/Nudge, which would close the
cycle. Evaluate() gathers RecentFacts/RecentNotes/RecentNudges, prompts
under a GBNF grammar bounded to three {observation, confidence,
suggested_action} objects, drops anything under min_confidence,
deduplicates against what earlier runs wrote, and writes the rest as
notes with source infer:memory-eval. /dash already renders notes with
their source, so the output is visible with no UI change.
cmd/mavend/memoryeval.go drives it on its own goroutine and ticker, not
on the 60s tick: an evaluation is a multi-second round-trip on the same
llama-server that answers voice turns, and it runs hourly at most. The
memory_eval config block is absent by default and absence means the
goroutine does not exist. No llama-server phraser also means no loop —
there is no template fallback, because a "memory evaluation" assembled
from templates is a fixed sentence pretending to be an observation.
What it deliberately cannot do, since this is the feature most likely to
turn Maven into a nag:
- It cannot speak. No dispatcher reference, no channel, no nudge. An
observation is a thought she wrote down and he reads on /dash.
Announcing them is a separate decision with its own opt-in.
- It cannot act. suggested_action is recorded as text and interpreted
by nobody — no reminder, routine or fact is created from it.
- It says nothing about an empty store: no memory means no LLM call,
so there are no observations invented out of two facts.
- Its own notes are excluded from the next evaluation's input, and are
written with a nil embedding so they stay out of the recall pool.
The plan's remaining items (dispatching observations, an /eval IPC
method and trace view, RecentEvents) and the fact that output quality is
entirely unmeasured are written up at the bottom of the plan doc.
296 lines
8.8 KiB
Go
296 lines
8.8 KiB
Go
package config
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func writeConfig(t *testing.T, body string) string {
|
|
t.Helper()
|
|
p := filepath.Join(t.TempDir(), "mavend.json")
|
|
if err := writeFile(t, p, body); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func TestLoadDefaults(t *testing.T) {
|
|
p := writeConfig(t, `{}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if time.Duration(c.TickInterval) != DefaultTickInterval {
|
|
t.Errorf("TickInterval default = %v, want %v", c.TickInterval, DefaultTickInterval)
|
|
}
|
|
if time.Duration(c.RepeatInterval) != DefaultRepeatInterval {
|
|
t.Errorf("RepeatInterval default = %v, want %v", c.RepeatInterval, DefaultRepeatInterval)
|
|
}
|
|
if c.DBPath == "" {
|
|
t.Error("DBPath default not applied")
|
|
}
|
|
if c.SocketPath == "" {
|
|
t.Error("SocketPath default not applied")
|
|
}
|
|
}
|
|
|
|
// Nudges come from templates unless the config says otherwise.
|
|
func TestPhraserLLMNudgesDefaultsOff(t *testing.T) {
|
|
p := writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf"}}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.Phraser.LLMNudges {
|
|
t.Error("llm_nudges defaults on; templates must be the default")
|
|
}
|
|
|
|
p = writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf","llm_nudges":true}}`)
|
|
c, err = Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if !c.Phraser.LLMNudges {
|
|
t.Error("llm_nudges:true did not parse")
|
|
}
|
|
}
|
|
|
|
func TestLoadDurationsParse(t *testing.T) {
|
|
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if time.Duration(c.TickInterval) != 90*time.Second {
|
|
t.Errorf("TickInterval = %v, want 90s", c.TickInterval)
|
|
}
|
|
if time.Duration(c.RepeatInterval) != 10*time.Minute {
|
|
t.Errorf("RepeatInterval = %v, want 10m", c.RepeatInterval)
|
|
}
|
|
}
|
|
|
|
func TestLoadBadDurationRejected(t *testing.T) {
|
|
p := writeConfig(t, `{"tick_interval":"not-a-duration"}`)
|
|
if _, err := Load(p); err == nil {
|
|
t.Fatal("Load succeeded for a bad duration; want error")
|
|
}
|
|
}
|
|
|
|
func TestLoadMissingFile(t *testing.T) {
|
|
p := filepath.Join(t.TempDir(), "nonexistent.json")
|
|
if _, err := Load(p); err == nil {
|
|
t.Fatal("Load succeeded for a missing file; want error")
|
|
}
|
|
}
|
|
|
|
func TestVoiceEnabledRequiresBind(t *testing.T) {
|
|
// enabled=true without bind is refused — the voice surface can't
|
|
// default a bind (127.0.0.1 too relaxed for production, a wg addr is
|
|
// the user's). surfacing the gap explicitly beats an idle listener.
|
|
p := writeConfig(t, `{"voice":{"enabled":true}}`)
|
|
if _, err := Load(p); err == nil {
|
|
t.Fatal("Load succeeded for voice.enabled=true with no bind; want error")
|
|
}
|
|
}
|
|
|
|
func TestVoiceEnabledWithBindOK(t *testing.T) {
|
|
// voice surface fully configured — accepted (the daemon wires Stub tts +
|
|
// voicesink; no models on disk required).
|
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
|
|
if _, err := Load(p); err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEmbedderAllPathsSetOK(t *testing.T) {
|
|
p := writeConfig(t, `{
|
|
"voice": {
|
|
"enabled": true, "bind": "127.0.0.1:9100",
|
|
"embedder": {
|
|
"model_path": "m.onnx",
|
|
"tokenizer_path": "t.json",
|
|
"lib_path": "l.so"
|
|
}
|
|
}
|
|
}`)
|
|
if _, err := Load(p); err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEmbedderPartialConfigRejected(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
json string
|
|
}{
|
|
{"missing model_path", `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{"tokenizer_path":"t.json","lib_path":"l.so"}}}`},
|
|
{"missing tokenizer_path", `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{"model_path":"m.onnx","lib_path":"l.so"}}}`},
|
|
{"missing lib_path", `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{"model_path":"m.onnx","tokenizer_path":"t.json"}}}`},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p := writeConfig(t, tt.json)
|
|
if _, err := Load(p); err == nil {
|
|
t.Fatal("Load succeeded for partial embedder; want error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEmbedderNilOK(t *testing.T) {
|
|
// voice block without embedder → ok (HashEmbedder floor)
|
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
|
|
if _, err := Load(p); err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVoicePresentDisabledOK(t *testing.T) {
|
|
// voice block present but not enabled — acceptable (the listener stays
|
|
// down; the routing table's ChannelVoice selections drop).
|
|
p := writeConfig(t, `{"voice":{"enabled":false}}`)
|
|
if _, err := Load(p); err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVoiceSttTtsConfigParsed(t *testing.T) {
|
|
// the stt/tts worker sub-blocks parse + remember socket/lang.
|
|
p := writeConfig(t, `{
|
|
"voice": {
|
|
"enabled": true, "bind": "127.0.0.1:9100",
|
|
"stt": {"socket": "/tmp/stt.sock", "lang": "ru"},
|
|
"tts": {"socket": "/tmp/tts.sock", "lang": "ru", "voice": "natasha"}
|
|
}
|
|
}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.Voice.Stt == nil || c.Voice.Stt.Socket != "/tmp/stt.sock" {
|
|
t.Fatalf("Stt config not parsed: %+v", c.Voice)
|
|
}
|
|
if c.Voice.Tts == nil || c.Voice.Tts.Socket != "/tmp/tts.sock" || c.Voice.Tts.Voice != "natasha" {
|
|
t.Fatalf("Tts config not parsed: %+v", c.Voice)
|
|
}
|
|
}
|
|
|
|
func TestWeatherConfig(t *testing.T) {
|
|
// Weather block with provider + default location → OK
|
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","weather":{"provider":"open-meteo","default_location":"Moscow"}}}`)
|
|
if _, err := Load(p); err != nil {
|
|
t.Fatalf("Load with weather config: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWeatherConfigNilOK(t *testing.T) {
|
|
// No weather block → OK (stub)
|
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
|
|
if _, err := Load(p); err != nil {
|
|
t.Fatalf("Load without weather config: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLLMRouterDefaultsOn(t *testing.T) {
|
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if !c.Voice.UseLLMRouter() {
|
|
t.Error("voice.llm_router absent should mean on")
|
|
}
|
|
}
|
|
|
|
// Missing and explicitly false must not mean the same thing.
|
|
func TestLLMRouterExplicitFalseTurnsItOff(t *testing.T) {
|
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":false}}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.Voice.UseLLMRouter() {
|
|
t.Error("voice.llm_router false should turn it off")
|
|
}
|
|
}
|
|
|
|
func TestLLMRouterRead(t *testing.T) {
|
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":true}}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if !c.Voice.UseLLMRouter() {
|
|
t.Error("voice.llm_router true was not read")
|
|
}
|
|
}
|
|
|
|
func TestDurationRoundTrip(t *testing.T) {
|
|
d := Duration(15 * time.Minute)
|
|
b, err := d.MarshalJSON()
|
|
if err != nil {
|
|
t.Fatalf("MarshalJSON: %v", err)
|
|
}
|
|
if got, want := string(b), `"15m0s"`; got != want {
|
|
t.Errorf("MarshalJSON = %s, want %s", got, want)
|
|
}
|
|
var d2 Duration
|
|
if err := d2.UnmarshalJSON(b); err != nil {
|
|
t.Fatalf("UnmarshalJSON: %v", err)
|
|
}
|
|
if 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)
|
|
}
|
|
}
|