Files
Maven/internal/phraser/phraser_test.go
T
claude 4534101d10 phraser: the reminder summary is cut in runes, and silence is an error (V-620)
Three defects in internal/phraser, all of the shape "reports done when
nothing happened".

The reminder summary was cut in bytes: `len(s) > 60` and `s[:57]`, in two
copies (Stub.PhraseReminder and LLMPhraser.PhraseReminder). On Russian a
letter is two bytes, so the cut fell at about 28 letters instead of 60 and
landed inside a letter about half the time. Sendable.Summary is what
voicesink hands to piper and what the telegram sink posts, so the half rune
was spoken and sent. One rune-counting helper now, shared by both. The test
that covered this was ASCII, which is what let the arithmetic stand.

The evidence branch of PhraseQuery and the bare-prose tail of PhraseChat
both returned ("", nil) when the server answered and the model wrote no
tokens. The knowledge branch has guarded that with errEmptyResponse since it
was written; these two did not. The daemon's callers check for the empty
string and paper over it, so the visible cost was the eval, which scored a
silent model as bad phrasing rather than as a failure, and a log line that
never appeared.

Stub.PhraseReminder set no Mood. Its sibling PhraseNudge sets "neutral" and
says in a comment why: the Stub is a production fallback and owes the output
contract a value. The zero value is not one of the five moods.

No prompt and no spoken wording changed, so the phrasing eval is unmoved.
2026-08-06 05:01:19 +04:00

307 lines
12 KiB
Go

package phraser
import (
"context"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/store"
)
// ----------------------------- nudges --------------------------------------
func TestPhraseNudgeWaterMentionsDuration(t *testing.T) {
now := time.Now().UTC()
earlier := now.Add(-4 * time.Hour)
st := loop.State{
Now: now,
Facts: map[string]store.Fact{"water": {Key: "water", Ts: earlier, Source: "tap:water", Value: `"250ml"`}},
}
c := loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1, State: st}
pn, err := NewStub().PhraseNudge(context.Background(), c)
if err != nil {
t.Fatalf("PhraseNudge: %v", err)
}
if pn.Candidate.Rule.Name != "water" {
t.Fatalf("candidate rule: want water, got %s", pn.Candidate.Rule.Name)
}
if !strings.Contains(pn.Body, "water") || !strings.Contains(pn.Body, "4 hours") {
t.Fatalf("body should mention water + 4 hours, got %q", pn.Body)
}
if pn.Summary != "drink water" {
t.Fatalf("summary: want 'drink water', got %q", pn.Summary)
}
// Summary must be shorter than Body — the away-channel minimal-body rule.
if len(pn.Summary) >= len(pn.Body) {
t.Fatalf("summary should be shorter than body: body=%q (%d), summary=%q (%d)", pn.Body, len(pn.Body), pn.Summary, len(pn.Summary))
}
}
func TestPhraseNudgeMealNoDataStillPhrases(t *testing.T) {
// predicate wouldn't fire on no data (since==null), but the phraser is
// still required to produce SOMETHING if called — never return empty body.
st := loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}}
c := loop.Candidate{Rule: loop.MealRule(), Severity: loop.Sev1, State: st}
pn, err := NewStub().PhraseNudge(context.Background(), c)
if err != nil {
t.Fatalf("PhraseNudge: %v", err)
}
if pn.Body == "" {
t.Fatal("body empty on no-data — should still phrase")
}
if pn.Summary == "" {
t.Fatal("summary empty on no-data")
}
}
func TestPhraseNudgeBreakDeskDuration(t *testing.T) {
now := time.Now().UTC()
st := loop.State{
Now: now,
Facts: map[string]store.Fact{
"desk_active": {Key: "desk_active", Ts: now.Add(-30 * time.Second)},
"break": {Key: "break", Ts: now.Add(-95 * time.Minute)},
},
}
c := loop.Candidate{Rule: loop.BreakRule(), Severity: loop.Sev2, State: st}
pn, _ := NewStub().PhraseNudge(context.Background(), c)
if !strings.Contains(pn.Body, "desk") || !strings.Contains(pn.Body, "an hour and a half") {
t.Fatalf("break body should mention desk + duration, got %q", pn.Body)
}
if pn.Summary != "take a break" {
t.Fatalf("summary: want 'take a break', got %q", pn.Summary)
}
}
func TestPhraseNudgeServiceDownNamedService(t *testing.T) {
// one fact per kuma monitor → the phrase names the monitor that is down.
now := time.Now().UTC()
st := loop.State{
Now: now,
Facts: map[string]store.Fact{
"service_down:nginx": {Key: "service_down:nginx", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
"service_down:db": {Key: "service_down:db", Ts: now, Source: loop.ServiceDownSource, Value: `"up"`},
},
}
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
pn, _ := NewStub().PhraseNudge(context.Background(), c)
if !strings.Contains(pn.Body, "nginx") {
t.Fatalf("body should name the service, got %q", pn.Body)
}
if !strings.Contains(pn.Summary, "nginx") {
t.Fatalf("summary should name the service, got %q", pn.Summary)
}
}
func TestPhraseNudgeServiceDownGenericKey(t *testing.T) {
// the old aggregate key, still in the store from before the per-monitor
// facts landed → the generic phrase, never a phantom "service_down down".
now := time.Now().UTC()
st := loop.State{
Now: now,
Facts: map[string]store.Fact{"service_down": {Key: "service_down", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`}},
}
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
pn, _ := NewStub().PhraseNudge(context.Background(), c)
if strings.Contains(pn.Body, "service_down down") {
t.Fatalf("body shouldn't repeat the key verbatim: %q", pn.Body)
}
}
// Two monitors down at once must both be named — he needs to know the blast
// radius, and "a service is down" was the whole defect being fixed here.
func TestPhraseNudgeServiceDownNamesEveryDownMonitor(t *testing.T) {
now := time.Now().UTC()
st := loop.State{
Now: now,
Facts: map[string]store.Fact{
"service_down:nginx": {Key: "service_down:nginx", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
"service_down:db": {Key: "service_down:db", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
},
}
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
pn, _ := NewStub().PhraseNudge(context.Background(), c)
for _, want := range []string{"nginx", "db"} {
if !strings.Contains(pn.Body, want) {
t.Fatalf("body should name %q, got %q", want, pn.Body)
}
}
}
func TestPhraseNudgeUnknownRuleFallsBack(t *testing.T) {
// a rule without a dedicated template — generic fallback names the rule +
// severity gist. never empty.
unk := loop.Rule{Name: "custom_rule", Severity: loop.Sev3}
st := loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}}
c := loop.Candidate{Rule: unk, Severity: loop.Sev3, State: st}
pn, _ := NewStub().PhraseNudge(context.Background(), c)
if pn.Body == "" || pn.Summary == "" {
t.Fatalf("fallback should produce both: body=%q summary=%q", pn.Body, pn.Summary)
}
if pn.Summary != "custom_rule" {
t.Fatalf("fallback summary should be the rule name, got %q", pn.Summary)
}
}
// ----------------------------- reminders ------------------------------------
func TestPhraseReminderExtractsText(t *testing.T) {
// the router's reminder slot extraction produces {"text": "wake me"} — the
// phraser unwraps it. the reminder's words are the user's; no editorializing.
rd := loop.ReminderDecision{
Reminder: store.Reminder{Payload: `{"text":"wake me at 7"}`},
State: loop.State{Now: time.Now().UTC()},
}
pr, err := NewStub().PhraseReminder(context.Background(), rd)
if err != nil {
t.Fatalf("PhraseReminder: %v", err)
}
if pr.Body != "wake me at 7" {
t.Fatalf("body: want 'wake me at 7', got %q", pr.Body)
}
if pr.Summary != "wake me at 7" {
t.Fatalf("summary: want 'wake me at 7', got %q", pr.Summary)
}
}
func TestPhraseReminderTruncatesLongSummary(t *testing.T) {
// away channels (ntfy/telegram) get Summary; a long reminder text →
// truncated so the lock-screen preview isn't a paragraph.
long := "remind me to do the thing where i need to walk all the way over there and back before the sun comes up"
rd := loop.ReminderDecision{
Reminder: store.Reminder{Payload: `{"text":"` + long + `"}`},
State: loop.State{Now: time.Now().UTC()},
}
pr, _ := NewStub().PhraseReminder(context.Background(), rd)
if len(pr.Summary) > 63 {
t.Fatalf("summary should be truncated to ~60, got %d: %q", len(pr.Summary), pr.Summary)
}
if pr.Body != long {
t.Fatalf("body should be the full text, got %q", pr.Body)
}
}
// The same truncation, in the language she actually speaks. The test above is
// ASCII, which is what let the byte arithmetic stand: `len(s) > 60` and `s[:57]`
// cut a Russian reminder at about 28 letters instead of 60, and landed inside a
// letter about half the time. Summary is what voicesink hands to piper and what
// the telegram sink posts, so half a rune was spoken and sent.
func TestPhraseReminderSummaryCountsRunesNotBytes(t *testing.T) {
long := "позвонить маме и забрать посылку из пункта выдачи на соседней улице до восьми вечера"
rd := loop.ReminderDecision{
Reminder: store.Reminder{Payload: `{"text":"` + long + `"}`},
State: loop.State{Now: time.Now().UTC()},
}
pr, _ := NewStub().PhraseReminder(context.Background(), rd)
if !utf8.ValidString(pr.Summary) {
t.Fatalf("summary is not valid UTF-8, it was cut mid-letter: %q", pr.Summary)
}
if n := utf8.RuneCountInString(pr.Summary); n > summaryLimit {
t.Fatalf("summary = %d runes, want at most %d: %q", n, summaryLimit, pr.Summary)
}
// The cut must be near the limit, not near half of it. A byte count would
// stop at 28 letters here.
if n := utf8.RuneCountInString(pr.Summary); n < summaryLimit-5 {
t.Fatalf("summary = %d runes, cut far too early — counted in bytes? %q", n, pr.Summary)
}
if pr.Mood == "" {
t.Error("Mood is empty; the Stub is a production fallback and owes the contract a mood")
}
}
func TestPhraseReminderNonJSONPayload(t *testing.T) {
// a payload that isn't JSON → the phraser falls back to the raw string.
rd := loop.ReminderDecision{
Reminder: store.Reminder{Payload: "just a plain string"},
State: loop.State{Now: time.Now().UTC()},
}
pr, _ := NewStub().PhraseReminder(context.Background(), rd)
if pr.Body != "just a plain string" {
t.Fatalf("non-json body: want the raw string, got %q", pr.Body)
}
if pr.Summary != "just a plain string" {
t.Fatalf("non-json summary: want the raw string, got %q", pr.Summary)
}
}
func TestPhraseReminderEmptyPayload(t *testing.T) {
// don't ship an empty message — the reminder path must produce something.
rd := loop.ReminderDecision{
Reminder: store.Reminder{Payload: ""},
State: loop.State{Now: time.Now().UTC()},
}
pr, _ := NewStub().PhraseReminder(context.Background(), rd)
if pr.Body == "" || pr.Summary == "" {
t.Fatalf("empty payload should fall back to 'reminder': body=%q summary=%q", pr.Body, pr.Summary)
}
if pr.Body != "reminder" {
t.Fatalf("empty payload body: want 'reminder', got %q", pr.Body)
}
}
// ----------------------------- chat -----------------------------------------
func TestPhraseChatReturnsNonEmpty(t *testing.T) {
p := NewStub()
reply, err := p.PhraseChat(context.Background(), "как дела", nil)
if err != nil {
t.Fatalf("PhraseChat: %v", err)
}
if reply == "" {
t.Fatal("PhraseChat returned empty reply")
}
}
func TestPhraseChatWithHistory(t *testing.T) {
p := NewStub()
history := []dialogue.Turn{
{Text: "привет", Intent: dialogue.IntentChat},
{Text: "как тебя зовут", Intent: dialogue.IntentChat},
}
reply, err := p.PhraseChat(context.Background(), "расскажи о себе", history)
if err != nil {
t.Fatalf("PhraseChat with history: %v", err)
}
if reply == "" {
t.Fatal("PhraseChat with history returned empty reply")
}
}
// ----------------------------- interface guard ------------------------------
func TestStubSatisfiesPhraser(t *testing.T) {
// compile-time guard: the Stub must satisfy the Phraser interface so the
// daemon can wire it. the production LLM impl satisfies the same interface.
var _ Phraser = (*Stub)(nil)
// also exercise the methods once so the guard isn't the only assertion.
p := NewStub()
if _, err := p.PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1, State: loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}}}); err != nil {
t.Fatalf("PhraseNudge: %v", err)
}
if _, err := p.PhraseReminder(context.Background(), loop.ReminderDecision{Reminder: store.Reminder{Payload: "{}"}, State: loop.State{Now: time.Now().UTC()}}); err != nil {
t.Fatalf("PhraseReminder: %v", err)
}
if _, err := p.PhraseChat(context.Background(), "как дела", nil); err != nil {
t.Fatalf("PhraseChat: %v", err)
}
}
func TestStubProducesDeliveryTypes(t *testing.T) {
// the output must be the delivery.Phrased* structs the dispatcher
// consumes — the phraser owns the full output contract.
pn, _ := NewStub().PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1, State: loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}}})
if _, ok := any(pn).(delivery.PhrasedNudge); !ok {
t.Fatalf("PhraseNudge must return delivery.PhrasedNudge, got %T", pn)
}
}
// ----------------------------- helpers --------------------------------------
// (the per-rule templates change output shape; assert via strings.Contains
// against salient fragments, not exact strings — keeps the tests robust to
// tone tweaks in the Stub.)