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.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
@@ -185,6 +186,34 @@ func TestPhraseReminderTruncatesLongSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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{
|
||||
|
||||
Reference in New Issue
Block a user