Coverage for src/lilbee/data/extract/trace.py: 100%
43 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
1"""Structured per-file extraction tracing, for sharing ingest diagnostics.
3Every xberg extraction emits one machine-parseable line on the ``lilbee.ingest.trace``
4logger: filename, wall-clock, chunk and page counts, and how many pages fell through
5to OCR. Files that needed the vision model also emit a line on ``lilbee.ingest.vision``
6so ``grep vision`` yields exactly the set of scanned files. Enable with
7``LILBEE_INGEST_TRACE=1`` (sets both loggers to DEBUG).
8"""
10from __future__ import annotations
12import logging
13import os
14from dataclasses import dataclass
15from pathlib import Path
17from lilbee.data.types import OcrReport
18from lilbee.runtime.progress import OcrBackendUsed
20trace_log = logging.getLogger("lilbee.ingest.trace")
21vision_log = logging.getLogger("lilbee.ingest.vision")
23_TRACE_ENV = "LILBEE_INGEST_TRACE"
26@dataclass(frozen=True)
27class ExtractionTrace:
28 """One xberg extraction's measured outcome."""
30 source: str
31 content_type: str
32 elapsed_s: float
33 page_count: int
34 chunk_count: int
35 ocr: OcrReport
37 @property
38 def used_vision(self) -> bool:
39 """A page fell through to OCR and the OCR backend is the vision model."""
40 return self.ocr.pages > 0 and self.ocr.backend is OcrBackendUsed.VISION
42 def as_line(self) -> str:
43 """A stable key=value line, easy to grep, diff, and hand to the xberg author."""
44 return (
45 f"extract source={self.source!r} type={self.content_type} "
46 f"elapsed_ms={self.elapsed_s * 1000:.0f} pages={self.page_count} "
47 f"chunks={self.chunk_count} ocr={self.ocr.backend} ocr_pages={self.ocr.pages} "
48 f"vision={'yes' if self.used_vision else 'no'}"
49 )
52def configure_from_env() -> None:
53 """Enable trace/vision logging per LILBEE_INGEST_TRACE; mirror to a file when
54 LILBEE_INGEST_TRACE_FILE is set.
56 The file handler exists because host apps own the root handlers: the TUI
57 logs WARNING+ to its file, so INFO trace lines vanish there even with the
58 loggers enabled. A dedicated handler on these two loggers makes the trace
59 destination independent of whichever front-end is running."""
60 if os.environ.get("LILBEE_INGEST_TRACE", "").strip().lower() not in {"1", "true", "yes"}:
61 return
62 trace_log.setLevel(logging.DEBUG)
63 vision_log.setLevel(logging.INFO)
64 target = os.environ.get("LILBEE_INGEST_TRACE_FILE", "").strip()
65 if not target:
66 return
67 resolved = str(Path(target).absolute())
68 for logger in (trace_log, vision_log):
69 if any(
70 isinstance(h, logging.FileHandler) and h.baseFilename == resolved
71 for h in logger.handlers
72 ):
73 continue
74 handler = logging.FileHandler(resolved)
75 handler.setLevel(logging.DEBUG)
76 handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
77 logger.addHandler(handler)
80def trace_extraction(trace: ExtractionTrace) -> None:
81 """Log one extraction's outcome, plus a dedicated line if it needed vision."""
82 trace_log.info("%s", trace.as_line())
83 if trace.used_vision:
84 vision_log.info(
85 "vision-ocr source=%r ocr_pages=%d elapsed_ms=%.0f",
86 trace.source,
87 trace.ocr.pages,
88 trace.elapsed_s * 1000,
89 )