Coverage for src/lilbee/data/extract/trace.py: 100%
42 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +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
17trace_log = logging.getLogger("lilbee.ingest.trace")
18vision_log = logging.getLogger("lilbee.ingest.vision")
20_TRACE_ENV = "LILBEE_INGEST_TRACE"
23@dataclass(frozen=True)
24class ExtractionTrace:
25 """One xberg extraction's measured outcome."""
27 source: str
28 content_type: str
29 elapsed_s: float
30 page_count: int
31 chunk_count: int
32 ocr_pages: int
33 vision_configured: bool
35 @property
36 def used_vision(self) -> bool:
37 """A page fell through to OCR and the OCR backend is the vision model."""
38 return self.ocr_pages > 0 and self.vision_configured
40 def as_line(self) -> str:
41 """A stable key=value line, easy to grep, diff, and hand to the xberg author."""
42 return (
43 f"extract source={self.source!r} type={self.content_type} "
44 f"elapsed_ms={self.elapsed_s * 1000:.0f} pages={self.page_count} "
45 f"chunks={self.chunk_count} ocr_pages={self.ocr_pages} "
46 f"vision={'yes' if self.used_vision else 'no'}"
47 )
50def configure_from_env() -> None:
51 """Enable trace/vision logging per LILBEE_INGEST_TRACE; mirror to a file when
52 LILBEE_INGEST_TRACE_FILE is set.
54 The file handler exists because host apps own the root handlers: the TUI
55 logs WARNING+ to its file, so INFO trace lines vanish there even with the
56 loggers enabled. A dedicated handler on these two loggers makes the trace
57 destination independent of whichever front-end is running."""
58 if os.environ.get("LILBEE_INGEST_TRACE", "").strip().lower() not in {"1", "true", "yes"}:
59 return
60 trace_log.setLevel(logging.DEBUG)
61 vision_log.setLevel(logging.INFO)
62 target = os.environ.get("LILBEE_INGEST_TRACE_FILE", "").strip()
63 if not target:
64 return
65 resolved = str(Path(target).absolute())
66 for logger in (trace_log, vision_log):
67 if any(
68 isinstance(h, logging.FileHandler) and h.baseFilename == resolved
69 for h in logger.handlers
70 ):
71 continue
72 handler = logging.FileHandler(resolved)
73 handler.setLevel(logging.DEBUG)
74 handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
75 logger.addHandler(handler)
78def trace_extraction(trace: ExtractionTrace) -> None:
79 """Log one extraction's outcome, plus a dedicated line if it needed vision."""
80 trace_log.info("%s", trace.as_line())
81 if trace.used_vision:
82 vision_log.info(
83 "vision-ocr source=%r ocr_pages=%d elapsed_ms=%.0f",
84 trace.source,
85 trace.ocr_pages,
86 trace.elapsed_s * 1000,
87 )