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.
245 lines
10 KiB
Go
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"
|
|
}
|
|
}
|