Coverage for src/lilbee/data/extract/document.py: 100%
335 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Document extraction: one xberg pass that natively extracts text and OCRs
2scanned pages/images through the registered backend; chunk + embed the result."""
4from __future__ import annotations
6import asyncio
7import contextvars
8import logging
9import time
10from collections.abc import AsyncGenerator, Callable, Generator, Sequence
11from contextlib import asynccontextmanager, contextmanager
12from dataclasses import dataclass, replace
13from pathlib import Path
14from typing import TYPE_CHECKING, Any, Protocol
16from lilbee.app.services import get_services
17from lilbee.core.config import active_config
18from lilbee.core.config.enums import OcrMode, OcrPageStrategy
19from lilbee.core.web_names import is_web_source
20from lilbee.data.offload import to_ingest_thread
21from lilbee.data.store import ChunkType, PageTextRecord, SourceMeta
22from lilbee.data.title import derive_title, source_meta_from_extraction
23from lilbee.data.types import (
24 IMAGE_CONTENT_TYPE,
25 MARKDOWN_OUTPUT,
26 PDF_CONTENT_TYPE,
27 ChunkRecord,
28 DocumentRecords,
29 ExtractMode,
30 MemberRecords,
31 OcrBackendName,
32 OcrReport,
33)
34from lilbee.providers.base import aux_options
35from lilbee.runtime.cancellation import CancelSignal
36from lilbee.runtime.progress import (
37 DetailedProgressCallback,
38 EventType,
39 ExtractEvent,
40 OcrBackendUsed,
41 OcrStartEvent,
42 noop_callback,
43)
45from .backends.vision_ocr import backend_options_for, ocr_request
46from .batch import active_extract_batcher
47from .chunk import ChunkLimitError, build_chunking_config, chunk_text, enforce_chunk_limit
48from .trace import ExtractionTrace, trace_extraction, trace_log
50if TYPE_CHECKING:
51 from xberg import (
52 ExtractedDocument,
53 ExtractionConfig,
54 LayoutDetectionConfig,
55 OcrConfig,
56 OcrStrategy,
57 PdfConfig,
58 )
59 from xberg.progress import ProgressEvent as OcrPageEvent
61 from .batch import ExtractBatcher
63log = logging.getLogger(__name__)
66class _ExtractedTable(Protocol):
67 """The table fields lilbee indexes as dedicated chunks.
69 Structural, so it is satisfied by both xberg's public ``Table`` and the native
70 type that ``ExtractedDocument.tables`` actually yields.
71 """
73 @property
74 def markdown(self) -> str: ...
76 @property
77 def page_number(self) -> int: ...
80def content_type_to_mode(content_type: str) -> ExtractMode:
81 """Map a content_type to the extraction mode (paginated for PDFs and images)."""
82 if content_type in (PDF_CONTENT_TYPE, IMAGE_CONTENT_TYPE):
83 return ExtractMode.PAGINATED
84 return ExtractMode.MARKDOWN
87def _page_text_record(source: str, page: int, text: str, content_type: str) -> PageTextRecord:
88 """Build one per-page text row for the export dataset."""
89 return PageTextRecord(source=source, page=page, text=text, content_type=content_type)
92_ocr_mode_override: contextvars.ContextVar[OcrMode | None] = contextvars.ContextVar(
93 "lilbee_ocr_mode_override", default=None
94)
95_ocr_timeout_override: contextvars.ContextVar[float | None] = contextvars.ContextVar(
96 "lilbee_ocr_timeout_override", default=None
97)
100# Per-file title for embedding input; a ContextVar (like the OCR overrides) so the
101# call chains need no signature threading and concurrent ingests don't leak titles.
102_embed_title: contextvars.ContextVar[str] = contextvars.ContextVar("lilbee_embed_title", default="")
105@contextmanager
106def _title_scope(title: str) -> Generator[None, None, None]:
107 token = _embed_title.set(title or "")
108 try:
109 yield
110 finally:
111 _embed_title.reset(token)
114def _embed_inputs(texts: list[str], title: str | None = None) -> list[str]:
115 """Embedding inputs, title-prefixed when ``cfg.embed_titles`` is on.
117 Only the vector sees the title; the stored chunk text is unchanged.
118 ``None`` falls back to the scoped per-file title (the OCR chains).
119 """
120 effective = title if title is not None else _embed_title.get()
121 if not effective or not active_config().embed_titles:
122 return texts
123 return [f"{effective}\n{text}" for text in texts]
126# Contextual enrichment: characters of document head shown to the model, and
127# the reply budget for the one situating sentence.
128_ENRICH_HEAD_CHARS = 2000
129_ENRICH_CHUNK_CHARS = 2000
130_ENRICH_MAX_TOKENS = 60
131_ENRICH_PROMPT = (
132 "Document beginning:\n{head}\n\nChunk from the same document:\n{chunk}\n\n"
133 "Write one short sentence situating this chunk within the document, to "
134 "improve search retrieval of the chunk. Answer with only the sentence."
135)
138def _enrich_texts(texts: list[str], doc_head: str, source_name: str) -> list[str]:
139 """Embedding inputs with one LLM-written situating sentence per chunk.
141 Anthropic-style contextual retrieval, opt-in (``cfg.contextual_enrichment``):
142 one generation per chunk, so ingest slows accordingly. Only the vector sees
143 the sentence; stored chunk text and citations stay verbatim. Any failure
144 keeps that chunk's bare text.
145 """
146 if not active_config().contextual_enrichment or not texts:
147 return texts
148 from lilbee.retrieval.reasoning import strip_reasoning
150 provider = get_services().provider
151 head = doc_head[:_ENRICH_HEAD_CHARS]
152 enriched: list[str] = []
153 failed = 0
154 for text in texts:
155 prompt = _ENRICH_PROMPT.format(head=head, chunk=text[:_ENRICH_CHUNK_CHARS])
156 try:
157 response = provider.chat(
158 [{"role": "user", "content": prompt}],
159 stream=False,
160 options=aux_options(_ENRICH_MAX_TOKENS),
161 )
162 lines = strip_reasoning(response.text).strip().splitlines()
163 sentence = lines[0].strip() if lines else ""
164 except Exception:
165 failed += 1
166 sentence = ""
167 enriched.append(f"{sentence}\n{text}" if sentence else text)
168 if failed:
169 log.warning(
170 "Contextual enrichment failed for %d of %d chunks in %s; those embed bare",
171 failed,
172 len(texts),
173 source_name,
174 )
175 return enriched
178def _effective_ocr_mode() -> OcrMode:
179 """``cfg.ocr`` unless a per-request OCR override is active.
181 The override is a ContextVar, not a global cfg mutation, so concurrent
182 ingests on the shared HTTP daemon each see their own setting.
183 """
184 override = _ocr_mode_override.get()
185 return active_config().ocr if override is None else override
188def _extraction_timeout_secs() -> int | None:
189 """``cfg.extraction_timeout`` as xberg's per-file cap; None when uncapped.
191 xberg defaults this to 600s. Passing lilbee's own value on every call keeps
192 the cap something a user can see and raise instead of an inherited default.
193 """
194 timeout = active_config().extraction_timeout
195 return timeout if timeout > 0 else None
198def _effective_ocr_timeout() -> float:
199 """``cfg.ocr_timeout`` unless a per-request OCR timeout override is active."""
200 override = _ocr_timeout_override.get()
201 return active_config().ocr_timeout if override is None else override
204@contextmanager
205def ocr_override(
206 ocr: OcrMode | None = None, ocr_timeout: float | None = None
207) -> Generator[None, None, None]:
208 """Scope per-request OCR settings without mutating the global cfg.
210 A ``None`` argument leaves that setting at its cfg default. Each override is
211 isolated to the entering context, so overlapping ingests never clobber one
212 another's OCR config.
213 """
214 tokens: list[tuple[contextvars.ContextVar[Any], contextvars.Token[Any]]] = []
215 try:
216 if ocr is not None:
217 tokens.append((_ocr_mode_override, _ocr_mode_override.set(ocr)))
218 if ocr_timeout is not None:
219 tokens.append((_ocr_timeout_override, _ocr_timeout_override.set(ocr_timeout)))
220 yield
221 finally:
222 for var, token in reversed(tokens):
223 var.reset(token)
226def ocr_backend() -> OcrBackendUsed:
227 """The OCR backend for this extraction: none when OCR is off, else vision or Tesseract."""
228 return OcrBackendUsed.chosen(_effective_ocr_mode(), active_config().vision_model)
231def _ocr_config(ocr_token: str | None) -> OcrConfig | None:
232 """xberg's OcrConfig for the backend ``ocr_backend`` picks, or None when OCR is off.
234 xberg OCRs each page without usable text; a page with usable text keeps it.
235 """
236 from xberg import OcrConfig
238 config = active_config()
239 backend = ocr_backend()
240 if backend is OcrBackendUsed.NONE:
241 return None
242 if backend is OcrBackendUsed.VISION:
243 options = backend_options_for(ocr_token) if ocr_token else None
244 return OcrConfig(
245 backend=OcrBackendName.LILBEE_VISION,
246 backend_options=options,
247 )
248 # xberg requires a non-empty language list (4.x defaulted to English;
249 # xberg 1.0 errors on an empty one). cfg.ocr_language is validated non-empty.
250 return OcrConfig(
251 backend=OcrBackendName.TESSERACT,
252 language=list(config.ocr_language),
253 )
256def _ocr_strategy() -> OcrStrategy:
257 """xberg's page-selection strategy for cfg.ocr_strategy; auto when OCR is off."""
258 from xberg import OcrStrategy
260 config = active_config()
261 # xberg rejects scanned_pages when OCR is disabled.
262 if ocr_backend() is OcrBackendUsed.NONE or config.ocr_strategy is OcrPageStrategy.AUTO:
263 return OcrStrategy(OcrPageStrategy.AUTO.value)
264 return OcrStrategy.scanned_pages(config.ocr_scan_confidence)
267def _force_ocr_pages() -> list[int] | None:
268 """cfg.force_ocr_pages for xberg; None when empty or OCR is off."""
269 if ocr_backend() is OcrBackendUsed.NONE:
270 return None
271 return list(active_config().force_ocr_pages) or None
274# Header/footer band stripped when layout detection is on: outermost 5%.
275_TOP_MARGIN_FRACTION = 0.05
276_BOTTOM_MARGIN_FRACTION = 0.05
279def _pdf_options() -> PdfConfig | None:
280 """PdfConfig for the enabled opt-in features (tables, layout), or None when all off."""
281 config = active_config()
282 if not (config.table_extraction or config.layout_detection):
283 return None
284 from xberg import PdfConfig
286 kwargs: dict[str, Any] = {}
287 if config.table_extraction:
288 kwargs["extract_tables"] = True
289 if config.layout_detection:
290 kwargs.update(
291 reading_order=True,
292 top_margin_fraction=_TOP_MARGIN_FRACTION,
293 bottom_margin_fraction=_BOTTOM_MARGIN_FRACTION,
294 )
295 return PdfConfig(**kwargs)
298def warn_if_table_model_ignored() -> None:
299 """Warn when table extraction runs with layout_detection off: xberg only
300 applies table_model inside layout detection, so the model is silently ignored.
301 """
302 config = active_config()
303 if config.table_extraction and not config.layout_detection:
304 log.warning(
305 "table_model=%s is ignored while layout_detection is off: tables use "
306 "the native extractor, not the structure model. Enable layout_detection "
307 "to apply the table model.",
308 config.table_model.value,
309 )
312def _layout_config() -> LayoutDetectionConfig | None:
313 """AUTO-strategy layout config when enabled, else None."""
314 config = active_config()
315 if not config.layout_detection:
316 return None
317 from xberg import LayoutDetectionConfig, LayoutStrategy
319 return LayoutDetectionConfig(strategy=LayoutStrategy.AUTO, table_model=config.table_model)
322def extraction_config(mode: ExtractMode, *, ocr_token: str | None = None) -> ExtractionConfig:
323 """Build ExtractionConfig for the given extraction mode."""
324 from xberg import ExtractionConfig, PageConfig
326 # Files are extracted one per call; xberg parallelizes OCR across a document's
327 # pages internally, and cross-file concurrency is the pipeline's semaphore.
328 chunking = build_chunking_config()
329 ocr = _ocr_config(ocr_token)
330 # OCR off sends no OCR block and sets disable_ocr; xberg auto-OCRs a textless PDF otherwise.
331 disable_ocr = ocr is None
332 # ocr = all defeats xberg's text-layer short-circuit for either engine; xberg
333 # rejects force_ocr together with disable_ocr.
334 force_ocr = ocr is not None and _effective_ocr_mode() is OcrMode.ALL
335 if mode is ExtractMode.PAGINATED:
336 paginated = ExtractionConfig(
337 chunking=chunking,
338 pages=PageConfig(extract_pages=True, insert_page_markers=False),
339 ocr=ocr,
340 disable_ocr=disable_ocr,
341 force_ocr=force_ocr,
342 pdf_options=_pdf_options(),
343 extraction_timeout_secs=_extraction_timeout_secs(),
344 ocr_strategy=_ocr_strategy(),
345 force_ocr_pages=_force_ocr_pages(),
346 )
347 # The layout fields keep xberg's defaults when layout detection is off.
348 layout = _layout_config()
349 if layout is None:
350 return paginated
351 return replace(paginated, layout=layout)
352 return ExtractionConfig(
353 chunking=chunking,
354 output_format=MARKDOWN_OUTPUT,
355 ocr=ocr,
356 disable_ocr=disable_ocr,
357 force_ocr=force_ocr,
358 extraction_timeout_secs=_extraction_timeout_secs(),
359 ocr_strategy=_ocr_strategy(),
360 force_ocr_pages=_force_ocr_pages(),
361 )
364def analysis_config() -> ExtractionConfig:
365 """Native extraction for corpus analysis: no OCR, no layout model, tables and languages on."""
366 from xberg import ExtractionConfig, LanguageDetectionConfig, PageConfig, PdfConfig
368 # No OCR block at all: xberg 1.2.7 OCRs a PDF's page images whenever one is present,
369 # even disabled (fixed in 1.2.9).
370 return ExtractionConfig(
371 disable_ocr=True,
372 pages=PageConfig(extract_pages=True),
373 pdf_options=PdfConfig(extract_tables=True),
374 language_detection=LanguageDetectionConfig(enabled=True, detect_multiple=True),
375 extraction_timeout_secs=_extraction_timeout_secs(),
376 )
379def make_extract_batcher() -> ExtractBatcher | None:
380 """The extraction batcher for this ingest run, or None when batching is off."""
381 config = active_config()
382 if not config.batch_extraction:
383 return None
384 from .batch import ExtractBatcher
385 from .xberg import aextract_batch
387 return ExtractBatcher(
388 size=config.batch_extraction_size,
389 config_fn=extraction_config,
390 ocr_fn=_ocr_config,
391 batch_fn=aextract_batch,
392 )
395@asynccontextmanager
396async def extract_batching() -> AsyncGenerator[None]:
397 """Activate extraction batching for the enclosed ingest, when the toggle is on.
399 The batcher is set before the block runs so the ingest tasks created inside it
400 inherit it in their copied context; off (the default) is a no-op.
401 """
402 batcher = make_extract_batcher()
403 if batcher is None:
404 yield
405 return
406 from .batch import reset_active_batcher, set_active_batcher
408 token = set_active_batcher(batcher)
409 try:
410 yield
411 finally:
412 await batcher.close()
413 reset_active_batcher(token)
416def _chunk_pages(page_texts: Sequence[tuple[int, str]]) -> list[tuple[int, str]]:
417 """Chunk each page's text. Semantic chunking is off: a single page rarely spans
418 multiple topics, so the semantic round-trip is not worth it."""
419 return [
420 (page_num, chunk)
421 for page_num, text in page_texts
422 for chunk in chunk_text(text, use_semantic=False)
423 ]
426async def chunk_and_embed_pages(
427 page_texts: Sequence[tuple[int, str]],
428 source_name: str,
429 content_type: str,
430 on_progress: DetailedProgressCallback,
431) -> list[ChunkRecord]:
432 """Chunk per-page text and embed every chunk. Used by the dataset import path."""
433 if not page_texts:
434 return []
436 # chunk_text runs xberg's synchronous extractor; offload it so a long
437 # document does not stall sibling files sharing this event loop.
438 all_chunks = await to_ingest_thread(_chunk_pages, page_texts)
439 if not all_chunks:
440 return []
441 texts = [c for _, c in all_chunks]
442 embed_texts = await to_ingest_thread(_enrich_texts, texts, page_texts[0][1], source_name)
443 vectors = await to_ingest_thread(
444 get_services().embedder.embed_batch,
445 _embed_inputs(embed_texts),
446 source=source_name,
447 on_progress=on_progress,
448 )
449 return [
450 ChunkRecord(
451 source=source_name,
452 content_type=content_type,
453 chunk_type=ChunkType.RAW,
454 page_start=page_num,
455 page_end=page_num,
456 line_start=0,
457 line_end=0,
458 chunk=text,
459 chunk_index=i,
460 vector=vec,
461 )
462 for i, ((page_num, text), vec) in enumerate(zip(all_chunks, vectors, strict=True))
463 ]
466def _capture_result_page_texts(
467 doc: ExtractedDocument,
468 source_name: str,
469 content_type: str,
470 page_texts_out: list[PageTextRecord] | None,
471) -> None:
472 """Append an extraction's page texts to the export accumulator.
474 Paginated documents yield one row per ``doc.pages`` entry; others have no
475 page split, so the full ``doc.content`` is recorded as page 0.
476 """
477 if page_texts_out is None:
478 return
479 if doc.pages:
480 page_texts_out.extend(
481 _page_text_record(source_name, page.page_number, page.content, content_type)
482 for page in doc.pages
483 )
484 elif doc.content.strip():
485 page_texts_out.append(_page_text_record(source_name, 0, doc.content, content_type))
488def _document_tables(doc: ExtractedDocument) -> list[_ExtractedTable]:
489 """The result tables to index as dedicated chunks, when table extraction is on.
491 Each table becomes its own chunk carrying xberg's markdown serialization and
492 page metadata. The table's flattened text also stays inside the content
493 chunks: stripping it there would tear holes in reading-order prose and the
494 page-text export, so both are indexed deliberately. The dedicated table
495 chunk adds the structured serialization for targeted retrieval.
496 """
497 if not active_config().table_extraction:
498 return []
499 return [t for t in (doc.tables or []) if t.markdown and t.markdown.strip()]
502def _warn_empty_ocr(source_name: str, media: str, backend: OcrBackendUsed) -> None:
503 """Warn that extraction yielded no text, with advice that matches the backend that ran."""
504 if backend is OcrBackendUsed.NONE:
505 log.warning(
506 "Skipped %s: text extraction produced no usable text. "
507 "OCR is off (ocr = off); set ocr to auto to read %s.",
508 source_name,
509 media,
510 )
511 return
512 if backend is OcrBackendUsed.VISION:
513 log.warning(
514 "Skipped %s: the vision model returned no usable text for %s.",
515 source_name,
516 media,
517 )
518 return
519 log.warning(
520 "Skipped %s: text extraction produced no usable text. "
521 "For better results on %s, configure a vision model via PUT /api/models/vision.",
522 source_name,
523 media,
524 )
527def _warn_skipped_scanned_pages(doc: ExtractedDocument, source_name: str) -> None:
528 """Warn that OCR being off left the scanned pages of an indexed PDF unread."""
529 skipped = _scanned_pages(doc)
530 if skipped:
531 log.warning(
532 "Indexed %s without its scanned pages %s: OCR is off (ocr = off). "
533 "Set ocr to auto to read them.",
534 source_name,
535 ", ".join(str(page) for page in skipped),
536 )
539async def ingest_document(
540 path: Path,
541 source_name: str,
542 content_type: str,
543 *,
544 quiet: bool = False,
545 on_progress: DetailedProgressCallback = noop_callback,
546 page_texts_out: list[PageTextRecord] | None = None,
547 cancel: CancelSignal | None = None,
548) -> DocumentRecords:
549 """Extract, chunk, and embed a document in a single xberg pass, with its metadata.
551 xberg extracts native text and, where a page has none, OCRs it through the
552 registered backend (lilbee's vision model, or tesseract). xberg reports each
553 OCR'd page of a PDF or image as it completes, and a scanned file under Tesseract
554 also gets one OCR_START event before its extraction. ``quiet`` is accepted for
555 pipeline call compatibility. The returned metadata carries the document's
556 extraction title/authors/date and is derived even when extraction yields nothing;
557 the OCR report says which backend the extraction ran and how many pages it OCR'd.
558 """
559 del quiet
560 doc, ocr = await _extract_document(
561 path, source_name, content_type, content_type_to_mode(content_type), on_progress, cancel
562 )
563 records, meta = await _records_from_document(
564 doc,
565 source_name,
566 content_type,
567 on_progress=on_progress,
568 page_texts_out=page_texts_out,
569 ocr_backend=ocr.backend,
570 )
571 return DocumentRecords(records, meta, ocr)
574async def ingest_archive(
575 path: Path,
576 source_name: str,
577 content_type: str,
578 *,
579 on_progress: DetailedProgressCallback = noop_callback,
580 cancel: CancelSignal | None = None,
581) -> list[MemberRecords]:
582 """Extract an archive once and build records for every member, nested archives included.
584 The archive itself contributes no chunks. Each member is its own source named
585 ``<archive>/<member path>``. xberg unpacks to ``max_archive_depth`` under its
586 zip-bomb limits, so depth and size are enforced before this runs.
587 """
588 doc, ocr = await _extract_document(
589 path, source_name, content_type, ExtractMode.PAGINATED, on_progress, cancel
590 )
591 members: list[MemberRecords] = []
592 await _collect_members(doc, source_name, members, on_progress, ocr.backend)
593 return members
596async def _collect_members(
597 doc: ExtractedDocument,
598 prefix: str,
599 members: list[MemberRecords],
600 on_progress: DetailedProgressCallback,
601 ocr_backend: OcrBackendUsed,
602) -> None:
603 # circular: document -> ingest.discovery via the ingest package's pipeline import
604 from lilbee.data.ingest.discovery import archive_content_types, classify_file
606 unsupported: list[str] = []
607 for entry in doc.children or []:
608 name = f"{prefix}/{entry.path}"
609 # The same gate discovery applies on disk: a member of an unsupported format
610 # is never chunked, so an archive of machine output cannot flood the index.
611 content_type = classify_file(Path(entry.path))
612 if content_type is None:
613 unsupported.append(entry.path)
614 continue
615 if content_type in archive_content_types():
616 await _collect_members(entry.result, name, members, on_progress, ocr_backend)
617 continue
618 page_texts: list[PageTextRecord] = []
619 try:
620 records, meta = await _records_from_document(
621 entry.result,
622 name,
623 content_type,
624 on_progress=on_progress,
625 page_texts_out=page_texts,
626 ocr_backend=ocr_backend,
627 )
628 except ChunkLimitError as exc:
629 raise ChunkLimitError(exc.count, exc.limit, member=name) from None
630 members.append(MemberRecords(name, content_type, records, page_texts, meta))
631 if unsupported:
632 log.info(
633 "Skipped %d member(s) of %s, unsupported format: %s",
634 len(unsupported),
635 prefix,
636 ", ".join(sorted(unsupported)),
637 )
640def _page_count_config() -> ExtractionConfig:
641 """A metadata-only ExtractionConfig: OCR and page bodies off, structure kept."""
642 from xberg import ExtractionConfig, PageConfig
644 # The pass is metadata-only: no OCR block, disable_ocr set, no page bodies.
645 return ExtractionConfig(
646 pages=PageConfig(extract_pages=False, insert_page_markers=False),
647 disable_ocr=True,
648 enable_quality_processing=False,
649 )
652@dataclass(frozen=True)
653class _PageProbe:
654 """What the metadata-only pass read: the page count and whether any page is a scan."""
656 pages: int = 0
657 has_scanned_pages: bool = False
660def _scanned_pages(doc: ExtractedDocument) -> list[int]:
661 """The PDF pages xberg found with no usable text layer."""
662 fmt = doc.metadata.format
663 pdf = fmt.pdf if fmt is not None else None
664 return list(pdf.scanned_pages or []) if pdf is not None else []
667async def _probe_pages(data: bytes, filename: str) -> _PageProbe:
668 """Read *data*'s page count and scanned pages in a metadata-only xberg pass.
670 Costs a second structural parse of *data* with OCR and page bodies both off.
671 Any failure here returns an empty probe rather than raising, so the caller's
672 real extraction still runs and reports its own error.
673 """
674 from .xberg import aextract_document
676 try:
677 doc = await aextract_document(data, filename=filename, config=_page_count_config())
678 except Exception:
679 log.debug("Page-count probe failed for %s; its OCR start is not announced", filename)
680 return _PageProbe()
681 return _PageProbe(pages=doc.counts.pages, has_scanned_pages=bool(_scanned_pages(doc)))
684async def _announce_tesseract_ocr(
685 data: bytes,
686 filename: str,
687 source_name: str,
688 content_type: str,
689 on_progress: DetailedProgressCallback,
690) -> None:
691 """Emit OCR_START when Tesseract will OCR a scanned page of this PDF."""
692 if ocr_backend() is not OcrBackendUsed.TESSERACT or not _has_own_pages(content_type):
693 return
694 probe = await _probe_pages(data, filename)
695 if probe.has_scanned_pages:
696 on_progress(EventType.OCR_START, OcrStartEvent(file=source_name, total_pages=probe.pages))
699def _has_own_pages(content_type: str) -> bool:
700 """Whether the file is itself paginated (a PDF or an image), which an archive is not."""
701 # The content type, not the extraction mode: ingest_archive always requests PAGINATED.
702 return content_type_to_mode(content_type) is ExtractMode.PAGINATED
705def _ocr_page_reporter(
706 source_name: str, backend: OcrBackendUsed, on_progress: DetailedProgressCallback
707) -> Callable[[OcrPageEvent], None]:
708 """An xberg progress callback that reports each OCR'd page as an EXTRACT event.
710 ``completed`` counts distinct pages, so a page xberg sends to the backend
711 more than once still counts once. xberg calls this from a worker thread.
712 """
714 def _report(event: OcrPageEvent) -> None:
715 on_progress(
716 EventType.EXTRACT,
717 ExtractEvent(
718 file=source_name,
719 page=event.completed,
720 total_pages=event.total,
721 ocr_backend=backend,
722 ),
723 )
725 return _report
728async def _extract_document(
729 path: Path,
730 source_name: str,
731 content_type: str,
732 mode: ExtractMode,
733 on_progress: DetailedProgressCallback,
734 cancel: CancelSignal | None,
735) -> tuple[ExtractedDocument, OcrReport]:
736 """Run one xberg pass over *path*, with per-page OCR progress and the extraction trace.
738 A set *cancel* stops vision OCR before its next page, and a pass that
739 ends under a set *cancel* raises ``CancelledError`` instead of returning a
740 partial document.
741 """
742 from .xberg import aextract_document
744 data = path.read_bytes()
745 await _announce_tesseract_ocr(data, path.name, source_name, content_type, on_progress)
746 backend = ocr_backend()
747 # An archive gets no reporter: xberg counts its pages across members, each total per member.
748 on_page = (
749 _ocr_page_reporter(source_name, backend, on_progress)
750 if _has_own_pages(content_type)
751 else None
752 )
754 trace_log.debug("extract-start source=%r type=%s", source_name, content_type)
755 started = time.perf_counter()
756 with ocr_request(timeout=_effective_ocr_timeout(), cancel=cancel) as token:
757 batcher = active_extract_batcher()
758 if batcher is not None:
759 doc = await batcher.submit(mode, data, path.name, token, on_page)
760 else:
761 config = extraction_config(mode, ocr_token=token)
762 # xberg's extract is async; awaiting it keeps the OCR page loop off this thread.
763 doc = await aextract_document(
764 data, filename=path.name, config=config, on_progress=on_page
765 )
766 if cancel is not None and cancel.is_set():
767 raise asyncio.CancelledError
768 elapsed = time.perf_counter() - started
769 ocr = OcrReport(backend=backend, pages=_ocr_page_count(doc))
771 # One trace line per extraction (filename, timing, counts, OCR pages), plus a
772 # vision line for scanned files. Emitted for empty results too (a slow file
773 # that yields nothing is worth surfacing).
774 trace_extraction(
775 ExtractionTrace(
776 source=source_name,
777 content_type=content_type,
778 elapsed_s=elapsed,
779 page_count=len(doc.pages or []) or len(doc.chunks or []),
780 chunk_count=len(doc.chunks or []),
781 ocr=ocr,
782 )
783 )
785 return doc, ocr
788def _ocr_page_count(doc: ExtractedDocument) -> int:
789 """Pages xberg OCR'd, which are the pages it attaches an OCR confidence to."""
790 return sum(1 for page in doc.pages or [] if page.ocr_confidence is not None)
793async def _records_from_document(
794 doc: ExtractedDocument,
795 source_name: str,
796 content_type: str,
797 *,
798 on_progress: DetailedProgressCallback,
799 page_texts_out: list[PageTextRecord] | None,
800 ocr_backend: OcrBackendUsed,
801) -> tuple[list[ChunkRecord], SourceMeta]:
802 """Chunk-cap, page-capture, and embed one extracted document into its records."""
803 # Derived before the empty-result return so a scan's title/authors survive zero chunks.
804 meta = source_meta_from_extraction(doc.metadata, source_name)
806 tables = _document_tables(doc)
807 if not doc.chunks and not tables:
808 if content_type in (PDF_CONTENT_TYPE, IMAGE_CONTENT_TYPE):
809 _warn_empty_ocr(source_name, "scanned documents", ocr_backend)
810 return [], meta
812 if ocr_backend is OcrBackendUsed.NONE:
813 _warn_skipped_scanned_pages(doc, source_name)
814 enforce_chunk_limit(len(doc.chunks or []) + len(tables))
815 _capture_result_page_texts(doc, source_name, content_type, page_texts_out)
817 # One EXTRACT event per file so progress subscribers show "extracted N pages"
818 # before embedding; result.pages, or the chunk count for non-paginated docs.
819 page_count = len(doc.pages or []) or len(doc.chunks or [])
820 ran = ocr_backend if _ocr_page_count(doc) else OcrBackendUsed.NONE
821 on_progress(
822 EventType.EXTRACT,
823 ExtractEvent(file=source_name, page=page_count, total_pages=page_count, ocr_backend=ran),
824 )
826 # Content chunks and table serializations share one embed batch; the vector
827 # list is split back apart below by position.
828 texts = [chunk.content for chunk in doc.chunks or []]
829 table_texts = [table.markdown for table in tables]
830 embed_texts = await to_ingest_thread(
831 _enrich_texts, texts + table_texts, texts[0] if texts else "", source_name
832 )
833 vectors = await to_ingest_thread(
834 get_services().embedder.embed_batch,
835 _embed_inputs(embed_texts, meta.title),
836 source=source_name,
837 on_progress=on_progress,
838 )
839 records = [
840 ChunkRecord(
841 source=source_name,
842 content_type=content_type,
843 chunk_type=ChunkType.RAW,
844 page_start=chunk.metadata.first_page or 0,
845 page_end=chunk.metadata.last_page or 0,
846 line_start=0,
847 line_end=0,
848 chunk=text,
849 chunk_index=chunk.metadata.chunk_index,
850 vector=vec,
851 )
852 for chunk, text, vec in zip(doc.chunks or [], texts, vectors[: len(texts)], strict=True)
853 ]
854 # Table chunk indices continue after the content chunks so a source's
855 # (source, chunk_index) pairs stay unique.
856 records.extend(
857 ChunkRecord(
858 source=source_name,
859 content_type=content_type,
860 chunk_type=ChunkType.TABLE,
861 page_start=table.page_number,
862 page_end=table.page_number,
863 line_start=0,
864 line_end=0,
865 chunk=text,
866 chunk_index=len(texts) + i,
867 vector=vec,
868 )
869 for i, (table, text, vec) in enumerate(
870 zip(tables, table_texts, vectors[len(texts) :], strict=True)
871 )
872 )
873 return records, meta
876def _markdown_h1(text: str) -> str | None:
877 """The document's leading ``# Heading``, the best title a note carries.
879 Only a top-level ATX heading counts; ``##`` and deeper are sections, not the
880 document title. None when the note opens without one.
881 """
882 for line in text.splitlines():
883 stripped = line.strip()
884 if not stripped:
885 continue
886 if stripped.startswith("# "):
887 return stripped[2:].strip() or None
888 return None
889 return None
892async def ingest_markdown(
893 path: Path,
894 source_name: str,
895 on_progress: DetailedProgressCallback = noop_callback,
896 page_texts_out: list[PageTextRecord] | None = None,
897) -> tuple[list[ChunkRecord], SourceMeta]:
898 """Chunk a markdown file with heading context prepended to each chunk.
900 Each chunk gets the heading hierarchy path (e.g. "# Setup > ## Install")
901 prepended for better retrieval context. When ``page_texts_out`` is given,
902 the full text is appended as page 0 for export. The returned metadata's
903 title is a crawled page's own title, else the note's leading ``# Heading``,
904 else the stem.
905 """
906 raw_text = await to_ingest_thread(path.read_text, encoding="utf-8", errors="replace")
907 page_title = (
908 get_services().store.crawl_pages.titles().get(source_name)
909 if is_web_source(source_name)
910 else None
911 )
912 meta = SourceMeta(title=derive_title(source_name, page_title or _markdown_h1(raw_text)))
913 if not raw_text.strip():
914 return [], meta
916 # chunk_text runs xberg's synchronous extractor; offload it so a large
917 # markdown doc does not stall sibling files sharing this event loop.
918 texts = await to_ingest_thread(
919 chunk_text, raw_text, mime_type="text/markdown", heading_context=True
920 )
921 if not texts:
922 return [], meta
924 enforce_chunk_limit(len(texts))
925 if page_texts_out is not None:
926 page_texts_out.append(_page_text_record(source_name, 0, raw_text, "text"))
928 embed_texts = await to_ingest_thread(_enrich_texts, texts, raw_text, source_name)
929 vectors = await to_ingest_thread(
930 get_services().embedder.embed_batch,
931 _embed_inputs(embed_texts, meta.title),
932 source=source_name,
933 on_progress=on_progress,
934 )
935 records = [
936 ChunkRecord(
937 source=source_name,
938 content_type="text",
939 chunk_type=ChunkType.RAW,
940 page_start=0,
941 page_end=0,
942 line_start=0,
943 line_end=0,
944 chunk=t,
945 chunk_index=idx,
946 vector=vec,
947 )
948 for idx, (t, vec) in enumerate(zip(texts, vectors, strict=True))
949 ]
950 return records, meta