phraser: one ask-and-parse step, and an empty generation says so (V-397)

PhraseQuery, PhraseSelf and the knowledge branch each wrote the same four
steps: call the model, parse the contract, pick whichever of the two errors
fired, wrap it with the path name. That is generate() now. Same fallbacks,
same errors, same text to the owner.

Two silences made visible, log only. A model that parsed cleanly and put an
empty string in "response" left no trace: the nudge went out in template
Russian and the reminder read its own text out, and nothing recorded that
the model had answered with nothing. Both say so now.
This commit is contained in:
2026-08-06 01:27:09 +04:00
parent 82d0384020
commit 968477ea59
+40 -26
View File
@@ -250,6 +250,12 @@ func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (deliver
// The model said nothing usable. Say it in Russian anyway — this text // The model said nothing usable. Say it in Russian anyway — this text
// goes straight to a Russian piper voice, so the old "water — care" // goes straight to a Russian piper voice, so the old "water — care"
// fallback was unspeakable. // fallback was unspeakable.
//
// It says so in the log now (V-397). A clean parse of {"response":""}
// left no trace at all: the nudge went out in template Russian and
// nothing recorded that the model had been asked and had answered with
// an empty field.
log.Printf("phraser: PhraseNudge: empty response for rule %q, using the plain Russian fallback", c.Rule.Name)
body = fallbackNudge(c) body = fallbackNudge(c)
} }
if mood == "" { if mood == "" {
@@ -272,38 +278,47 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
notes = nonEmpty(notes) notes = nonEmpty(notes)
if len(notes) == 0 { if len(notes) == 0 {
sys, prompt := p.knowledgePrompt(utterance) sys, prompt := p.knowledgePrompt(utterance)
resp, err := p.chatWithSystem(ctx, sys, prompt, 768) text, raw, err := p.generate(ctx, "phrase query (knowledge)", sys, prompt, 768)
if err != nil { if err != nil {
return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", err) return UnknownFallback(), err
} }
if resp == "" { if raw == "" {
return UnknownFallback(), errEmptyResponse return UnknownFallback(), errEmptyResponse
} }
text, _, perr := parseResponseMood(resp)
if perr != nil {
return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", perr)
}
if text != "" { if text != "" {
return text, nil return text, nil
} }
return resp, nil return raw, nil
} }
sys, prompt := p.evidencePrompt(utterance, notes) sys, prompt := p.evidencePrompt(utterance, notes)
resp, err := p.chatWithSystem(ctx, sys, prompt, 768) text, raw, err := p.generate(ctx, "phrase query (evidence)", sys, prompt, 768)
text, _, perr := parseResponseMood(resp) if err != nil {
if err != nil || perr != nil {
// Read the notes out rather than ship a broken fragment. // Read the notes out rather than ship a broken fragment.
cause := err return SourcesFallback(strings.Join(notes, "; ")), err
if cause == nil {
cause = perr
}
return SourcesFallback(strings.Join(notes, "; ")),
fmt.Errorf("phrase query (evidence): %w", cause)
} }
if text != "" { if text != "" {
return text, nil return text, nil
} }
return resp, nil return raw, nil
}
// generate asks the model one question and reads the reply contract off the
// answer. text is what she said, raw is what the model actually wrote — the
// callers that ship bare prose when the model skipped the JSON need both.
//
// where names the path for the error, which every caller wraps identically.
// An error here always means the caller must use its fallback: a transport
// failure, or a generation that opened a JSON object and never closed it.
func (p *LLMPhraser) generate(ctx context.Context, where, sys, user string, maxTokens int) (text, raw string, err error) {
raw, err = p.chatWithSystem(ctx, sys, user, maxTokens)
if err != nil {
return "", raw, fmt.Errorf("%s: %w", where, err)
}
text, _, perr := parseResponseMood(raw)
if perr != nil {
return "", raw, fmt.Errorf("%s: %w", where, perr)
}
return text, raw, nil
} }
// PhraseChat uses the LLM to respond conversationally, building a multi-turn // PhraseChat uses the LLM to respond conversationally, building a multi-turn
@@ -390,6 +405,10 @@ func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision
body, mood = "", "" body, mood = "", ""
} }
if body == "" { if body == "" {
// Same silence as PhraseNudge had (V-397): she reads the reminder's own
// text out, which is fine, but nothing said the model had produced
// nothing.
log.Printf("phraser: PhraseReminder: empty response, reading the reminder text out instead")
body = text body = text
} }
if mood == "" { if mood == "" {
@@ -416,14 +435,9 @@ func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error
// is already a readable answer, which is why this needs no separate fallback. // is already a readable answer, which is why this needs no separate fallback.
func (p *LLMPhraser) PhraseSelf(ctx context.Context, utterance, description string) (string, error) { func (p *LLMPhraser) PhraseSelf(ctx context.Context, utterance, description string) (string, error) {
sys, prompt := p.selfPrompt(utterance, description) sys, prompt := p.selfPrompt(utterance, description)
resp, err := p.chatWithSystem(ctx, sys, prompt, 768) text, _, err := p.generate(ctx, "phrase self", sys, prompt, 768)
text, _, perr := parseResponseMood(resp) if err != nil {
if err != nil || perr != nil { return description, err
cause := err
if cause == nil {
cause = perr
}
return description, fmt.Errorf("phrase self: %w", cause)
} }
if text != "" { if text != "" {
return text, nil return text, nil