.. SPDX-License-Identifier: CC-BY-4.0 .. SPDX-FileCopyrightText: Netresearch DTT GmbH .. include:: /Includes.rst.txt .. _adr-004: =========================================================== ADR-004: Text Formats as Schema-Validated Structured Output =========================================================== :Status: Accepted :Date: 2026-09-25 :Authors: Netresearch DTT GmbH .. _adr-004-context: Context ======= Four text formats join the media artifacts: an executive summary, an FAQ, social-media posts for three platforms and a newsletter text. Unlike the podcast script or the story copy, which are intermediate material for a render step, these texts *are* the result. Each has a shape the editor relies on — a list of question/answer pairs, a subject plus preheader plus one call to action, one post per platform — and the social posts have hard platform limits (LinkedIn 3,000, X 280, Instagram 2,200 characters). The existing generators call :php:`CompletionServiceInterface::completeJson()` and parse the decoded array tolerantly. That call only guarantees valid JSON; a missing key or a wrong type surfaces in the parser, and there is no second chance to get it right. nr-llm (since 0.35, the floor of this extension) also offers :php:`completeStructured()`: the caller passes a JSON schema from a strict subset (nr-llm ADR-126), nr-llm validates the answer against it, and on a mismatch makes one repair round-trip that shows the model its invalid output. Prompts are advisory. A model asked for "at most 280 characters" regularly writes 300. .. _adr-004-decision: Decision ======== 1. **One structured call per format.** Each text generator declares a JSON schema inside nr-llm's strict subset and calls ``completeStructured()`` through the same :php:`ConfiguredCompletionService` as every other text call, so the ``nr_repurpose_text`` configuration, the caller-source attribution and the budget middleware apply unchanged. The social posts for all three platforms come from one call. 2. **The generator validates again, for what a schema cannot say.** ``parse()`` trims, drops incomplete entries, caps lists (eight summary sentences, ten FAQ pairs, thirty hashtags) and enforces the platform limits by cutting at the last sentence end that fits (:php:`TextLimiter`). An answer that is unusable despite matching the schema raises :php:`InvalidLlmOutputException` with an editor-readable reason. 3. **Lower bounds stay in the prompt.** The prompts ask for five to eight summary sentences and five to ten FAQ pairs; the code enforces only the upper bounds. Rejecting a four-pair FAQ would fail the artifact for a thin source, and asking the model to reach five would invite invented content. 4. **Storage.** Every text row stores its plain-text rendering in ``script_text`` (what an editor copies) and the structured answer in ``metadata.content`` (what the result view renders). No FAL file is written. The social posts are one row per platform variant, like the Schaubild variants. 5. **Source material is data, not instructions.** The system prompt holds everything the extension decides: the role, the format's task, the output rules, the output language (only if it is a well-formed language code) and the editor's audience and tone snippets. The user prompt holds only the source-derived brief, enclosed in ```` and ```` and introduced as untrusted data that must not be followed. Inside the data, the ``<`` of every tag-like ``