Skip to content

Answer Formatting

Problem

Production answers render as one dense paragraph. Two independent causes:

  1. SSE framing destroyed newlines. agent_streaming_generator interpolated raw token text into a single data: <text> line. A \n\n inside a token delta terminates the SSE event early; a single \n produces a bare line with no data: prefix, which spec-compliant parsers (gateway SseAccumulator, promptfoo response.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.
  2. No formatting instruction in the synthesis prompt. _build_system_prompt had 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

  1. _sse_event() in app/api/routers/chat.py: split free-text data on \n and emit one data: line per text line (SSE spec multi-line data). Applied to token and error events; JSON events (metadata, sources, usage) cannot contain raw newlines after json.dumps. The gateway accumulator and the promptfoo transform already rejoin multiple data: lines with \n, so no consumer changes are needed.
  2. Formatting block in _build_system_prompt (app/services/llm.py), its own delimited block directly above the final language/script line.
  3. 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 by fix/arabic-refusal-round2).