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.

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.

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.

← All ADRs