// 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 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, ""); i >= 0 { s = strings.TrimSpace(s[i+8:]) } return s }