diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 1cb5fc9..d03d8e2 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -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 // goes straight to a Russian piper voice, so the old "water — care" // 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) } if mood == "" { @@ -272,38 +278,47 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes [] notes = nonEmpty(notes) if len(notes) == 0 { 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 { - return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", err) + return UnknownFallback(), err } - if resp == "" { + if raw == "" { return UnknownFallback(), errEmptyResponse } - text, _, perr := parseResponseMood(resp) - if perr != nil { - return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", perr) - } if text != "" { return text, nil } - return resp, nil + return raw, nil } sys, prompt := p.evidencePrompt(utterance, notes) - resp, err := p.chatWithSystem(ctx, sys, prompt, 768) - text, _, perr := parseResponseMood(resp) - if err != nil || perr != nil { + text, raw, err := p.generate(ctx, "phrase query (evidence)", sys, prompt, 768) + if err != nil { // Read the notes out rather than ship a broken fragment. - cause := err - if cause == nil { - cause = perr - } - return SourcesFallback(strings.Join(notes, "; ")), - fmt.Errorf("phrase query (evidence): %w", cause) + return SourcesFallback(strings.Join(notes, "; ")), err } if text != "" { 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 @@ -390,6 +405,10 @@ func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision body, mood = "", "" } 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 } 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. func (p *LLMPhraser) PhraseSelf(ctx context.Context, utterance, description string) (string, error) { sys, prompt := p.selfPrompt(utterance, description) - resp, err := p.chatWithSystem(ctx, sys, prompt, 768) - text, _, perr := parseResponseMood(resp) - if err != nil || perr != nil { - cause := err - if cause == nil { - cause = perr - } - return description, fmt.Errorf("phrase self: %w", cause) + text, _, err := p.generate(ctx, "phrase self", sys, prompt, 768) + if err != nil { + return description, err } if text != "" { return text, nil