.. 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 ``