Coverage for src/lilbee/retrieval/language.py: 100%
47 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"""Language packs for query understanding.
3ALL language-specific patterns for intent detection and noun matching live
4here, mirroring ``cli/tui/messages.py``: parsing logic consumes the active
5pack instead of hardcoding English, so supporting another language means
6adding a pack, not editing parsers. English is the only shipped pack; the
7accessor grows config-driven selection when a second pack exists.
8"""
10from __future__ import annotations
12import re
13from collections.abc import Callable
14from dataclasses import dataclass
17@dataclass(frozen=True)
18class QueryLanguage:
19 """Everything language-specific the query-understanding layer consumes.
21 The patterns encode question *shapes*, not vocabulary lists: a pack for
22 another language supplies its own shapes (word order included), and the
23 parsing logic in ``query.intent`` stays untouched.
24 """
26 code: str
27 # "document 214", "doc #47": a document noun followed by a short identifier.
28 doc_ref_pattern: re.Pattern[str]
29 # Generic nouns that follow a document noun in topical questions ("the
30 # documents mention...", "which document says..."); never identifiers.
31 ref_stopwords: frozenset[str]
32 # Count-question shapes; routing rules live in ``query.intent.parse_aggregate``.
33 how_many_pattern: re.Pattern[str]
34 total_pattern: re.Pattern[str]
35 association_pattern: re.Pattern[str]
36 per_pattern: re.Pattern[str]
37 distinct_pattern: re.Pattern[str]
38 term_mention_pattern: re.Pattern[str]
39 # Leading article stripped from a counted term ("the observatory").
40 leading_article_pattern: re.Pattern[str]
41 # Known-item question shapes ("summarize X", "what is X about"): each
42 # captures a candidate document title, resolved token-exactly against
43 # source names by the caller. Shapes, not vocabulary: a wrong candidate
44 # costs one lookup, never a wrong route.
45 known_item_patterns: tuple[re.Pattern[str], ...]
46 # Spelling variants of a noun phrase (singular/plural) for entity-type
47 # matching; morphology is the most language-specific piece of all.
48 noun_variants: Callable[[str], set[str]]
51# Plural forms the suffix rules below can't produce, mapped both ways.
52_EN_IRREGULAR_PLURALS = {
53 "person": "people",
54 "man": "men",
55 "woman": "women",
56 "child": "children",
57 "foot": "feet",
58 "tooth": "teeth",
59 "mouse": "mice",
60 "goose": "geese",
61}
62_EN_IRREGULAR_SINGULARS = {plural: singular for singular, plural in _EN_IRREGULAR_PLURALS.items()}
65def _english_noun_variants(noun: str) -> set[str]:
66 """Normalized spelling variants of a noun phrase: itself plus
67 singular/plural forms of its last word ("tail numbers" ~ "tail number",
68 "people" ~ "person"). Over-generated junk forms match nothing; a missed
69 form only fails to resolve, never resolves wrongly.
70 """
71 normalized = " ".join(noun.strip().lower().split())
72 if not normalized:
73 return set()
74 head, _, last = normalized.rpartition(" ")
75 prefix = head + " " if head else ""
76 forms = {last}
77 if last in _EN_IRREGULAR_PLURALS:
78 forms.add(_EN_IRREGULAR_PLURALS[last])
79 if last in _EN_IRREGULAR_SINGULARS:
80 forms.add(_EN_IRREGULAR_SINGULARS[last])
81 if last.endswith("ies") and len(last) > len("ies"):
82 forms.add(last[:-3] + "y")
83 if last.endswith("y"):
84 forms.add(last[:-1] + "ies")
85 if last.endswith(("ses", "xes", "zes", "ches", "shes")):
86 forms.add(last[:-2])
87 forms.add(last[:-1] if last.endswith("s") else last + "s")
88 return {prefix + form for form in forms}
91# Nouns that name the corpus's units rather than entities within them. A
92# count over these is a document scan; a count over an entity noun (people,
93# aircraft) needs extracted records and must NOT route to the scan, because
94# the scan answers "N documents", a different question than the one asked.
95_EN_CORPUS_NOUNS = (
96 r"(?:documents|sources|files|pages|chunks|passages|books|novels|texts|works"
97 r"|articles|papers|reports|letters|stories|entries|records|notes|emails"
98 r"|posts|volumes|manuscripts)"
99)
100# "how many of these books ...", "how many of the stories ...".
101_EN_OF_THESE = r"(?:of\s+(?:these|those|the|my|our)\s+)?"
103ENGLISH = QueryLanguage(
104 code="en",
105 doc_ref_pattern=re.compile(
106 r"\b(?:document|doc|file|exhibit|attachment|report)\s+#?([\w][\w.-]{0,23})\b",
107 re.IGNORECASE,
108 ),
109 ref_stopwords=frozenset(
110 {"that", "which", "the", "this", "these", "those", "it", "them", "was", "is", "are"}
111 ),
112 how_many_pattern=re.compile(
113 r"^\s*(?:roughly\s+|approximately\s+|about\s+)?how\s+many\b", re.IGNORECASE
114 ),
115 # "how many documents/books/sources are there/indexed": corpus totals.
116 total_pattern=re.compile(
117 r"how\s+many\s+" + _EN_OF_THESE + _EN_CORPUS_NOUNS + r"\s*"
118 r"(?:are\s+(?:there|indexed|in\s+the\s+index)|do(?:es)?\s+.*\b(?:index|corpus|vault)\b.*)?[?\s]*$",
119 re.IGNORECASE,
120 ),
121 # "how many X is each Y associated with" / "how many X per Y": typed
122 # association counts over extracted entities.
123 association_pattern=re.compile(
124 r"how\s+many\s+(.+?)\s+(?:is|are)\s+each\s+(.+?)\s+"
125 r"(?:associated\s+with|linked\s+to|recorded\s+(?:for|against))",
126 re.IGNORECASE,
127 ),
128 per_pattern=re.compile(r"how\s+many\s+(.+?)\s+per\s+(.+?)[?.\s]*$", re.IGNORECASE),
129 # "how many distinct/unique X ...": typed distinct counts.
130 distinct_pattern=re.compile(
131 r"how\s+many\s+(?:distinct|unique|different)\s+(.+?)"
132 r"(?:\s+(?:are|were|is|exist)\b.*)?[?.\s]*$",
133 re.IGNORECASE,
134 ),
135 # "how many <corpus noun> mention/contain/reference X": term-mention
136 # counts, including "of these/those" phrasing ("how many of these books
137 # mention blood"). Entity nouns deliberately fall through (see
138 # _EN_CORPUS_NOUNS).
139 term_mention_pattern=re.compile(
140 r"how\s+many\s+" + _EN_OF_THESE + _EN_CORPUS_NOUNS + r"\s+"
141 r"(?:mention|mentions|mentioning|contain|contains|containing|reference|references|referencing|discuss|discussing)\s+"
142 r"(.+?)[?.\s]*$",
143 re.IGNORECASE,
144 ),
145 leading_article_pattern=re.compile(r"^(?:the|a|an)\s+", re.IGNORECASE),
146 known_item_patterns=(
147 re.compile(r"^\s*(?:please\s+)?summari[sz]e\s+(.+?)[?.!\s]*$", re.IGNORECASE),
148 re.compile(r"^\s*(?:please\s+)?describe\s+(.+?)[?.!\s]*$", re.IGNORECASE),
149 re.compile(r"^\s*what\s+is\s+(.+?)\s+about[?.!\s]*$", re.IGNORECASE),
150 re.compile(
151 r"^\s*(?:give\s+me\s+)?(?:a\s+|an\s+)?(?:summary|overview)\s+of\s+(.+?)[?.!\s]*$",
152 re.IGNORECASE,
153 ),
154 ),
155 noun_variants=_english_noun_variants,
156)
158_PACKS = {ENGLISH.code: ENGLISH}
161def query_language() -> QueryLanguage:
162 """The active language pack.
164 English is the only shipped pack; when more exist this resolves from
165 the configured language instead of a constant.
166 """
167 return _PACKS["en"]
170def noun_variants(noun: str) -> set[str]:
171 """Spelling variants of *noun* under the active language pack."""
172 return query_language().noun_variants(noun)