Document enrichment

Maintainer, 2026-11-08: "connect ingestion in a way that it automatically tries to do ocr on pictures and get some description what is on it (put in node.description), try to create an icon (if it's a pic, try to create simplified variant, if it is text, try to create svg)" — then "if it is pdf, take it apart and extract tables and all", "also create summary in node.description", "and all content must be searchable", and "this ingestion of any blob content should be standard behaviour for any instance on the mac studio, not just control".

This page is the design of record. The indexing it builds on is Document ingestion and Content indexing coverage.

Where it runs

Enrichment is the second stage of indexing, on the instance that indexes the file. It is wired into both indexing paths:

Path Trigger Enriched node
A content collection (upload, a mail attachment saved to content, a synced or imported file) ContentIndexingService.IndexFile — after the Document node is written, and again on an unchanged re-index (the enrichment's own hash gate makes that a no-op once done, and catches up files indexed before the enrichment existed) {collection}/_Documents/{slug}
An attached storage container IngestDocument's parse, after its parts are indexed the IngestDocument node

It is on by default on every instance that has content indexing — the switch is ContentIndexing:Enrichment:Enabled (set it to false to turn it off for one instance). It never enqueues anywhere else: the work and the files stay on the instance, and on a Mac the models it calls are the Mac's own (below).

The line

DocumentEnricher.Enqueue puts the file into one serial line per process and returns at once. The line works one file at a time, so a burst of uploads never floods the model or the owner's machine; on a Mac Studio the instance's VM is already clamped to yield to the owner (Mac Studio attach runbook). The backlog is bounded at 2,000 files; a file over the bound is logged and caught up by its next re-index.

Progress is visible: every step writes the document's enrichment record ({document}/_Enrichment, node type DocumentEnrichment), and the document's page embeds its Progress view — status and current step, what was found, the per-step timings, every failure.

The steps

Step Image PDF Other text (docx, md, txt, mail)
text the indexer's caption + OCR the indexer's text (scanned pages already OCR'd by the indexer) the indexer's text
structure — PdfStructure.Decompose: sections, tables, figures (below) MarkdownStructure: headings → sections, pipe tables → tables
figures — each figure: description (IImageDescriber), OCR (IOcrProvider), simplified icon —
sections — each substantial section summarised (at most 24 model calls; the rest keep their opening sentences) same
nodes — DocumentSection / DocumentTable / DocumentFigure nodes under the document; an earlier version's extra parts removed sections and tables
description caption + "Text in the picture: …" a SUMMARY (below) a SUMMARY
icon the picture, simplified an SVG pictogram the model draws, sanitised same
apply description + icon onto the node, where allowed + the summary replaces the indexer's extractive one in the document body same

Taking a PDF apart

From the text layer, with the page and on-page box of everything found:

Scanned pages have no text layer: they are read from the indexer's OCR transcription of that page. The OCR prompt asks for tables as Markdown tables, so the same pipe-table parser finds them, and the transcription's Markdown headings split it into sections. Nothing else is needed on the host — there is no separate table extractor to install: the layout analysis is in-process (PdfPig), and the only model a scan needs is the OCR engine the instance declares.

Bounds: 60 tables, 24 figures, 80 sections per document; what is cut is recorded in the record's Notes.

The description

The icon

Never over a person's words

AutoFieldRule.MayWrite: an automatic value goes only into a field that is empty or still holds exactly the value the enrichment wrote last time (the record's AutoDescription / AutoIcon). Anything else was set by a person and is kept. The check runs inside the document owner's update, so an edit made while the model was thinking wins. The record says, for the description and the icon, whether the value on the node is automatic (DescriptionIsAutomatic, IconIsAutomatic) — that is the auto-generated marker the page shows. The same rule applies to each section, table and figure node's description. The marker lives on the record, not in the document's content, because a re-index replaces the document's content wholesale.

Idempotent

The record's ContentHash + PipelineVersion is the key: a document whose record carries its bytes' hash and the current version, Done or Partial, is skipped; a file already waiting in the line is not queued twice. Raising DocumentEnricher.PipelineVersion re-enriches every document once.

Best effort

Every step's failure is caught, logged ([Enrichment] {path}: step {step} failed) and recorded on the record (Errors, status Partial); the next step still runs. Enqueue never errors, and ContentIndexingService drops a throwing enricher with a warning — indexing never fails or waits for it.

Searchable

Every part is an ordinary mesh node, so it is found by both search kinds:

Embeddings come from the instance's declared embedding provider — on a Mac the local model (Hosting/MacStudioOnboarding → Local models). The enrichment adds no embedding path of its own.

Which models

Only the platform's abstractions, never a named provider:

Need Abstraction Registered by
Summaries, the SVG drawing EnrichmentChatModel → the instance's default chat model (DefaultChatClientProvider.TryCreate) the AI module
Picture captions IImageDescriber (the default multimodal chat model) the AI module
OCR IOcrProvider (the Ocr section the deployment declares) the indexing pipeline
Picture icons IImageIconRenderer (PosterizedIconRenderer) the indexing module
Embeddings IChunkEmbedder / the node embedding provider the deployment

On a Mac every one of these resolves to the Mac's own Ollama (the chat model with vision, the OCR engine OllamaVision, the local embedding model), declared by its onboarding; elsewhere to the configured providers. Every model call is ONE tool-free completion through the Http I/O pool with reasoning effort None, so a thinking model does not spend its budget reasoning about a summary.

The nodes

Node Path Content Page
DocumentSection {document}/Section-001 Title, Level, Page, Summary, Text summary, location, text
DocumentTable {document}/Table-001 Title, Caption, Page, Box, Columns, Rows (c0, c1, … per row), MergedCells, Source, Text, GridRows, GridTemplateColumns the framework DataGrid
DocumentFigure {document}/Figure-001 Title, Page, Box, Preview (≤ 512 KB), Description, OcrText the picture, description, text in it
DocumentEnrichment (satellite) {document}/_Enrichment hash, version, status, step, auto markers, counts, model, timings, errors, notes Progress, embedded in the document's page

Every page is a template that binds its node by path (Doc/GUI/DataBinding → Templates first, data later): nothing on the hub waits for the node and builds controls out of it. A template's grid columns are fixed, so a table's page declares 12 columns and binds the node's GridRows (the header as the first row, then the body; a wider table's further cells are joined into the last column) and its GridTemplateColumns (one share per used column, zero width for the rest). The document page's Enrichment view binds the record's Report, a Markdown rendering the enricher writes with every record update.

Tests

src/MeshWeaver.ContentCollections.Indexing.Test:

src/MeshWeaver.ContentCollections.Indexing.Graph.Test/DocumentEnrichmentTest on a real mesh: a PDF with a table becomes a summarised document with section and table nodes and a sanitised SVG icon; identical bytes are not enriched twice; a person's description survives a re-index of changed bytes; a photo gets its caption, its text's gist and a picture icon; a scanned PDF's table comes from OCR; a figure inside a PDF is its own described node; a failing model is recorded as Partial, never thrown.

Measured on a Mac

2026-11-08, the whole pipeline (LiveEnrichmentCheck: indexer + OCR + describer + enrichment on a real mesh, one file at a time as the line runs) against each Mac's own Ollama (eiger reached through a tunnel to its loopback Ollama). The same three files on both: a photo of a sign (42 KB JPEG), a 21-page report with 19 tables and a figure (367 KB PDF), and a scanned receipt (one image-only page, 50 KB PDF).

Mac Studio (qwen3.8:27b-mlx, bge-m3) MacBook Pro (qwen3.6, bge-m3)
photo index 3.5 s (caption + OCR), enrichment +1.0 s → 4.5 s 6.6 s + 1.0 s → 7.6 s
21-page report index 0.4 s, enrichment +46.0 s → 46.4 s (sections 34.8 s, description 4.5 s, SVG icon 3.0 s, figure 2.9 s) 0.5 s + 24.0 s → 24.5 s (sections 15.6 s)
scanned receipt index 2.6 s (page OCR), enrichment +14.0 s → 16.6 s 1.6 s + 4.0 s → 5.6 s

What came out, on both: the photo's description is the caption plus the sign's text, with a posterised icon. The report is taken apart into 21 sections, 19 tables and 1 figure; its summary (633–643 characters) names the parties, the dates and the tables, and it gets an SVG icon. The receipt's table (3 rows × 4 columns) comes from the OCR transcription. No step failed. A single section summary measured alone on the Mac Studio took 3.8 s (1,847 prompt tokens, 99 completion tokens). The section summaries dominate a long document, and they are bounded at 24 model calls per document. Reproduce with LiveEnrichmentCheck's class comment.

Not established here