Coverage for src/qdrant_loader/core/text_processing/chunk_enricher.py: 100%
43 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:15 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:15 +0000
1"""Shared chunk NLP enrichment.
3The single definition of what semantic metadata a chunk gets, so the markdown and
4docling chunking strategies produce identical fields. Owns one ``SemanticAnalyzer``
5and turns ``(content, doc_id)`` into the enrichment metadata dict.
6"""
8from __future__ import annotations
10from typing import TYPE_CHECKING, Any
12from qdrant_loader.core.text_processing.semantic_analyzer import SemanticAnalyzer
13from qdrant_loader.utils.logging import LoggingConfig
15if TYPE_CHECKING:
16 from qdrant_loader.config import Settings
18logger = LoggingConfig.get_logger(__name__)
21def _empty() -> dict[str, Any]:
22 """The always-present enrichment keys with no data — fresh lists each call."""
23 return {"entities": [], "topics": [], "key_phrases": []}
26class ChunkEnricher:
27 """Owns a ``SemanticAnalyzer`` and maps ``(content, doc_id)`` to chunk metadata."""
29 def __init__(self, settings: Settings) -> None:
30 chunking = settings.global_config.chunking
31 self._enhanced = bool(chunking.enable_enhanced_semantic_analysis)
32 self._analyzer: SemanticAnalyzer | None = None
33 if chunking.enable_semantic_analysis:
34 sa = settings.global_config.semantic_analysis
35 self._analyzer = SemanticAnalyzer(
36 spacy_model=sa.spacy_model,
37 num_topics=sa.num_topics,
38 passes=sa.lda_passes,
39 )
41 @property
42 def enabled(self) -> bool:
43 return self._analyzer is not None
45 def fit_topics(self, contents: list[str]) -> None:
46 """Front-load the document-level topic model on a document's chunk contents.
48 Training one model over all chunks lets each subsequent :meth:`enrich` call
49 infer the chunk's topics against it, instead of training a degenerate
50 single-chunk model each time. No-op when semantic analysis is disabled; a
51 fit failure is swallowed so chunks simply fall back to per-chunk topics.
52 """
53 if self._analyzer is None:
54 return
55 try:
56 self._analyzer.fit_topic_model(contents)
57 except Exception:
58 logger.warning(
59 "Topic model fit failed; chunks will fall back to per-chunk topics",
60 exc_info=True,
61 )
63 def enrich(self, content: str, doc_id: str) -> dict[str, Any]:
64 if self._analyzer is None:
65 return _empty()
66 try:
67 result = self._analyzer.analyze_text(
68 content, doc_id=doc_id, include_enhanced=self._enhanced
69 )
70 except Exception:
71 logger.warning(
72 "Chunk semantic enrichment failed; emitting empty enrichment",
73 doc_id=doc_id,
74 exc_info=True,
75 )
76 return _empty()
77 enriched: dict[str, Any] = {
78 "entities": result.entities,
79 "topics": result.topics,
80 "key_phrases": result.key_phrases,
81 }
82 if self._enhanced:
83 enriched["pos_tags"] = result.pos_tags
84 enriched["dependencies"] = result.dependencies
85 enriched["document_similarity"] = result.document_similarity
86 return enriched
88 def shutdown(self) -> None:
89 if self._analyzer is not None:
90 self._analyzer.shutdown()
91 self._analyzer = None