Files
Maven/internal/phraser/phraser.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

245 lines
10 KiB
Go

// Package phraser is maven's "rules decide, llm phrases" seam — the layer
// that turns a loop decision into the body + summary the delivery module ships.
//
// Per docs/design.md § Resident language model: the phraser is the resident model
// (Qwen3-1.7B — RU continued pretraining plus joint persona/router SFT, not a
// sub-1b prompted-only model as the retired spec claimed; see docs/design.md
// § Superseded, "small-model phrasing claim"). It takes
// (rule, severity, context) and produces Body (full voice message, local — no
// shoulder-surf concern beyond who's in the room) + Summary (minimal body for
// away channels — "disk low on homesrv," not detail; no exfil through the
// relay). the phraser NEVER owns the route — it phrases what the loop decided.
//
// This package defines the interface + a deterministic Stub (the floor). the
// Stub is template-based, no model — it exists so the daemon can be wired
// end-to-end before the LLM-backed impl lands. the LLM impl is a single new
// type satisfying the same interface; the daemon swaps one for the other at
// the construction seam, no CoreAPI or delivery change.
//
// Architecture: the phraser is impure (the LLM impl makes RPC calls). the
// Stub is pure (templates over State) and is the test floor. both produce
// delivery.PhrasedNudge / delivery.PhrasedReminder, which the dispatcher
// consumes unchanged.
package phraser
import (
"context"
"fmt"
"strings"
"time"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/store"
)
// Phraser — the seam the daemon wires. one method per delivery path (nudge
// = loop-derived, reminder = user-stated). both return the
// delivery.Phrased* structs the dispatcher consumes, so the phraser owns the
// full output contract: Body (voice) + Summary (away channels).
//
// the daemon calls PhraseNudge with the loop's *Candidate (Rule + Severity +
// the State snapshot at evaluation time — exactly the (rule, severity,
// context) input the spec names). PhraseReminder with the ReminderDecision
// (Reminder + State). PhraseChat with a conversational utterance + dialogue
// history. the phraser reads the State for context ("you haven't had water in
// 4h, you're at your desk, it's 2pm") — never touches the store.
type Phraser interface {
PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error)
PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error)
PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error)
// PhraseSelf answers a question about her from her own description, which
// is not a source she read and must not be phrased as one (Vikunja #555).
PhraseSelf(ctx context.Context, utterance, description string) (string, error)
PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error)
Close() error
}
// Stub — the deterministic, no-model floor. template-based, reads context
// from the Candidate/Decision State. produces a terse Body (voice) + an even
// terser Summary (away channels). warm-but-functional tone; the LLM impl
// carries the personality-prompt character spec, the Stub does not.
//
// the Stub is the production path until the LLM-backed impl lands, and the
// test path afterward (deterministic phrasing makes the daemon + delivery
// unit-testable without a model in the loop).
type Stub struct{}
// NewStub builds the floor phraser. no config — the Stub is stateless.
func NewStub() *Stub { return &Stub{} }
// PhraseChat returns a stub reply — the LLMPhraser replaces this with a
// prompted response from the model. The history parameter is accepted but
// ignored at the stub level (the production impl uses it for multi-turn).
func (s *Stub) PhraseChat(_ context.Context, _ string, _ []dialogue.Turn) (string, error) {
return ChatFallback(), nil
}
// PhraseQuery returns a deterministic summary of the best matching notes.
func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string, error) {
if len(notes) == 0 {
return UnknownFallback(), nil
}
return SourcesFallback(strings.Join(notes, "; ")), nil
}
// PhraseSelf reads the description out as it stands. There is nothing to fall
// back to and nothing to shorten: the text is already written in her voice, and
// that is the whole reason it is a constant rather than a prompt.
func (s *Stub) PhraseSelf(_ context.Context, _, description string) (string, error) {
return description, nil
}
// Close implements Phraser.Close (no-op for the stub).
func (s *Stub) Close() error { return nil }
// PhraseNudge — dispatches on the rule name to a per-rule template, falls
// back to a generic shape. reads the State for the durations/values that made
// 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)
// "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
// JSON, shape owned by the router's reminder slot extraction) and renders it
// as both Body and a short Summary. the reminder's payload is the user's own
// words — the phraser just unwraps it, doesn't editorialize.
func (s *Stub) PhraseReminder(_ context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) {
text := extractReminderText(d.Reminder.Payload)
if text == "" {
text = "reminder"
}
// Mood, for the same reason PhraseNudge sets it: the Stub is a production
// fallback, so it owes the output contract a value. This one was left at the
// zero value, which is not one of the five moods.
return delivery.PhrasedReminder{
Decision: d, Body: text, Summary: reminderSummary(text), Mood: "neutral",
}, nil
}
// summaryLimit — how much of a reminder goes to the away channels.
const summaryLimit = 60
// reminderSummary shortens a reminder body to the away-channel summary.
//
// Counted in runes. Both copies of this counted bytes — `len(s) > 60` and
// `s[:57]` — and on a Russian reminder that is wrong twice. A Cyrillic letter is
// two bytes, so the cut fell at about 28 letters rather than 60; and byte 57
// lands inside a letter about half the time, so the summary ended in half a
// rune. That is not cosmetic: Sendable.Summary is the text voicesink hands to
// piper and the text the telegram sink posts, so the broken byte was spoken and
// sent.
func reminderSummary(body string) string {
r := []rune(body)
if len(r) <= summaryLimit {
return body
}
return string(r[:summaryLimit-3]) + "..."
}
// phraseNudge — the per-rule templates. each reads the context the predicate
// used to decide, so the phrased message names WHY the rule fired ("you
// haven't had water in 4h") rather than just THAT it fired.
func phraseNudge(c loop.Candidate) (body, summary string) {
switch c.Rule.Name {
case "water":
if d, ok := c.State.Since("water"); ok {
body = fmt.Sprintf("you haven't had water in %s — drink something.", humanDur(d))
} else {
body = "drink some water."
}
return body, "drink water"
case "meal":
if d, ok := c.State.Since("meal"); ok {
body = fmt.Sprintf("it's been %s since you ate — get some food.", humanDur(d))
} else {
body = "you should eat something."
}
return body, "eat something"
case "break":
if d, ok := c.State.Since("break"); ok {
body = fmt.Sprintf("you've been at your desk for %s without a break — step away for a bit.", humanDur(d))
} else {
body = "take a break."
}
return body, "take a break"
case "service_down":
// One fact per kuma monitor, so the nudge names the service. The rule
// and this share loop.DownServices, so the message cannot name a
// service the predicate did not fire on.
down := loop.DownServices(c.State)
switch len(down) {
case 0:
return "a service on homesrv is down — check journalctl.", "service down on homesrv"
case 1:
return fmt.Sprintf("%s on homesrv is down — check journalctl.", down[0]),
fmt.Sprintf("%s down on homesrv", down[0])
default:
list := strings.Join(down, ", ")
return fmt.Sprintf("%s on homesrv are down — check journalctl.", list),
fmt.Sprintf("%d services down on homesrv", len(down))
}
default:
// generic: name the rule + severity; the LLM impl replaces this with
// a prompted phrase. the Stub never editorializes beyond the rule name.
body = fmt.Sprintf("%s — %s", c.Rule.Name, sevLabel(c.Severity))
summary = c.Rule.Name
return body, summary
}
}
// extractReminderText — the reminder payload is raw JSON and store.ReminderText
// owns the unwrapping. It used to be a second copy of that logic here, which is
// how the day plan came to recite a reminder as its literal JSON: the copies
// were never going to be kept in step.
func extractReminderText(payload string) string { return store.ReminderText(payload) }
// humanDur — round a duration to the coarsest sensible unit for speech.
// "4h12m" → "4 hours"; "92m" → "1h32m" → "an hour and a half". keep it simple:
// hours, then minutes, rounded. this is FLOOR phrasing — the LLM impl can
// natural-language it; the Stub sticks to readable.
func humanDur(d time.Duration) string {
if d < 0 {
d = 0
}
h := int(d.Hours())
m := int(d.Minutes()) % 60
switch {
case h >= 2:
return fmt.Sprintf("%d hours", h)
case h == 1:
if m >= 30 {
return "an hour and a half"
}
return "an hour"
default:
if m >= 45 {
return "an hour"
}
return fmt.Sprintf("%d minutes", m)
}
}
// sevLabel — a one-word gist of severity for the generic fallback. the
// per-rule templates don't use this; it's only for rules without a dedicated
// template (i.e. rules added to DefaultRules after the phrasers ship, before
// they get a template).
func sevLabel(s loop.Severity) string {
switch {
case s <= loop.Sev1:
return "care"
case s == loop.Sev2:
return "care"
case s == loop.Sev3:
return "ops"
default:
return "alarm"
}
}