ADR 076 · Accepted
Document Understanding — Native-First, Rasterize Fallback
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-argv proc_open, no shell parsing, temp-stub cleanup in finally), extended by the whole-document renderDocument() 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.
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 document blocks 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.