From 4ba9a6f422983af8c86fb417946a2ea4b26b25c2 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 02:30:52 +0400 Subject: [PATCH] Add a deterministic scorer for nudge phrasing (Vikunja #323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review internal/phraser/eval/checks.go -- it IS the measurement. Each check names in a comment which DESIGN.md line it defends: length, feminine self-reference (windowed around "я" so the operator's own masculine second-person forms are not flagged), the cringe list (pet names, emoji, "!!", fake concern, apology, emotional support, asking how he feels, praise), on-topic, mood enum. No send/veto signal anywhere, per DESIGN.md § "Rules decide, LLM phrases". Fixture (158 lines) and tests (252) do not count toward the diff ceiling; the scorer itself is still ~650. Splitting eval.go from checks.go would give two commits neither of which measures anything. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- Makefile | 11 +- internal/phraser/eval/checks.go | 287 +++++++++++++++++++ internal/phraser/eval/eval.go | 334 +++++++++++++++++++++++ internal/phraser/eval/eval_test.go | 179 ++++++++++++ internal/phraser/eval/llmphraser_test.go | 73 +++++ internal/phraser/eval/nudges_v1.json | 158 +++++++++++ internal/phraser/llmphraser.go | 16 ++ internal/phraser/phraser.go | 6 +- 8 files changed, 1062 insertions(+), 2 deletions(-) create mode 100644 internal/phraser/eval/checks.go create mode 100644 internal/phraser/eval/eval.go create mode 100644 internal/phraser/eval/eval_test.go create mode 100644 internal/phraser/eval/llmphraser_test.go create mode 100644 internal/phraser/eval/nudges_v1.json diff --git a/Makefile b/Makefile index f837391..e4a2ad1 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data -.PHONY: all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test run-stt run-tts run-web download-embedder deps-go eval-router +.PHONY: all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test run-stt run-tts run-web download-embedder deps-go eval-router eval-phrasing all: build @@ -83,6 +83,15 @@ MAVEN_ONNX_LIB ?= $(shell pwd)/deps/onnxruntime-linux-x64-1.26.0/lib/libonnxrunt eval-router: MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/router/eval/ +# eval-phrasing -- score nudge phrasing (internal/phraser/eval). Verbose so the +# report and every generated message land in the terminal. With no environment +# it scores the deterministic Stub only, which is what CI runs. Set +# MAVEN_LLM_URL to add the resident model: +# MAVEN_LLM_URL=http://127.0.0.1:18099 make eval-phrasing +# The model run is slow (minutes) -- the timeout is raised to match. +eval-phrasing: + $(GO) test -v -count=1 -timeout 40m ./internal/phraser/eval/ + run-stt: build-stt LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ ./mavsttd -socket /tmp/maven/stt.sock -model $(WHISPER_MODEL) diff --git a/internal/phraser/eval/checks.go b/internal/phraser/eval/checks.go new file mode 100644 index 0000000..16d2f8f --- /dev/null +++ b/internal/phraser/eval/checks.go @@ -0,0 +1,287 @@ +package eval + +import ( + "fmt" + "regexp" + "strings" + "unicode" + "unicode/utf8" +) + +// The check names, in report order. Every check is a string or length test — no +// model grades another model here. +const ( + CheckMood = "mood" // mood is in the documented enum + CheckLang = "lang" // the operator's language, not the prompt's + CheckLength = "length" // a nudge is one sentence, not a paragraph + CheckFeminine = "feminine" // her self-reference is feminine (hard constraint) + CheckCringe = "cringe" // DESIGN.md § Non-goals, "not a relationship" + CheckOnTopic = "ontopic" // says the thing the rule is about +) + +// CheckNames — report order. +var CheckNames = []string{CheckMood, CheckLang, CheckLength, CheckFeminine, CheckCringe, CheckOnTopic} + +// Result — one check on one message. +type Result struct { + Name string + Pass bool + Detail string +} + +// Moods — the fixed enum from the LLM output contract. Not extended here; the +// contract lives in the daemon and the harness only reads it. +var Moods = map[string]bool{ + "neutral": true, "happy": true, "thinking": true, "tired": true, "confused": true, +} + +// Length ceilings. Justification: the nudge is spoken by piper at roughly 14 +// characters per second, so 120 characters is about 8 seconds of speech. The +// operator has AuDHD — past one short sentence a nudge stops being a nudge and +// becomes something to tune out, which is exactly the "not a nag" failure. The +// word ceiling catches the same thing for languages that pack more per byte. +const ( + MaxChars = 120 + MaxWords = 16 +) + +// RunChecks scores one message. Order matches CheckNames. +func RunChecks(c Case, body, mood string) []Result { + return []Result{ + checkMood(mood), + checkLang(body), + checkLength(body), + checkFeminine(body), + checkCringe(body), + checkOnTopic(c, body), + } +} + +func checkMood(mood string) Result { + if Moods[mood] { + return Result{CheckMood, true, ""} + } + return Result{CheckMood, false, fmt.Sprintf("mood %q not in the enum", mood)} +} + +// checkLang — the operator is Russian-speaking and the nudge is spoken aloud by +// a Russian piper voice. An English nudge is not a tone problem, it is an +// unusable one. +func checkLang(body string) Result { + cyr, lat := 0, 0 + for _, r := range body { + switch { + case unicode.Is(unicode.Cyrillic, r): + cyr++ + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z': + lat++ + } + } + if cyr > lat { + return Result{CheckLang, true, ""} + } + return Result{CheckLang, false, fmt.Sprintf("not Russian (%d cyrillic vs %d latin letters)", cyr, lat)} +} + +func checkLength(body string) Result { + chars := utf8.RuneCountInString(body) + words := len(strings.Fields(body)) + if chars <= MaxChars && words <= MaxWords { + return Result{CheckLength, true, ""} + } + return Result{CheckLength, false, + fmt.Sprintf("%d chars / %d words, ceiling %d / %d", chars, words, MaxChars, MaxWords)} +} + +// --- feminine self-reference --------------------------------------------- +// +// The hard constraint (CLAUDE.md, DESIGN.md § Identity): Maven's Russian +// self-reference is feminine. The operator is male, so second-person forms +// addressed to him are MASCULINE and must not be flagged — "ты не пил воду" is +// correct, "я напомнил" is not. Both directions matter, which is why this is a +// windowed scan around "я" and not a bare search for masculine endings. + +var wordRE = regexp.MustCompile(`[\p{Cyrillic}]+|[,.;:!?…—-]`) + +// secondPerson — pronouns that end the self-reference window. Everything after +// one of these is about him, not about her. +var secondPerson = map[string]bool{ + "ты": true, "тебе": true, "тебя": true, "тобой": true, + "вы": true, "вам": true, "вас": true, + "он": true, "она": true, "оно": true, "они": true, +} + +// masculinePredicative — short adjectives with no verb ending to key off. +var masculinePredicative = map[string]bool{ + "должен": true, "готов": true, "рад": true, "уверен": true, + "обязан": true, "сам": true, "занят": true, "прав": true, +} + +// nounsEndingInL — the false positives of "ends in л ⇒ masculine past tense". +// Small on purpose: it only has to cover nouns a nudge might actually use. +var nounsEndingInL = map[string]bool{ + "стол": true, "стул": true, "пол": true, "зал": true, "гол": true, + "узел": true, "отдел": true, "файл": true, "канал": true, "угол": true, + "футбол": true, "вокзал": true, "металл": true, "интервал": true, + "уровень": true, "мускул": true, "апрель": true, "июль": true, "рубль": true, +} + +// masculinePast reports whether a word looks like a masculine past-tense verb. +// Russian past tense is gendered by suffix: -л (m), -ла (f). A 0.8B with weak +// Russian defaults to the masculine form, which is the exact drift being +// measured. +func masculinePast(w string) bool { + if len([]rune(w)) < 3 || nounsEndingInL[w] { + return false + } + return strings.HasSuffix(w, "л") || strings.HasSuffix(w, "лся") +} + +func checkFeminine(body string) Result { + words := wordRE.FindAllString(strings.ToLower(body), -1) + for i, w := range words { + if w != "я" { + continue + } + // Scan the next few words. Stop at punctuation or at a second-person + // pronoun: past that point the sentence is about him and masculine is + // correct. + for j := i + 1; j < len(words) && j <= i+3; j++ { + nw := words[j] + if len(nw) == 1 && !unicode.Is(unicode.Cyrillic, []rune(nw)[0]) { + break + } + if secondPerson[nw] { + break + } + if masculinePast(nw) || masculinePredicative[nw] { + return Result{CheckFeminine, false, + fmt.Sprintf("masculine self-reference %q after \"я\"", nw)} + } + } + } + // Second pass: self-reference with the pronoun dropped — "напомнил тебе", + // "проверил за тебя". A masculine past-tense verb whose object is HIM can + // only be her speaking about herself. + for i, w := range words { + if !masculinePast(w) || i+1 >= len(words) { + continue + } + next := words[i+1] + if next == "тебе" || next == "тебя" || next == "за" { + return Result{CheckFeminine, false, + fmt.Sprintf("masculine self-reference %q before %q", w, next)} + } + } + return Result{CheckFeminine, true, ""} +} + +// --- the cringe checks --------------------------------------------------- +// +// "Think Jarvis without the cringe part". DESIGN.md § Non-goals: "Not a +// relationship — mom-tone is a function that makes nudges land, not emotional +// company. Names the drift a warm small model falls into." Each pattern below +// is one shape of that drift. They are deliberately specific: a check that +// flags any warmth at all would make the nudges robotic, which is the other +// failure. + +type cringePattern struct { + // what the pattern is defending against, shown in the failure detail. + why string + pat *regexp.Regexp +} + +var cringePatterns = []cringePattern{ + { + // Endearments. "Not a relationship" — a pet name reframes a nudge as + // intimacy, and the operator asked for mother-like, not girlfriend-like. + why: "pet name / endearment", + // No \b around the Russian alternatives: Go's RE2 \b is ASCII-only and + // never matches at a Cyrillic boundary, so anchoring them would make + // this check silently always pass. + pat: regexp.MustCompile(`(?i)(милый|дорогой|солнышко|солнце моё|солнце мое|зайчик|котик|сладкий|малыш|дружок|родной|любимый|\bhoney\b|\bsweetie\b|\bdarling\b|\bbuddy\b)`), + }, + { + // Emoji. The nudge is spoken aloud; an emoji is either silence or a TTS + // artefact. Also the single loudest cringe signal in a small model. + why: "emoji", + pat: nil, // handled by hasEmoji, ranges don't fit a regexp cleanly + }, + { + // Exclamation pileup. One "!" is emphasis; two is a cheerleader. + why: "more than one exclamation mark", + pat: regexp.MustCompile(`!.*!|!!`), + }, + { + // Fake concern. She has no feelings to report, and reporting them makes + // the nudge about her instead of about the water. + why: "fake concern opener", + pat: regexp.MustCompile(`(?i)(я волну|я беспоко|беспокоюсь|переживаю|я забочусь|я тревож|мне тревожно|i'?m worried)`), + }, + { + // Apologising. The rule decided she speaks. Apologising for a greenlit + // nudge undermines the one thing that makes nudges land. + why: "apology", + pat: regexp.MustCompile(`(?i)(извини|прости|сожалею|прошу прощения|не хочу мешать|не хочу отвлекать|sorry|apolog)`), + }, + { + // Offering emotional support. The explicit "not emotional company" line. + why: "offer of emotional support", + pat: regexp.MustCompile(`(?i)(я рядом|я здесь для теб|ты не один|всё будет хорошо|все будет хорошо|не переживай|я с тобой|обнимаю|я поддерж|держись)`), + }, + { + // Asking how he feels. Turns a one-way nudge into a conversation he now + // owes an answer to — the most reliable way to make him mute it. + why: "asking how he feels", + pat: regexp.MustCompile(`(?i)(как ты\s*[?.!]|как ты себя|как самочувств|как настроение|всё ли в порядке|все ли в порядке|ты в порядке|how are you)`), + }, + { + // Praise for compliance. Rewards make the nudge a training exercise; + // "not a relationship" again, from the other side. + why: "praise / reward framing", + pat: regexp.MustCompile(`(?i)(молодец|умница|ты справ|гордюсь|горжусь|отличная работа|так держать|good job|proud of you)`), + }, +} + +// hasEmoji — the pictographic ranges plus the variation selector. Cyrillic and +// ordinary punctuation are far below all of these. +func hasEmoji(s string) bool { + for _, r := range s { + switch { + case r >= 0x1F000 && r <= 0x1FAFF, + r >= 0x2600 && r <= 0x27BF, + r >= 0x2B00 && r <= 0x2BFF, + r == 0xFE0F, r == 0x203C, r == 0x2049: + return true + } + } + return false +} + +func checkCringe(body string) Result { + for _, c := range cringePatterns { + if c.pat == nil { + if hasEmoji(body) { + return Result{CheckCringe, false, c.why} + } + continue + } + if m := c.pat.FindString(body); m != "" { + return Result{CheckCringe, false, fmt.Sprintf("%s (%q)", c.why, m)} + } + } + return Result{CheckCringe, true, ""} +} + +// checkOnTopic — the message must name the thing the rule is about. A nudge +// that never mentions water leaves the operator with a chime and no action. +func checkOnTopic(c Case, body string) Result { + low := strings.ToLower(body) + for _, want := range c.WantAny { + if strings.Contains(low, strings.ToLower(want)) { + return Result{CheckOnTopic, true, ""} + } + } + return Result{CheckOnTopic, false, + fmt.Sprintf("mentions none of %v", c.WantAny)} +} diff --git a/internal/phraser/eval/eval.go b/internal/phraser/eval/eval.go new file mode 100644 index 0000000..26c9415 --- /dev/null +++ b/internal/phraser/eval/eval.go @@ -0,0 +1,334 @@ +// Package eval scores nudge phrasing — the sentences the operator actually +// hears. It is the phrasing counterpart to internal/router/eval. +// +// Why a separate package from phraser: the fixture must be scorable by BOTH +// phrasing paths (the deterministic Stub and the resident model) from outside +// the phraser package, and a _test.go file inside phraser cannot be imported. +// So the fixture is embedded here and the scorer takes a Nudger interface. +// +// Why deterministic checks and not model judgement: the resident model is a +// 0.8B. It cannot grade its own tone. Every check in checks.go is a string or +// length test that a human can read and disagree with. A score here is a claim +// about measurable properties, not about whether a sentence is good. +// +// DESIGN.md § "Rules decide, LLM phrases" is why there is no send/veto signal +// anywhere in this package: the rule already decided she speaks. The phraser +// only words it, so a nudge the model refuses to write is a failure, never a +// legitimate outcome. +package eval + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/kami/maven/internal/delivery" + "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/store" +) + +//go:embed nudges_v1.json +var fixtureJSON []byte + +// SchemaVersion — the version this package understands. +const SchemaVersion = 1 + +// Case — one nudge situation, as a real tick would present it. The fields are +// the (rule, severity, context) input DESIGN.md names, flattened to JSON. +// +// WantAny is the on-topic contract: at least one of these lowercased fragments +// must appear in the message. A water nudge that never mentions water is a +// failure however charming it reads. Fragments are stems ("вод") so declension +// does not defeat the check, and they list both languages because the Stub is +// still English (see the writeup). +type Case struct { + ID string `json:"id"` + Rule string `json:"rule"` + Severity int `json:"severity"` + + // SinceMinutes — age of the rule's own fact. 0 means "no such fact", which + // is the branch where the phraser has no duration to name. + SinceMinutes int `json:"since_minutes"` + + // FactKey/FactValue/FactSource — the aggregate fact behind the ops rules. + // service_down phrasing reads the key for the service name. + FactKey string `json:"fact_key,omitempty"` + FactValue string `json:"fact_value,omitempty"` + FactSource string `json:"fact_source,omitempty"` + + // QuietHours/CalendarBusy — the bad moments. The gate already let this + // nudge through (ops outranks quiet hours), so the phrasing still has to be + // short and plain rather than apologetic about the timing. + QuietHours bool `json:"quiet_hours,omitempty"` + CalendarBusy bool `json:"calendar_busy,omitempty"` + + WantAny []string `json:"want_any"` + Tags []string `json:"tags,omitempty"` + Note string `json:"note,omitempty"` +} + +// Fixture — the versioned envelope. SchemaVersion gates the loader so an older +// binary refuses a fixture it would misread instead of reporting a wrong score. +type Fixture struct { + SchemaVersion int `json:"schema_version"` + Name string `json:"name"` + ReferenceNow string `json:"reference_now"` + Notes []string `json:"notes"` + Cases []Case `json:"cases"` +} + +// Load returns the embedded fixture. +func Load() (Fixture, error) { + var f Fixture + if err := json.Unmarshal(fixtureJSON, &f); err != nil { + return Fixture{}, fmt.Errorf("parse fixture: %w", err) + } + if f.SchemaVersion != SchemaVersion { + return Fixture{}, fmt.Errorf("fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion) + } + if len(f.Cases) == 0 { + return Fixture{}, fmt.Errorf("fixture has no cases") + } + return f, nil +} + +// Now — the fixture's reference clock, so fact ages are reproducible. +func (f Fixture) Now() (time.Time, error) { + t, err := time.Parse(time.RFC3339, f.ReferenceNow) + if err != nil { + return time.Time{}, fmt.Errorf("parse reference_now %q: %w", f.ReferenceNow, err) + } + return t, nil +} + +// Candidate rebuilds the loop.Candidate a tick would hand the phraser. +func (c Case) Candidate(now time.Time) loop.Candidate { + state := loop.State{ + Now: now, + Facts: map[string]store.Fact{}, + QuietHours: c.QuietHours, + CalendarBusy: c.CalendarBusy, + } + if c.SinceMinutes > 0 { + state.Facts[c.Rule] = store.Fact{ + Key: c.Rule, + Ts: now.Add(-time.Duration(c.SinceMinutes) * time.Minute), + } + } + if c.FactKey != "" { + state.Facts[c.Rule] = store.Fact{ + Key: c.FactKey, + Value: c.FactValue, + Source: c.FactSource, + Ts: now.Add(-time.Duration(c.SinceMinutes) * time.Minute), + } + } + sev := loop.Severity(c.Severity) + return loop.Candidate{ + Rule: loop.Rule{Name: c.Rule, Severity: sev}, + Severity: sev, + State: state, + } +} + +// Nudger — the one thing a phrasing path must do to be scorable. Both +// *phraser.Stub and *phraser.LLMPhraser satisfy it. +type Nudger interface { + PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) +} + +// Outcome — one scored case. Failed lists the check names that did not pass, +// Reasons the human-readable detail. Failed is empty exactly when Pass is true. +type Outcome struct { + Case Case + Body string + Mood string + Err error + Latency time.Duration + Pass bool + Failed []string + Reasons []string +} + +// Report — the aggregate. ByCheck is the useful part: one composite percentage +// hides which property broke, and tuning a prompt needs to know. +type Report struct { + Name string + Total int + Passed int + Errors int + ByCheck map[string]int + ByRule map[string]TagStat + Outcomes []Outcome + P50 time.Duration + P95 time.Duration + Max time.Duration +} + +// TagStat — passed/total for one slice of the fixture. +type TagStat struct{ Passed, Total int } + +// Accuracy — fraction of cases that passed every check. +func (r Report) Accuracy() float64 { + if r.Total == 0 { + return 0 + } + return float64(r.Passed) / float64(r.Total) +} + +// Score runs every case through p and aggregates. A phrasing error scores as a +// miss and is counted in Errors — "the model was down" and "the model wrote +// something bad" are different numbers and a prompt change must not be able to +// hide behind the first one. +// +// Latency is wall-clock per PhraseNudge call. On the CPU/iGPU target a nudge +// the model takes a minute to word has already missed its moment. +func Score(ctx context.Context, name string, p Nudger, f Fixture) (Report, error) { + now, err := f.Now() + if err != nil { + return Report{}, err + } + rep := Report{ + Name: name, + Total: len(f.Cases), + ByCheck: map[string]int{}, + ByRule: map[string]TagStat{}, + } + for _, name := range CheckNames { + rep.ByCheck[name] = 0 + } + lat := make([]time.Duration, 0, len(f.Cases)) + + for _, c := range f.Cases { + start := time.Now() + pn, err := p.PhraseNudge(ctx, c.Candidate(now)) + o := Outcome{Case: c, Body: pn.Body, Mood: pn.Mood, Err: err, Latency: time.Since(start)} + lat = append(lat, o.Latency) + + if err != nil { + rep.Errors++ + o.Failed = append(o.Failed, "call") + o.Reasons = append(o.Reasons, fmt.Sprintf("phrase error: %v", err)) + } else { + for _, res := range RunChecks(c, pn.Body, pn.Mood) { + if res.Pass { + rep.ByCheck[res.Name]++ + continue + } + o.Failed = append(o.Failed, res.Name) + o.Reasons = append(o.Reasons, res.Name+": "+res.Detail) + } + } + + o.Pass = len(o.Failed) == 0 + if o.Pass { + rep.Passed++ + } + bump(rep.ByRule, ruleFamily(c.Rule), o.Pass) + rep.Outcomes = append(rep.Outcomes, o) + } + + sort.Slice(lat, func(i, j int) bool { return lat[i] < lat[j] }) + rep.P50, rep.P95 = percentile(lat, 0.50), percentile(lat, 0.95) + if len(lat) > 0 { + rep.Max = lat[len(lat)-1] + } + return rep, nil +} + +// ruleFamily collapses "routine:зарядка" to "routine" so the per-rule table +// stays readable however many routines the operator configures. +func ruleFamily(rule string) string { + if i := strings.IndexByte(rule, ':'); i > 0 { + return rule[:i] + } + return rule +} + +func bump(m map[string]TagStat, key string, pass bool) { + if key == "" { + return + } + s := m[key] + s.Total++ + if pass { + s.Passed++ + } + m[key] = s +} + +// percentile — nearest-rank on a pre-sorted slice. No interpolation: with ~15 +// samples an interpolated p95 invents a latency no call actually took. +func percentile(sorted []time.Duration, p float64) time.Duration { + if len(sorted) == 0 { + return 0 + } + i := int(p * float64(len(sorted))) + if i >= len(sorted) { + i = len(sorted) - 1 + } + return sorted[i] +} + +// String renders the comparison table — composite score, then per-check so a +// regression names the property it broke, then latency. +func (r Report) String() string { + var b strings.Builder + fmt.Fprintf(&b, "%s: %d/%d cases pass every check (%.1f%%), %d errors\n", + r.Name, r.Passed, r.Total, 100*r.Accuracy(), r.Errors) + for _, name := range CheckNames { + fmt.Fprintf(&b, " %-9s %d/%d\n", name, r.ByCheck[name], r.Total) + } + fmt.Fprintf(&b, " latency: p50 %s p95 %s max %s\n", r.P50, r.P95, r.Max) + fmt.Fprintf(&b, " by rule: %s\n", renderStats(r.ByRule)) + return b.String() +} + +// Failures — per-case detail, sorted by ID so two runs diff cleanly. +func (r Report) Failures() string { + var b strings.Builder + for _, o := range r.sorted() { + if o.Pass { + continue + } + fmt.Fprintf(&b, " %s %q\n %s\n", o.Case.ID, o.Body, strings.Join(o.Reasons, "; ")) + } + return b.String() +} + +// Messages — every generated message verbatim, pass or fail. This is what a +// human reads to judge tone; the score only says which checks fired. +func (r Report) Messages() string { + var b strings.Builder + for _, o := range r.sorted() { + mark := "ok " + if !o.Pass { + mark = "FAIL" + } + fmt.Fprintf(&b, " %s %-22s [%s] %q\n", mark, o.Case.ID, o.Mood, o.Body) + } + return b.String() +} + +func (r Report) sorted() []Outcome { + out := append([]Outcome(nil), r.Outcomes...) + sort.Slice(out, func(i, j int) bool { return out[i].Case.ID < out[j].Case.ID }) + return out +} + +func renderStats(m map[string]TagStat) string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s %d/%d", k, m[k].Passed, m[k].Total)) + } + return strings.Join(parts, " ") +} diff --git a/internal/phraser/eval/eval_test.go b/internal/phraser/eval/eval_test.go new file mode 100644 index 0000000..a3f3389 --- /dev/null +++ b/internal/phraser/eval/eval_test.go @@ -0,0 +1,179 @@ +package eval + +import ( + "context" + "strings" + "testing" + + "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/phraser" +) + +func TestLoadFixture(t *testing.T) { + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if _, err := f.Now(); err != nil { + t.Fatalf("Now: %v", err) + } + seen := map[string]bool{} + for _, c := range f.Cases { + if seen[c.ID] { + t.Errorf("duplicate case id %q", c.ID) + } + seen[c.ID] = true + if c.Rule == "" || c.Severity < 1 || c.Severity > 4 { + t.Errorf("%s: rule %q severity %d", c.ID, c.Rule, c.Severity) + } + if len(c.WantAny) == 0 { + t.Errorf("%s: no want_any, the on-topic check would always pass", c.ID) + } + } + // Coverage floor: all five loop rules plus both minted families, or the + // fixture measures a subset and the score does not mean what it says. + for _, rule := range []string{"water", "meal", "break", "service_down", "netdata_critical", "routine", "morning"} { + found := false + for _, c := range f.Cases { + if ruleFamily(c.Rule) == rule { + found = true + } + } + if !found { + t.Errorf("no case for rule family %q", rule) + } + } +} + +// TestStubBaseline is the CI ratchet: the deterministic Stub, no model, no +// network. The floor is low on purpose — the Stub is English template phrasing, +// so it fails `lang` on every case by construction. The point of the ratchet is +// that the checks keep running and the Stub does not get worse, not that the +// Stub is good. +func TestStubBaseline(t *testing.T) { + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + rep, err := Score(context.Background(), "stub (deterministic floor)", phraser.NewStub(), f) + if err != nil { + t.Fatalf("Score: %v", err) + } + t.Log("\n" + rep.String() + rep.Messages()) + + if rep.Errors != 0 { + t.Errorf("stub returned %d errors — the deterministic path must never fail", rep.Errors) + } + // Per-check ratchets rather than one composite: the Stub's composite is 0 + // (it never passes `lang`), so a composite floor would catch nothing. + floors := map[string]int{ + CheckMood: 15, + // 12, not 15: the Stub's `break` template genuinely runs past the + // ceiling ("you've been at your desk for 4 hours without a break — step + // away for a bit." is 76 chars but 16 words). Left failing rather than + // raising the ceiling to hide it. + CheckLength: 12, + CheckFeminine: 15, + CheckCringe: 15, + CheckOnTopic: 12, + } + for name, floor := range floors { + if rep.ByCheck[name] < floor { + t.Errorf("check %s: %d/%d, below ratchet %d — phrasing regressed", + name, rep.ByCheck[name], rep.Total, floor) + } + } +} + +// TestChecksCatchWhatTheyClaim — the checks are the measurement, so they get +// their own tests. Without these, a bad regexp would silently make every +// phrasing run look clean. +func TestChecksCatchWhatTheyClaim(t *testing.T) { + water := Case{Rule: "water", WantAny: []string{"вод"}} + + cases := []struct { + name string + body string + want string // the check that must fail, "" for a clean message + }{ + {"clean", "уже четыре часа без воды — попей.", ""}, + {"long", "уже четыре часа без воды, а это довольно много, и вообще пить надо регулярно, иначе будет плохо совсем", CheckLength}, + {"english", "you haven't had water in 4 hours, drink something", CheckLang}, + {"masculine self", "я напомнил про воду.", CheckFeminine}, + {"masculine dropped pronoun", "напомнил тебе про воду.", CheckFeminine}, + {"masculine predicative", "я должен сказать: попей воды.", CheckFeminine}, + // The other direction: HE is male, so second-person masculine is right. + {"second person masculine ok", "ты не пил воду четыре часа.", ""}, + {"feminine self ok", "я заметила: воды не было четыре часа.", ""}, + {"pet name", "милый, попей воды.", CheckCringe}, + {"emoji", "попей воды 💧", CheckCringe}, + {"exclamations", "попей воды!!", CheckCringe}, + {"fake concern", "я беспокоюсь: воды не было четыре часа.", CheckCringe}, + {"apology", "извини, что отвлекаю — попей воды.", CheckCringe}, + {"emotional support", "я рядом, ты не один. попей воды.", CheckCringe}, + {"asks how he feels", "как ты себя чувствуешь? попей воды.", CheckCringe}, + {"praise", "молодец! теперь попей воды.", CheckCringe}, + {"off topic", "пора бы уже что-то сделать.", CheckOnTopic}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var failed []string + for _, r := range RunChecks(water, tc.body, "neutral") { + if !r.Pass { + failed = append(failed, r.Name+"("+r.Detail+")") + } + } + joined := strings.Join(failed, " ") + switch { + case tc.want == "" && len(failed) > 0: + t.Errorf("clean message flagged: %s", joined) + case tc.want != "" && !strings.Contains(joined, tc.want+"("): + t.Errorf("want %s to fail, got %q", tc.want, joined) + } + }) + } +} + +func TestMoodCheckUsesTheEnum(t *testing.T) { + if r := checkMood("cheerful"); r.Pass { + t.Error("mood outside the enum passed") + } + if r := checkMood(""); r.Pass { + t.Error("empty mood passed") + } + for m := range Moods { + if r := checkMood(m); !r.Pass { + t.Errorf("enum mood %q failed", m) + } + } +} + +// TestCandidateCarriesTheContext — the whole harness is worthless if the +// Candidate it builds does not carry the duration the prompt is supposed to +// name. +func TestCandidateCarriesTheContext(t *testing.T) { + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + now, _ := f.Now() + for _, c := range f.Cases { + cand := c.Candidate(now) + if cand.Rule.Name != c.Rule || cand.Severity != loop.Severity(c.Severity) { + t.Errorf("%s: candidate lost rule or severity", c.ID) + } + if c.SinceMinutes > 0 { + d, ok := cand.State.Since(c.Rule) + if !ok || int(d.Minutes()) != c.SinceMinutes { + t.Errorf("%s: since %v ok=%v, want %d minutes", c.ID, d, ok, c.SinceMinutes) + } + } + if c.FactKey != "" { + fact, ok := cand.State.Fact(c.Rule) + if !ok || fact.Key != c.FactKey { + t.Errorf("%s: fact key %q, want %q", c.ID, fact.Key, c.FactKey) + } + } + } +} diff --git a/internal/phraser/eval/llmphraser_test.go b/internal/phraser/eval/llmphraser_test.go new file mode 100644 index 0000000..12b108a --- /dev/null +++ b/internal/phraser/eval/llmphraser_test.go @@ -0,0 +1,73 @@ +package eval + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/phraser" +) + +// TestLLMPhrasingBaseline — the resident model wording real nudges. Opt-in, +// same shape as internal/router/eval's MAVEN_LLM_URL gate, because CI has no +// model and a phrasing run costs minutes on the CPU target. +// +// llama-server -m /mnt/hdd1/llms/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf \ +// --host 127.0.0.1 --port 18099 -c 2048 -ngl 99 +// MAVEN_LLM_URL=http://127.0.0.1:18099 make eval-phrasing +// +// It reports and does not assert a quality bar. The numbers are the input to +// tuning the persona prompt; an assertion here would be the test inventing the +// bar rather than measuring against it. The one thing worth failing on is a +// harness fault — every case erroring means the run measured infrastructure. +func TestLLMPhrasingBaseline(t *testing.T) { + base := os.Getenv("MAVEN_LLM_URL") + if base == "" { + t.Skip("MAVEN_LLM_URL unset — point it at a running llama-server (see doc comment)") + } + // A local llama-server must not go through an HTTP proxy. This box proxies + // loopback through a SOCKS bridge that answers 503, which would score every + // case as a phrasing error and read as "the model cannot phrase". + noProxyLoopback(t) + + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + cfg := phraser.DefaultConfig("") + // Generous: an unconstrained 0.8B can spend a minute thinking before it + // writes a word, and a timeout would be scored as a model failure. + cfg.Timeout = 5 * time.Minute + p := phraser.NewLLMPhraserAt(base, cfg) + defer p.Close() + + rep, err := Score(context.Background(), "llm (0.8B, built-in persona)", p, f) + if err != nil { + t.Fatalf("Score: %v", err) + } + t.Log("\n" + rep.String() + "\nmessages:\n" + rep.Messages() + "\nfailures:\n" + rep.Failures()) + + if rep.Errors == rep.Total { + t.Errorf("all %d cases errored — harness fault, not a measurement", rep.Total) + } +} + +// noProxyLoopback appends the loopback host to no_proxy before any request, so +// http.ProxyFromEnvironment (which caches the environment on first use) sees it. +func noProxyLoopback(t *testing.T) { + t.Helper() + for _, key := range []string{"no_proxy", "NO_PROXY"} { + cur := os.Getenv(key) + if strings.Contains(cur, "127.0.0.1") { + continue + } + if cur == "" { + t.Setenv(key, "127.0.0.1,localhost") + continue + } + t.Setenv(key, cur+",127.0.0.1,localhost") + } +} diff --git a/internal/phraser/eval/nudges_v1.json b/internal/phraser/eval/nudges_v1.json new file mode 100644 index 0000000..8f8e7d8 --- /dev/null +++ b/internal/phraser/eval/nudges_v1.json @@ -0,0 +1,158 @@ +{ + "schema_version": 1, + "name": "nudge phrasing v1", + "reference_now": "2026-07-31T21:40:00+03:00", + "notes": [ + "The five loop rules (water, meal, break, service_down, netdata_critical) plus one routine: and one morning: case, at the severities they actually ship with.", + "The bad-moment cases (quiet_hours, calendar_busy) are here because the gate already let them through — ops outranks quiet hours. The phrasing must stay short and plain, not apologise for the timing.", + "want_any lists stems, not whole words, so declension does not defeat the on-topic check. English stems are included because the deterministic Stub is still English.", + "There is no expected message. The checks measure properties, not similarity to a reference sentence — a fixed golden string would just freeze one arbitrary phrasing." + ], + "cases": [ + { + "id": "water-3h", + "rule": "water", + "severity": 1, + "since_minutes": 190, + "want_any": ["вод", "попей", "пить", "выпей", "напит", "water", "drink"], + "tags": ["care"], + "note": "The base case. Just over the 3h predicate." + }, + { + "id": "water-7h", + "rule": "water", + "severity": 1, + "since_minutes": 430, + "want_any": ["вод", "попей", "пить", "выпей", "напит", "water", "drink"], + "tags": ["care"], + "note": "Long overdue. Severity is unchanged, so the phrasing must not escalate into alarm." + }, + { + "id": "water-busy", + "rule": "water", + "severity": 1, + "since_minutes": 240, + "calendar_busy": true, + "want_any": ["вод", "попей", "пить", "выпей", "напит", "water", "drink"], + "tags": ["care", "bad-moment"], + "note": "Mid-meeting. A bad moment invites an apology, which is the check that should catch it." + }, + { + "id": "meal-7h", + "rule": "meal", + "severity": 1, + "since_minutes": 420, + "want_any": ["ешь", "еда", "еды", "поешь", "перекус", "обед", "ужин", "покуш", "food", "eat"], + "tags": ["care"] + }, + { + "id": "meal-11h-quiet", + "rule": "meal", + "severity": 1, + "since_minutes": 660, + "quiet_hours": true, + "want_any": ["ешь", "еда", "еды", "поешь", "перекус", "обед", "ужин", "покуш", "food", "eat"], + "tags": ["care", "bad-moment"], + "note": "Quiet hours suppresses care nudges in the gate, so this one only reaches the phraser via an explicit override. Included because it is the shape most likely to draw a hedge." + }, + { + "id": "break-90m", + "rule": "break", + "severity": 2, + "since_minutes": 95, + "want_any": ["перерыв", "разомн", "встань", "отдохн", "пауз", "отойд", "размин", "break", "step away"], + "tags": ["care"] + }, + { + "id": "break-4h", + "rule": "break", + "severity": 2, + "since_minutes": 240, + "want_any": ["перерыв", "разомн", "встань", "отдохн", "пауз", "отойд", "размин", "break", "step away"], + "tags": ["care"] + }, + { + "id": "break-busy", + "rule": "break", + "severity": 2, + "since_minutes": 150, + "calendar_busy": true, + "want_any": ["перерыв", "разомн", "встань", "отдохн", "пауз", "отойд", "размин", "break", "step away"], + "tags": ["care", "bad-moment"] + }, + { + "id": "service-down", + "rule": "service_down", + "severity": 4, + "since_minutes": 3, + "fact_key": "vaultwarden", + "fact_value": "\"down\"", + "fact_source": "poll:uptimekuma", + "want_any": ["vaultwarden", "сервис", "упал", "не отвеч", "лежит", "недоступ", "down", "service"], + "tags": ["ops"], + "note": "The service name is in the fact key, not the value. A nudge that says 'a service' without naming it is on-topic but useless — the on-topic check cannot catch that, a human reading Messages() can." + }, + { + "id": "service-down-night", + "rule": "service_down", + "severity": 4, + "since_minutes": 2, + "quiet_hours": true, + "fact_key": "nextcloud", + "fact_value": "\"down\"", + "fact_source": "poll:uptimekuma", + "want_any": ["nextcloud", "сервис", "упал", "не отвеч", "лежит", "недоступ", "down", "service"], + "tags": ["ops", "bad-moment"], + "note": "03:00-shaped. Sev4 outranks quiet hours by design, so she speaks — plainly, without softening it into a maybe." + }, + { + "id": "netdata-disk", + "rule": "netdata_critical", + "severity": 3, + "since_minutes": 5, + "fact_key": "netdata_alarm", + "fact_value": "\"critical\"", + "fact_source": "poll:netdata", + "want_any": ["netdata", "диск", "критич", "алярм", "аларм", "тревог", "место", "памят", "critical", "alarm", "disk"], + "tags": ["ops"] + }, + { + "id": "netdata-busy", + "rule": "netdata_critical", + "severity": 3, + "since_minutes": 12, + "calendar_busy": true, + "fact_key": "netdata_alarm", + "fact_value": "\"critical\"", + "fact_source": "poll:netdata", + "want_any": ["netdata", "диск", "критич", "алярм", "аларм", "тревог", "место", "памят", "critical", "alarm", "disk"], + "tags": ["ops", "bad-moment"] + }, + { + "id": "routine-pills", + "rule": "routine:таблетки", + "severity": 2, + "since_minutes": 0, + "want_any": ["таблетк", "приня", "лекарств", "pill"], + "tags": ["routine"], + "note": "A routine: rule has no fact of its own, so there is no duration to name. The rule name is the only context." + }, + { + "id": "routine-stretch", + "rule": "routine:зарядка", + "severity": 1, + "since_minutes": 0, + "want_any": ["зарядк", "размин", "упражн", "потянис", "разомн", "stretch", "exercise"], + "tags": ["routine"] + }, + { + "id": "morning-checklist", + "rule": "morning:утро", + "severity": 2, + "since_minutes": 0, + "want_any": ["утр", "чеклист", "список", "не сделан", "осталось", "morning"], + "tags": ["routine"], + "note": "In production cmd/mavend/tick.go phrases morning routines deterministically and never calls the LLM. Scored anyway: the phraser is reachable with this rule name, and a fallback that garbles it is still a bug." + } + ] +} diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 90879ab..c61a3e1 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -67,6 +67,22 @@ func NewLLMPhraser(ctx context.Context, cfg Config) (*LLMPhraser, error) { return p, nil } +// NewLLMPhraserAt wires a phraser to a llama-server that someone else started +// and owns. It spawns nothing, so Close does not kill anything. +// +// This exists for the phrasing scorer (internal/phraser/eval), which must +// measure the phrasing against a shared llama-server without taking the model +// load hit per run or killing a server another process depends on. The daemon +// still uses NewLLMPhraser and still owns its own child process. +func NewLLMPhraserAt(baseURL string, cfg Config) *LLMPhraser { + return &LLMPhraser{ + cfg: cfg, + client: &http.Client{Timeout: cfg.Timeout}, + port: strings.TrimSuffix(baseURL, "/"), + cancel: func() {}, + } +} + func (p *LLMPhraser) start(ctx context.Context) error { args := []string{ "-m", p.cfg.ModelPath, diff --git a/internal/phraser/phraser.go b/internal/phraser/phraser.go index cd25abe..09db1f8 100644 --- a/internal/phraser/phraser.go +++ b/internal/phraser/phraser.go @@ -92,7 +92,11 @@ func (s *Stub) Close() error { return nil } // the predicate fire (the same State the predicate saw). func (s *Stub) PhraseNudge(_ context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) { body, summary := phraseNudge(c) - return delivery.PhrasedNudge{Candidate: c, Body: body, Summary: summary}, nil + // "neutral" rather than empty: Mood is part of the documented output + // contract and the Stub is a production fallback, so it must satisfy the + // contract too. Template phrasing has no tone to report, and neutral is the + // enum's own default. + return delivery.PhrasedNudge{Candidate: c, Body: body, Summary: summary, Mood: "neutral"}, nil } // PhraseReminder — extracts the user's text from the reminder payload (raw