ADR-076: Document understanding — native-first, rasterize fallback
- Status
-
Accepted
- Date
-
2026-07-17
- Authors
-
Netresearch DTT GmbH
Context
Two providers already implement DocumentCapableInterface (Gemini,
Claude): they accept a whole PDF as a Base64 document content block
in a single chat call and reason over it natively. Meanwhile
nr_ai_search built a PDF ingestion enrichment (its ADR-034) that
never uses that capability: it shells out to poppler (pdfimages /
pdftoppm), rasterizes image-bearing pages, and sends each page
through VisionServiceInterface. The renderer trio it wrote for that
— PdfRendererInterface, PopplerPdfRenderer,
PdfRenderingException — is consumer-agnostic: nothing in it knows
about ingestion, chunking, or degradation policy.
The 2026-07 downstream-extraction analysis (issue #416) decided the ingestion pipeline stays in the consumer, but that when nr_llm grows document understanding it must be designed around the existing native capability, with rasterization only as the compatibility fallback.
Decision
Netresearch\NrLlm\Specialized\Document\DocumentAnalysisService is
the stateless "understand this document" primitive:
analyzeDocument(string $pdf, string $prompt, ?ChatOptions $options).
Native path first. The service resolves the effective provider the
same way LlmServiceManager::chat() would (explicit per-call
provider, else the default configuration's provider type, else the
registry default). When that provider implements
DocumentCapableInterface and supports PDF, the whole document goes
into ONE chat call as a Base64 document block. Native ingestion is
preferred because the model reasons over the whole document — layout,
cross-page references, figures in context — at one call's latency and
attribution, instead of N per-page approximations stitched together.
Rasterization as fallback only. For providers without document
support, the PDF is rasterized page-by-page and each page is read by
VisionServiceInterface with the caller's prompt; the answers are
concatenated with [Page N] markers. The rasterizer is the ported
nr_ai_search renderer behind a new seam:
PdfRasterizerInterface— image-page inventory, single-page and whole-document rasterization to PNG blobs, availability probe.PopplerPdfRenderer— the poppler-backed implementation, ported essentially unchanged (array-argvproc_open, no shell parsing, temp-stub cleanup infinally), extended by the whole-documentrenderDocument()the fallback needs.PdfRasterizationException— the typed rasterization failure.
poppler is an optional system dependency. Declared in
composer.json suggest (poppler-utils); it is only needed
when the fallback runs. When rasterization is needed but the binaries
are absent, the service throws the typed, actionable
ServiceUnavailableException::rasterizerUnavailable() (install
poppler-utils, or configure a document-capable provider). When the
native path is taken, poppler is never touched.
What stays consumer-side, permanently: ingestion orchestration, enable/disable and cost-cap configuration flags, per-page degradation contracts ("a failed page keeps the plain-text extraction"), chunking and indexing. On the fallback path a failed page therefore fails the call — consumers own retry/degrade policy.
Public-service accounting (count authority)
DocumentAnalysisService joins Category D (Specialized standalone
consumer API) as a concrete public: true service; the
PdfRasterizerInterface alias stays private (DI autowiring only).
This supersedes ADR-071's count as the audited breakdown
(ADR-028 / ADR-065 process):
- Category A (documented downstream LLM-API contract): 16
- Category B (supporting-service interface aliases): 5
- Category C (concrete-only documented surface): 1
- Category D (Specialized standalone consumer API): 5 — Whisper, TextToSpeech, DallE, Fal, DocumentAnalysis
- Category E (resolved outside DI via
makeInstance()): 2
Total public: true overrides: 29.
Relation to ADR-050 and nr_ai_search
ADR-050 drew the retrieval boundary ("no consumer
pipeline in nr_llm"), and its reading so far kept all PDF-vision
processing consumer-side. This ADR supersedes that reading for the
stateless primitive only: "understand this document" is a capability on
the same footing as EmbeddingService::embed() — no persistent
state, no pipeline, no configuration flags. Everything pipeline-shaped
remains in nr_ai_search per ADR-050's stopping rule.
nr_ai_search will adopt PdfRasterizerInterface /
PopplerPdfRenderer (replacing its own copy) and amend its ADR-028
consumer-side in a follow-up change set — nothing in nr_llm depends on
that migration.
Consequences
- Consumers get whole-document reasoning in one call on Gemini/Claude
without writing provider-specific
documentblocks themselves. - The fallback keeps the feature working on every vision-capable provider, at per-page cost/latency and without cross-page reasoning.
- One more audited public service (29); the policy test locks the count against this ADR.
- nr_llm gains an optional system-binary dependency surface (poppler), but only on the fallback path and typed-failing when absent.