Answer Formatting
Problem
Production answers render as one dense paragraph. Two independent causes:
- SSE framing destroyed newlines.
agent_streaming_generatorinterpolated raw token text into a singledata: <text>line. A\n\ninside a token delta terminates the SSE event early; a single\nproduces a bare line with nodata:prefix, which spec-compliant parsers (gatewaySseAccumulator, promptfooresponse.js) silently drop — losing both the newline and any text after it in that delta. Recorded evidence (docs/61_retrieval/live-raw.json): every captured answer has zero newlines, with glue artifacts ("[1].Exemple","élève :- Maîtrise","pouvez :1. Rappeler") proving the model emitted line structure that the wire format destroyed. - No formatting instruction in the synthesis prompt.
_build_system_prompthad grounding, persona, exercise, and language rules — nothing about answer structure, so output shape depended on model defaults.
Target
- The client-reassembled streamed answer is byte-identical to the model answer, newlines included.
- The system prompt carries a terse Markdown structure contract: one-line lead,
short sections with numbered steps or bullets, blank lines between blocks,
bold key terms, inline
[n]citations, readable math, same structure for RTL. - The language/script instruction remains the LAST line of the system prompt.
Approach
_sse_event()inapp/api/routers/chat.py: split free-text data on\nand emit onedata:line per text line (SSE spec multi-line data). Applied totokenanderrorevents; JSON events (metadata,sources,usage) cannot contain raw newlines afterjson.dumps. The gateway accumulator and the promptfoo transform already rejoin multipledata:lines with\n, so no consumer changes are needed.- Formatting block in
_build_system_prompt(app/services/llm.py), its own delimited block directly above the final language/script line. - Non-streaming JSON path was already safe (JSON string escaping); a parity test pins it.
Non-goals
- Rendering: the website/Flutter clients own Markdown rendering (separate PR).
- Grounding-rule changes in
llm.py(owned byfix/arabic-refusal-round2).