Files
Maven/internal/memeval/eval.go
T
kami 88c841cb0e memeval: scope the evaluator's note windows by source
Both windows the evaluator keeps over the notes table were row budgets over
every writer. The dedupe read 200 recent notes and kept the eval ones, so after
200 ordinary notes an old observation left the window and the next evaluation
wrote the same sentence again. The snapshot asked for MaxItems notes and then
discarded her own, so once hourly evaluation had run for a few weeks the model
saw almost no real notes. Both reads are now filtered in SQL, by
RecentNotesBySource and RecentNotesExcludingSource.

Two smaller things in the same area. The dedupe key stripped any trailing
bracketed clause, so an observation ending in one hashed differently from its
stored form; it now strips only the recorded action. The evaluation timeout was
five minutes on the one llama-server that also answers voice turns, which made
a collision a five-minute mute assistant, and is now sixty seconds.

Found in review of #55.
2026-08-01 13:57:26 +04:00

373 lines
15 KiB
Go

// 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)
// RecentNotesExcludingSource feeds the snapshot, RecentNotesBySource feeds
// the dedupe. Both are source-scoped in SQL rather than filtered here: a
// plain RecentNotes read makes each window a budget over ALL note writers,
// so her own hourly observations shrink the snapshot and ordinary notes
// push old observations out of the dedupe set.
RecentNotesExcludingSource(ctx context.Context, source string, n int) ([]store.Note, error)
RecentNotesBySource(ctx context.Context, source string, 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)
}
// DedupeWindow — how many of her OWN past observations the dedupe looks back
// over. Wider than MaxItems because the point is to remember saying it, not to
// summarize it. Counted in eval notes only: when this was a plain recent-notes
// read the window was really a budget over every note writer, so a few hundred
// ordinary notes pushed an observation out of sight and the next evaluation was
// free to write the same sentence again.
const DedupeWindow = 200
// recordedTexts — the normalized text of every observation earlier evaluations
// wrote, for dedupe.
func (e *Evaluator) recordedTexts(ctx context.Context) (map[string]bool, error) {
notes, err := e.read.RecentNotesBySource(ctx, EvalNoteSource, DedupeWindow)
if err != nil {
return nil, fmt.Errorf("memory eval: recent notes: %w", err)
}
seen := make(map[string]bool, len(notes))
for _, n := range notes {
seen[normalizeObservation(stripAction(n.Text))] = true
}
return seen, nil
}
// actions — the suggested_action enum, as the grammar constrains it.
var actions = []string{"note", "propose", "notify"}
// stripAction removes the "[action]" suffix formatNote appended, and only that.
// Matching any bracketed tail would eat the end of an observation that happens
// to finish on a bracketed clause, which changes its dedupe key and lets the
// same sentence through twice.
func stripAction(text string) string {
for _, a := range actions {
if s, ok := strings.CutSuffix(text, " ["+a+"]"); ok {
return s
}
}
return text
}
// 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, in SQL. Feeding her own
// observations back in is how "я заметила, что ты не записывал еду" becomes
// evidence for noticing it again, three evaluations deep. Excluding them after
// the read was worse than not excluding them: hourly evaluation makes her own
// notes the majority of the newest rows within weeks, so asking for MaxItems
// and dropping hers left the model a handful of real notes to look at.
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.RecentNotesExcludingSource(ctx, EvalNoteSource, 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
}
}
if len(notes) > 0 {
b.WriteString("\nЗаметки:\n")
for _, nt := range notes {
fmt.Fprintf(&b, "- %s %s\n", nt.Ts.Format("2006-01-02 15:04"), truncate(nt.Text, 160))
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]) + "…"
}