Instruction file imported from dignite-projects/vault-extract (
.cursor/rules/project/text-extraction.mdc). Copyright stays with the author.
Text-Extraction Stack (Dignite Vault Extract)
Carried over from CLAUDE.md, auto-loaded when editing text-extraction / OCR / Markdown-provider code. CLAUDE.md keeps only the persistence-layer hard constraint of Markdown-first (
Documenthas only one text field,Markdown).
Text-extraction capability stack (three-layer contract + multiple providers) — the core pluggable point
Dignite.Vault.Extract.Parse— orchestrator + the defaultITextExtractorimplementation (DefaultTextExtractor: dispatch by file extension — images go to OCR; everything else goes to the matching Markdown provider, selected per file across coexisting providers byCanHandle(ext)+Priority, with whole-page OCR fallback when a PDF yields no meaningful text layer). Declares theIMarkdownTextProviderside-contract within the same projectDignite.Vault.Extract.Ocr— the minimal contract layer on the OCR-provider implementation side (IOcrProvider/OcrOptions/OcrResult, Markdown-first hard constraint). Third-party OCR integrations reference only this project and see neither the orchestrator nor theIMarkdownTextProviderside-contract- OCR provider implementations:
Dignite.Vault.Extract.Ocr.VisionLlm(Host's current default, #259: a multimodal vision LLM transcribes photos / thermal receipts / image-type PDFs, reusing the keyed visionIChatClientregistered by the host; PDFs are rasterized page by page via PDFium + SkiaSharp; depends only on theDignite.Vault.Extract.Ocrcontract layer),Dignite.Vault.Extract.Ocr.PaddleOcr(local sidecar, PP-StructureV3 runs fine on CPU, outputs Markdown), andDignite.Vault.Extract.Ocr.AzureDocumentIntelligence(cloud option, high accuracy);IOcrProvideris mutually exclusive — the Host enables exactly one of the three, and switching requires syncing both[DependsOn]and the.csproj ProjectReference(when the switch involves VisionLlm, also sync the hostConfigureAIkeyed visionIChatClientregistration) - Markdown provider implementations (coexist, dispatched per file by extension):
Dignite.Vault.Extract.Parse.Pdf(PdfExtractor, PdfPig) — owns.pdf; extracts the digital text layer and embedded raster images, transcribing each image through the host-selectedIOcrProvider(transcription only, no keyed VisionIChatClientat this layer, no new LLM call site — reachable because the module referencesParse→Ocr) and inlining the transcription into the Markdown at the image's reading position (#301). A PDF with no text layer returns empty so the orchestrator's whole-page OCR fallback owns it (no double OCR). Vector-only graphics are an accepted blind spot; dropped/undecodable images / truncated figure OCR / cap hits trip the #268 completeness signal. Figure output is inline-into-Markdown only —TextExtractionResultis unchanged (noFiguresfield)Dignite.Vault.Extract.Parse.ElBrunoMarkItDown(based on ElBruno.MarkItDotNet) — the catch-all fallback (CanHandlealways true,MarkdownProviderPriorities.Fallback), covering Word/HTML/plain-text/CSV/RTF/EPUB and.pdfwhen the Pdf module is not installed
- The asymmetry with OCR providers is deliberate — Markdown providers coexist and dispatch per file by extension (each self-declares
CanHandle(ext)+Priority; ElBruno is the catch-all, so omitting a specialized module degrades that extension gracefully), whereasIOcrProvideris host-selected and mutually exclusive (exactly one enabled viaDependsOn). The Markdown-provider contract stays close to the orchestrator (tightly coupled); OCR providers are more likely third-party (cloud services / local sidecars), so their separate thin contract layer gives them a stable boundary
Text-extraction provenance + native payload archiving (#210)
A text-extraction provider's native output (bbox / table cells / text-span anchors / confidence / region types and other out-of-band spatial signals) is an unreliable after-the-fact derivation (provider version drift → a re-run's bbox may not align with the Markdown already stored/chunked at the time); the only way to get aligned raw material is to capture it together with the Markdown at extraction time. Dignite Vault Extract captures and archives it at the channel layer accordingly, holding the boundary strictly:
- Native payload → blob, not into the DB:
NativePayload(Content+ContentType+SchemaName) hangs on the transportTextExtractionResult; the OCR provider passes it via flatOcrResultfields (NativePayloadContent/NativePayloadContentType/NativePayloadSchemaName), mapped byDefaultTextExtractor(the Ocr project does not reference Abstractions); pure text→Markdown providers (ElBruno/MarkItDown) have no spatial model and leave itnull. The text-extraction job archives it intoIBlobContainer<ExtractDocumentContainer>under the stable per-document keyextraction-native/{documentId}(re-extraction overwrites, one archive blob per document, no orphans). TheDocumenttext payload is still onlyMarkdown— raw bbox is never stuffed back into the Markdown string, nor relationalized into the DB. Document.ExtractionMetadata(DocumentParseMetadata?, a Domain typed value object → JSON column, going throughAbpJsonValueConverterlikeExportTemplate.Columns) — stores minimal provenance:ProviderName?(the winning provider family name) +NativePayloadManifest?(BlobName/ContentType/SizeBytes/Sha256/SchemaName). NoDictionary<string,object>bag (#206 principle). No first-classExtractionProviderNamecolumn, no path / step chain (zero consumption, cut in review). Plus an extraction-completeness quality signal (IsComplete+IncompleteReason?, #268, optional constructor parameters → old JSON missing the fields defaults to complete) — unlike provenance, this is exposed at the egress (see next bullet). Providers report it via the flatIsComplete/IncompleteReasonfields onOcrResult/TextExtractionResult(set false on VLM truncation / guard hit / PDF page loss; providers that do not set this signal default to true).- Archiving fails open: no payload / over the limit (
DocumentConsts.MaxNativePayloadArchiveBytes, default 16 MiB) / blob write failure → log a warning, set the manifest to null, and text extraction still succeeds — an auxiliary audit blob must never break the main Markdown pipeline. - The egress does not expose provenance, but does expose the completeness quality signal: the REST
DocumentDtoand MCP egress expose none of the internal provenance fields such as ExtractionMetadata / BlobName / ProviderName (without a download endpoint a payload summary is not actionable; BlobName is an internal storage key). Exception: extraction completeness is a downstream-actionable quality signal (not provenance) — the RESTDocumentDtoexposesExtractionIsComplete+ExtractionIncompleteReason?(#268, projected fromExtractionMetadatabyDocumentAppService.MapToDtoAsync, null metadata → complete); downstream decides on its own whether to accept / degrade / route to manual review, and the channel layer does not intercept on the downstream's behalf (it does not gate Ready). On permanent deletion, the archive blob is deleted together per the manifest'sBlobName. Document.Languagepersisted: writesTextExtractionResult.DetectedLanguageviaDocument.SetLanguage(...), ending the previously write-never dead field.- No Layer 3 this round (normalized
PageBlocks/ anchors / citation) — raw bbox stays in the blob and stops there.
Markdown-first engineering guidance (transport / provider layer)
The persistence-layer hard constraints (
Document.Markdownis the only text field, no parallel plain-text field /Dictionary<string,object>extension bag) are in CLAUDE.md, always in effect. This section is the engineering detail on the provider implementation side.
- OCR / digital-born extraction:
ITextExtractor/IMarkdownTextProvider/IOcrProviderimplementations must output Markdown and must not fall back to a plain-text path- For structured documents (contracts / policies / reports / CSV / titled DOCX / PP-StructureV3 / Azure DI prebuilt-layout) — headings, tables, and lists are real signals for downstream chunking and LLM understanding; exploit them fully
- For unstructured content (loose OCR paragraphs / plain txt / PP-OCRv4 line-level output / single-sentence notes) — Markdown is a container naming, not a signal gain; keeping the Markdown path is only so that the downstream chunker / built-in LLM classification / custom field extraction consume one single format. Be honest about this; do not dress up flat paragraphs as "also Markdown signal"
- Translation responsibility is fulfilled inside the provider —
OcrResult/TextExtractionResultexpose no RawText field; after obtaining the underlying service's plain-text output, the provider is itself responsible for wrapping it into flat Markdown (e.g.string.Join("\n\n", paragraphs)), and the plain-text-to-Markdown fallback logic must not leak to the upstream orchestrator
- Prompt expression: internal LLM system prompts explicitly state "the input is Markdown", letting the model exploit structural markup as a semantic signal
Markdown-first is an engineering default, not a philosophical principle. Markdown is the text payload, but out-of-band signals (coordinates / confidence / page metadata / form key-value structure / stamp position) are orthogonal to Markdown. If page-aware citations, signature/stamp localization, or form key-value extraction are needed in future, they should be named optional independent extension fields on TextExtractionResult (e.g. IReadOnlyList<PageBlock>? PageBlocks, nullable, decoupled from Markdown), or a separate extractor interface (orthogonal to ITextExtractor) — not blocked by a literal reading of "Markdown is the sole text payload".
- Forbidden pattern: adding a generic "extension slot" of type
Dictionary<string, object>/Dictionary<string, string>onTextExtractionResult— this is a code smell: unclear future types, casts everywhere on the consumer side, unfriendly to LLM-facing schema - Correct approach: open a separate Issue per out-of-band signal (it is an architecture decision), adding named, strongly-typed, nullable fields as needed; if the signal is strongly OCR-related and unrelated to the Markdown Provider, consider adding it on
OcrResultrather thanTextExtractionResultto avoid misplaced responsibility
Re-extraction / re-recognition policy: no in-place re-OCR (Markdown is write-once)
Document.Markdown is write-once — SetMarkdown throws MarkdownIsImmutable once set (the Markdown-first persistence invariant). This decides what "re-recognize from the original file" can and cannot mean:
- There is no in-place re-OCR of an already-extracted document, by design.
RetryPipelineAsync('text-extraction')only fires on a Failed parse run (no Markdown yet —EnsureRetryableAsync: onlyFailedis retryable); after parse succeeds, re-running it would hitMarkdownIsImmutable. There is no batch re-OCR entry either —DocumentReprocessingAppServiceexposes only reclassify + field re-extract, both running on the existing Markdown. - The legitimate "fresh OCR" path is re-upload — a new
Documentwith its own blob and pipeline run. In-place re-OCR is rejected because downstream records / chunks / citations derived from the stored Markdown would silently drift (a re-run's text / coordinates need not align with what was already stored and chunked — the same rationale as the #210 native-payload note). Different OCR output = a different document, not a mutation of the existing one. - Why no per-document "re-OCR" button: digital-born extraction is deterministic (re-run = byte-identical, zero gain); image OCR via the VLM is non-deterministic, so re-running is an undirected resample (can regress as easily as improve), not a fix. The real quality levers are (a) a better source → operator re-upload, and (b) OCR / provider params → the host deployment layer, never customer-facing (CLAUDE.md security/config constraint). So an operator-facing re-OCR button has ~zero expected value and fights the write-once invariant.
- Naming follows from this: the operator action that re-runs classification on existing Markdown is surfaced as "重新分类 / Re-classify" (not "重新识别 / Re-recognize"), so it does not imply re-scanning the original file. Internal identity is unchanged — API method
RerecognizeAsync(#263), localization keysDocument:Rerecognize*; display name ≠ internal name on purpose. - The one scenario with real value is a future host-side "batch re-OCR on stored blobs" (e.g. to re-pick-up tuned OCR params for existing documents) — and it is a channel-boundary change: it must first solve Markdown drift (emit a new version + downstream retract, not an in-place overwrite). STOP and open a GitHub Issue before writing code (tracked: #396).