Skip to content

DocumentEnricher, PageEnricher and ChunkEnricher

The ports that summarise and tag one document, one page or one chunk, when a request asks.

Protocols:

  • DocumentEnricher.id: EnricherId
  • DocumentEnricher.enrich(text: str) -> Enrichment
  • DocumentEnricherProvider.document_enrichers() -> tuple[DocumentEnricher, ...]
  • PageEnricher.id: EnricherId
  • PageEnricher.enrich(text: str) -> Enrichment
  • PageEnricherProvider.page_enrichers() -> tuple[PageEnricher, ...]
  • ChunkEnricher.id: EnricherId
  • ChunkEnricher.enrich(text: str) -> Enrichment
  • ChunkEnricherProvider.chunk_enrichers() -> tuple[ChunkEnricher, ...]

An enricher says what a text says and what it is about: an Enrichment carrying a summary, prose in the text’s own language, and tags, LabelScore entries whose vocabulary is the enricher’s own rather than a taxonomy’s. Each part is optional. A part left out is “no opinion”, and an empty Enrichment() is a legitimate answer: nothing to say.

This is the third return shape on the annotation grid, beside labels and spans. A summary is not a label, because it is prose and not a choice from a set; a tag is not a facet’s label, because nothing enumerated it beforehand, and it is not a span, because the text need not contain the word. The unit axis is the classifier’s: one port per document, page and chunk, one class declarable through all three hooks.

The residue of model-backed enrichment once classification and entity extraction had their own ports was exactly what neither shape could carry. Stretching LabelScore to hold a sentence, or EntitySpan to hold a word the text does not contain, would have advertised summarisation while publishing something no client could read as one. ADR-0029’s revisit trigger named this case, a return shape that is neither labels nor spans, and this is the port it asked for (ADR-0037).

Both parts in one port, rather than a summariser and a tagger, because a model answers both in one call and a deployment asking for both should pay once. An implementation that can only give one part gives that part, and the request order decides who gives the other.

The input is the full text of one unit: every readable page joined in order for the document, one page’s text, or one chunk’s. An enricher bounds it itself (ADR-0033); indx_interfaces.excerpt is the shared head-of-text rule.

Each part is won separately. The first enabled enricher with a summary wins the summary for that unit; the first with tags wins the tags. A second enricher disagrees by answering a part the first left out, never by replacing it.

A ChunkEnricher requires chunk granularity, refused with a 422 before the fetch when the request did not ask for CHUNK. Unknown IDs and external implementations under data_residency refuse the same way, under invalid_enricher, with the classifier’s reasoning: a silently skipped enricher is an answer the caller believes was given and was not. IDs share one namespace with the classifier and extractor ports, eight in all.

A raise is logged and the unit skipped. device, cost_usd and builtin sit outside the protocol and are read with defaults.

  • Write the summary in the language the text is written in, and keep it prose. A blank summary is refused by the model; leave the part out instead.
  • Coin tags sparingly and rank them. A tag is a claim about subject matter, and its confidence is a self-report on LabelScore’s footing.
  • Leave a part out rather than emitting an empty one. tags=() and no tags are the same answer, and both let the next enricher speak.
  • Keep module scope cheap, and build the engine behind the first call.
  • Advertise nothing when unable to run: no extra, no model, no enricher.

DocumentExecutor.encode resolves the enabled IDs and checks residency before the fetch, reads the document, chunks it when chunk granularity was asked for, and asks each enabled enricher for each unit in request order.

A document enricher’s answer is written to the document block’s metadata under ENRICHMENT_METADATA_KEY (enrichment), as the Enrichment serialises with only the parts answered. A page enricher’s answer goes to each page block under the same key. A chunk enricher’s answers ride the document block under CHUNK_ENRICHMENT_METADATA_KEY (chunk_enrichment), keyed by chunk block ID, because chunk blocks carry no metadata (ADR-0031). Both keys are reserved; EncodeRequest.metadata refuses them outright.

The trace names each enricher that won a part, with the pages it answered on and the parts as its facets. POLICY_VERSION does not move, and neither does the capability snapshot ID: snapshot.enrichers is outside the content hash, so installing an enricher invalidates no outstanding plan.

Two ship. indx-enrich-extractive is the floor a default install carries: the sentences that carry most of the unit’s own vocabulary, verbatim and in document order, chosen by term frequency with a redundancy penalty so three near-copies do not make one summary, and no tags, because a tag is a word the text need not contain and coining one takes a model. indx-enrich-llm asks a chat model for both parts in one call, behind the llm extra and its own INDX_ENRICH_LLM_* prefix.