Coverage for src/lilbee/retrieval/concepts/nlp.py: 100%
37 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-17 10:02 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-17 10:02 +0000
1"""spaCy-backed NLP helpers for the concept graph."""
3from __future__ import annotations
5import functools
6import logging
7from typing import Any
9from lilbee.core.text import collapse_whitespace, is_valid_label
11log = logging.getLogger(__name__)
14@functools.cache
15def concepts_available() -> bool:
16 """Whether the concept-graph dependencies (spacy, graspologic) are installed.
18 Fixed for the process lifetime (cached), like :func:`gpu_device_count`.
19 Python caches only *successful* imports, so an absent extra re-walks
20 ``sys.path`` on every call, and ingest calls this once per file.
22 Deliberately checks the *packages* only, not whether the ``en_core_web_sm``
23 model is downloaded: a missing model is a fixable user situation, so
24 :func:`load_spacy_pipeline` raises with the download command rather than
25 being silently reported unavailable here. Callers therefore still handle
26 ``ImportError`` from the load even when this returns True.
27 """
28 try:
29 import graspologic_native # noqa: F401
30 import spacy # noqa: F401
32 return True
33 except ImportError:
34 return False
37def _ensure_spacy_model() -> Any:
38 """Load the spaCy NER model; raise ImportError with an install hint if missing."""
39 import spacy
41 model_name = "en_core_web_sm"
42 try:
43 return spacy.load(model_name)
44 except OSError as exc:
45 raise ImportError(
46 f"spaCy model {model_name!r} not installed. Run: python -m spacy download {model_name}"
47 ) from exc
50def load_spacy_pipeline() -> Any:
51 """Public entry point for the shared spaCy NER + noun-chunk pipeline.
53 Raises ``ImportError`` if spaCy or the ``en_core_web_sm`` model is not
54 installed; the message carries the download command. This is the seam
55 other packages import -- ``_ensure_spacy_model`` stays private to this
56 module and its own package.
57 """
58 return _ensure_spacy_model()
61def _filter_noun_chunks(doc: Any, max_concepts: int) -> list[str]:
62 """Extract deduplicated, filtered noun chunks from a spaCy doc.
64 Applies the same :func:`is_valid_label` gate the wiki entity
65 extractor uses, so structural-noise concepts (markdown table
66 delimiters, page-number-prefixed tokens, sub-three-char fragments)
67 never enter the co-occurrence graph and therefore never become a
68 synthesis-page cluster label.
70 The gate runs on the lowercased form here while the NER extractor
71 gates on the original-cased surface; the two decisions match
72 because ``is_valid_label`` is case-agnostic today. Any future
73 case-sensitive rule must land in both call sites together.
74 """
75 seen: set[str] = set()
76 concepts: list[str] = []
77 for chunk in doc.noun_chunks:
78 # Collapse wrapped lines: a noun chunk spanning a line break is the
79 # same concept as its unwrapped form and must fold into one node.
80 concept = collapse_whitespace(chunk.text.lower())
81 if not is_valid_label(concept):
82 continue
83 if concept in seen:
84 continue
85 seen.add(concept)
86 concepts.append(concept)
87 if len(concepts) >= max_concepts:
88 break
89 return concepts