Coverage for src/lilbee/retrieval/query/intent.py: 100%

120 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""Query intent detection: known-item lookups and corpus aggregates. 

2 

3Top-k similarity retrieval answers topical questions. Two other question 

4shapes reach the same pipe and fail structurally: 

5 

6- A known-item lookup ("summarize survey_214.pdf") names the thing it 

7 wants; the answer is a document, not a ranking. 

8- An aggregate ("how many documents mention the observatory") is a property 

9 of the whole corpus; the top 20 of 500k chunks cannot count anything. 

10 

11Detection here is deterministic and deliberately conservative: a missed 

12route degrades to topical retrieval, which handles the query the way it 

13always has, while a false positive would hijack a topical question. Every 

14pattern therefore requires an explicit structural cue. 

15 

16Language-specific patterns live in ``retrieval.language`` packs; the logic 

17here consumes the active pack, so another language is an added pack, not an 

18edited parser. Only language-neutral shapes (filenames, quoting, token 

19splitting) are defined in this module. 

20""" 

21 

22from __future__ import annotations 

23 

24import re 

25from dataclasses import dataclass 

26from enum import Enum 

27from pathlib import Path 

28 

29from lilbee.core.llm_json import first_json_object 

30from lilbee.retrieval.language import QueryLanguage, query_language 

31 

32 

33class AggregateKind(Enum): 

34 """What a count-shaped question is asking to count.""" 

35 

36 TOTAL_SOURCES = "total_sources" 

37 TERM_MENTIONS = "term_mentions" 

38 DISTINCT_TYPE = "distinct_type" 

39 TYPE_ASSOCIATION = "type_association" 

40 UNSUPPORTED = "unsupported" 

41 

42 

43@dataclass(frozen=True) 

44class AggregateQuery: 

45 """A parsed aggregate question. 

46 

47 ``noun`` carries the thing being counted for the typed kinds; 

48 ``group_noun`` the per-group dimension of an association question. Both 

49 are question words, resolved against the extraction schema by the caller 

50 (the parser stays schema-free and purely syntactic). 

51 """ 

52 

53 kind: AggregateKind 

54 term: str = "" 

55 noun: str = "" 

56 group_noun: str = "" 

57 

58 

59# Filename-shaped tokens: a path-ish word with a known document extension. 

60# No spaces: a name containing them arrives quoted and the quote pattern 

61# catches it; allowing spaces here would swallow leading sentence words. 

62# Language-neutral: filenames look the same in every language. 

63_FILENAME_RE = re.compile( 

64 r"[\w.-][\w./-]*\.(?:pdf|md|txt|docx?|rst|html?|epub|csv|py|rs|js|ts|java|go)\b", 

65 re.IGNORECASE, 

66) 

67 

68# Quoted names: 'harbor survey 2002' / "harbor survey 2002". A quote only 

69# delimits when the pair matches and neither end touches a word from the 

70# outside, so contractions and possessives ("what's", "Alice's") never pair 

71# into a phantom name; double-quoted names may contain apostrophes. 

72_QUOTED_RE = re.compile(r"(?<!\w)\"([^\"]{2,80})\"(?!\w)|(?<!\w)'([^']{2,80})'(?!\w)") 

73 

74 

75def document_references(question: str, lang: QueryLanguage | None = None) -> list[str]: 

76 """Candidate document identifiers named in *question*, best-first. 

77 

78 Filenames beat quoted names beat "document N" references; all are 

79 resolved against real source metadata by the caller, so a wrong 

80 candidate costs one lookup, not a wrong route. 

81 """ 

82 lang = lang or query_language() 

83 candidates: list[str] = [] 

84 for m in _FILENAME_RE.finditer(question): 

85 candidates.append(m.group(0).strip()) 

86 for m in _QUOTED_RE.finditer(question): 

87 quoted = (m.group(1) or m.group(2)).strip() 

88 if quoted: 

89 candidates.append(quoted) 

90 for m in lang.doc_ref_pattern.finditer(question): 

91 ref = m.group(1).strip() 

92 if ref.lower() not in lang.ref_stopwords: 

93 candidates.append(ref) 

94 seen: set[str] = set() 

95 unique = [] 

96 for c in candidates: 

97 key = c.lower() 

98 if key not in seen: 

99 seen.add(key) 

100 unique.append(c) 

101 return unique 

102 

103 

104_TOKEN_SPLIT_RE = re.compile(r"[^0-9A-Za-z]+") 

105 

106 

107def matches_reference(ref: str, filename: str) -> bool: 

108 """Whether *filename* names the document *ref* refers to, token-exactly. 

109 

110 Substring search cannot resolve a bare number against zero-padded ids: 

111 "482" is a substring of both "...00000482" and "...00010482". Tokens 

112 split on non-alphanumerics are compared whole; numeric tokens compare by 

113 value so leading zeros don't hide the match, and a longer number sharing 

114 a suffix stays a non-match. 

115 """ 

116 ref_token = ref.strip().lower() 

117 if ref_token in (filename.lower(), Path(filename).name.lower()): 

118 return True 

119 stem = Path(filename).stem.lower() 

120 for token in _TOKEN_SPLIT_RE.split(stem): 

121 if not token: 

122 continue 

123 if token == ref_token: 

124 return True 

125 if _same_number(token, ref_token): 

126 return True 

127 return False 

128 

129 

130def _same_number(token: str, ref_token: str) -> bool: 

131 """Whether two tokens are the same number ignoring leading zeros. 

132 

133 Compares zero-stripped decimal strings rather than calling ``int``: 

134 ``str.isdigit()`` is True for Unicode digits like the superscript two, 

135 which ``int`` rejects, and the reference pattern matches those. 

136 """ 

137 if not (token.isdecimal() and ref_token.isdecimal()): 

138 return False 

139 return token.lstrip("0") == ref_token.lstrip("0") 

140 

141 

142def title_candidates(question: str, lang: QueryLanguage | None = None) -> list[str]: 

143 """Document-title candidates from known-item question shapes. 

144 

145 "summarize Frankenstein" yields "Frankenstein"; a question with no 

146 known-item shape yields nothing, so topical questions that merely 

147 mention a title word never reach title resolution. 

148 """ 

149 lang = lang or query_language() 

150 candidates = [] 

151 for pattern in lang.known_item_patterns: 

152 m = pattern.match(question) 

153 if m: 

154 title = m.group(1).strip().strip("\"'") 

155 if title: 

156 candidates.append(title) 

157 return candidates 

158 

159 

160def _title_tokens(text: str, lang: QueryLanguage) -> list[str]: 

161 """Lowercased comparison tokens with the leading article stripped.""" 

162 stripped = lang.leading_article_pattern.sub("", text.strip()) 

163 return [t for t in _TOKEN_SPLIT_RE.split(stripped.lower()) if t] 

164 

165 

166def matches_title(title: str, filename: str, lang: QueryLanguage | None = None) -> bool: 

167 """Whether *filename*'s stem is the document *title* names, token-exactly. 

168 

169 Leading articles are stripped from both sides so "the prince" matches 

170 "The Prince.txt" and "Prince.txt" alike; every remaining token must 

171 match, so "the report" never resolves "Annual Report 2020.txt". 

172 """ 

173 lang = lang or query_language() 

174 title_tokens = _title_tokens(title, lang) 

175 return bool(title_tokens) and title_tokens == _title_tokens(Path(filename).stem, lang) 

176 

177 

178def matches_stored_title(title: str, stored: str | None, lang: QueryLanguage | None = None) -> bool: 

179 """Whether the stored document title is what *title* names, token-exactly. 

180 

181 Covers documents whose ingested title (markdown H1, extraction metadata) 

182 differs from their filename, so "summarize Frankenstein Analysis" routes 

183 to notes-2024.md. 

184 """ 

185 if not stored: 

186 return False 

187 lang = lang or query_language() 

188 title_tokens = _title_tokens(title, lang) 

189 return bool(title_tokens) and title_tokens == _title_tokens(stored, lang) 

190 

191 

192def parse_aggregate(question: str, lang: QueryLanguage | None = None) -> AggregateQuery | None: 

193 """Parse a count-shaped question, or ``None`` for anything else. 

194 

195 Only "how many ..." questions qualify; of those, term-mention and 

196 corpus-total forms are answerable against today's schema. The rest 

197 (counts over typed records the store does not hold) come back as 

198 ``UNSUPPORTED`` so the caller can decline precisely instead of feeding 

199 the question to top-k retrieval that structurally cannot count. 

200 """ 

201 lang = lang or query_language() 

202 if not lang.how_many_pattern.search(question): 

203 return None 

204 m = lang.association_pattern.search(question) or lang.per_pattern.search(question) 

205 if m: 

206 return AggregateQuery( 

207 AggregateKind.TYPE_ASSOCIATION, 

208 noun=m.group(1).strip(), 

209 group_noun=m.group(2).strip(), 

210 ) 

211 m = lang.distinct_pattern.search(question) 

212 if m: 

213 return AggregateQuery(AggregateKind.DISTINCT_TYPE, noun=m.group(1).strip()) 

214 m = lang.term_mention_pattern.search(question) 

215 if m: 

216 term = m.group(1).strip().strip("\"'") 

217 # Strip a leading article so 'mention the observatory' counts 'observatory'. 

218 term = lang.leading_article_pattern.sub("", term) 

219 if term: 

220 return AggregateQuery(AggregateKind.TERM_MENTIONS, term=term) 

221 if lang.total_pattern.search(question): 

222 return AggregateQuery(AggregateKind.TOTAL_SOURCES) 

223 return AggregateQuery(AggregateKind.UNSUPPORTED) 

224 

225 

226# --- LLM-backed classification (config-gated; see Searcher.route_direct_answer) --- 

227 

228# Answer budget for the classification call: one small JSON object. 

229INTENT_CLASSIFY_MAX_TOKENS = 96 

230 

231# The classifier prompt is intentionally language-agnostic about the QUESTION 

232# (the model reads any language); only the label vocabulary is fixed. 

233INTENT_CLASSIFY_PROMPT = """Classify this question for a document-search engine. 

234Respond with ONLY a JSON object, no other text: 

235{{"kind": "...", "term": "", "noun": "", "group_noun": ""}} 

236 

237kind must be exactly one of: 

238- "topical": an ordinary question answered by reading passages (the default) 

239- "total_sources": asks how many documents/files the collection holds 

240- "term_mentions": asks how many documents mention or contain a specific \ 

241term; put that term in "term" 

242- "distinct_type": asks how many distinct entities of some type exist; put \ 

243the type noun in "noun" 

244- "type_association": asks how many X each Y has; put X in "noun" and Y in \ 

245"group_noun" 

246 

247When unsure, use "topical". 

248 

249Question: {question} 

250""" 

251 

252 

253_LLM_KINDS = { 

254 "total_sources": AggregateKind.TOTAL_SOURCES, 

255 "term_mentions": AggregateKind.TERM_MENTIONS, 

256 "distinct_type": AggregateKind.DISTINCT_TYPE, 

257 "type_association": AggregateKind.TYPE_ASSOCIATION, 

258} 

259 

260 

261def parse_llm_aggregate(text: str) -> AggregateQuery | None: 

262 """Map the classifier's reply to a route, or ``None`` for no route. 

263 

264 Conservative on every axis: anything malformed, unknown, "topical", or 

265 missing a required field yields ``None``, which sends the question to 

266 ordinary retrieval -- the same harmless degrade as a deterministic miss. 

267 ``UNSUPPORTED`` is never produced here; declining is reserved for the 

268 deterministic layer, whose patterns prove the question is count-shaped. 

269 """ 

270 data = first_json_object(text) 

271 if data is None: 

272 return None 

273 raw_kind = data.get("kind", "") 

274 # A non-string kind (list, dict) is malformed, not a crash: an unhashable 

275 # value would raise TypeError inside dict.get. 

276 kind = _LLM_KINDS.get(raw_kind) if isinstance(raw_kind, str) else None 

277 if kind is None: 

278 return None 

279 term = str(data.get("term", "") or "").strip() 

280 noun = str(data.get("noun", "") or "").strip() 

281 group_noun = str(data.get("group_noun", "") or "").strip() 

282 required_ok = { 

283 AggregateKind.TOTAL_SOURCES: True, 

284 AggregateKind.TERM_MENTIONS: bool(term), 

285 AggregateKind.DISTINCT_TYPE: bool(noun), 

286 AggregateKind.TYPE_ASSOCIATION: bool(noun and group_noun), 

287 }[kind] 

288 if not required_ok: 

289 return None 

290 return AggregateQuery(kind, term=term, noun=noun, group_noun=group_noun)