Files
Maven/internal/phraser/parse.go
claude 521c315b30 phraser: one file for the reply contract, and the legacy parser goes (V-397)
parseResponseMood, escapeRawControls and stripThink move to parse.go
unchanged. The {"body","summary"} parser is deleted: it was the contract
before 6a51216 replaced it with {"response","mood"}, no prompt in the repo
asks for that shape, the GBNF cannot emit it, and no test covered it. Its
only two callers were nudge and reminder fallbacks that already fall through
to hand-written Russian.
2026-08-06 01:14:03 +04:00

121 lines
4.2 KiB
Go

// phraser/parse.go — the reply-side of the LLM output contract:
// {"response":"...","mood":"..."} in, a string and a mood out.
//
// One parser, and every phrasing path in the repo goes through it — the six
// LLMPhraser methods, PhraseWorld, and Replier.PhraseReply, which cmd/mavend
// wraps. The contract is written down in CLAUDE.md; this file is the only
// place it is implemented, so the two halves cannot drift.
package phraser
import (
"encoding/json"
"fmt"
"strings"
)
type responseMood struct {
Response string `json:"response"`
Mood string `json:"mood"`
}
// errBrokenJSON — the model started a JSON object and never finished it.
// That is a failed generation, not a reply. Callers must use their fallback.
var errBrokenJSON = fmt.Errorf("phraser: model output starts as JSON but does not parse")
// parseResponseMood extracts {"response","mood"} from LLM output, tolerant
// of thinking tokens and extra text before/after the JSON block.
//
// Three outcomes:
// - parsed fine → the fields, nil error.
// - output never looked like JSON → ("", "", nil). The caller may ship it
// as-is; small models sometimes answer in bare prose and that is fine.
// - output starts with "{" but does not parse → errBrokenJSON. The grammar
// guarantees a valid *prefix*, so a generation that hits the token cap
// mid-object comes back as a fragment like `{` or `{\n "`. Shipping that
// as a reply is the bug this error exists to stop.
func parseResponseMood(raw string) (response, mood string, err error) {
cleaned := strings.TrimSpace(raw)
start := strings.Index(cleaned, "{")
end := strings.LastIndex(cleaned, "}")
if start < 0 || end < 0 || end <= start {
if strings.HasPrefix(cleaned, "{") {
return "", "", errBrokenJSON
}
return "", "", nil
}
var parsed responseMood
if e := json.Unmarshal([]byte(escapeRawControls(cleaned[start:end+1])), &parsed); e != nil {
if strings.HasPrefix(cleaned, "{") {
return "", "", errBrokenJSON
}
return "", "", nil
}
return parsed.Response, parsed.Mood, nil
}
// escapeRawControls escapes the control characters a model writes literally
// inside a JSON string, so a reply that is otherwise fine still parses.
//
// The grammar is what stops these being generated (Vikunja #537). This is the
// second line, for the paths that send no grammar at all — NoGrammar, and any
// remote model whose server ignores one. A raw newline is the shape that was
// measured; the rest of the range is here because the same argument covers it.
//
// Inside a string only. The first version escaped the whole object on the
// argument that JSON permits no control character outside a string either, so
// rewriting one could not do harm. That argument is wrong: JSON permits a
// newline, a tab and a return BETWEEN tokens, which is what pretty-printing is.
// Qwen3-1.7B pretty-prints — it opens `{` and writes three newlines before the
// first key — and escaping those into a literal backslash-n broke every reply
// it wrote. Measured 2026-08-05 on the talk fixture: 31 of 36 conversational
// cases came back as errBrokenJSON and answered from the stub (Vikunja #44).
func escapeRawControls(s string) string {
if !strings.ContainsFunc(s, func(r rune) bool { return r < 0x20 }) {
return s
}
var b strings.Builder
b.Grow(len(s) + 8)
inString := false
escaped := false
for _, r := range s {
switch {
case escaped:
// The character after a backslash is the model's own escape and is
// already whatever it meant to write.
escaped = false
b.WriteRune(r)
continue
case inString && r == '\\':
escaped = true
b.WriteRune(r)
continue
case r == '"':
inString = !inString
b.WriteRune(r)
continue
}
switch {
case !inString || r >= 0x20:
b.WriteRune(r)
case r == '\n':
b.WriteString(`\n`)
case r == '\r':
b.WriteString(`\r`)
case r == '\t':
b.WriteString(`\t`)
default:
fmt.Fprintf(&b, `\u%04x`, r)
}
}
return b.String()
}
// stripThink removes the <think> block that Thinking-variant models emit
// before the actual response. No-op when no think block is present.
func stripThink(s string) string {
if i := strings.LastIndex(s, "</think>"); i >= 0 {
s = strings.TrimSpace(s[i+8:])
}
return s
}