4534101d10
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.
307 lines
12 KiB
Go
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.)
|