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

160 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-17 10:02 +0000

1"""Source formatting, context templating, and LLM citation extraction.""" 

2 

3from __future__ import annotations 

4 

5import re 

6from pathlib import Path 

7 

8from lilbee.data.store import ChunkType, CitationRecord, SearchChunk, is_memory_source 

9 

10CONTEXT_TEMPLATE = """Context: 

11{context} 

12 

13Question: {question}""" 

14 

15 

16# Bracketed citation groups: [1], [1, 2], [1-3], [1, 3-5]. Models mix all of 

17# these despite being asked for single [n] markers; matching only [n] made 

18# cited_sources under-count and fed JSON consumers false-negative grounding. 

19_CITE_GROUP_RE = re.compile(r"\[(\d+(?:\s*[-,]\s*\d+)*)\]") 

20_CITE_RANGE_RE = re.compile(r"(\d+)\s*-\s*(\d+)") 

21# Ranges wider than this are page spans or line numbers, not citation lists. 

22_MAX_CITE_RANGE = 32 

23 

24# Heading shapes that open a model-authored citation block: anchored to the 

25# start of the text as well as a preceding newline (an answer that is nothing 

26# but a fabricated block still strips), optionally markdown-decorated (ATX 

27# hashes, emphasis around the word or the colon: "**Sources:**", "*References:*"). 

28_HEADING_WORDS = r"(?:(?:Key\s+)?Sources|References|Bibliography|Citations)" 

29_CITE_HEADING = ( 

30 r"(?:\n{1,3}|\A)[ \t]*(?:#+\s*)?[*_]{0,3}" + _HEADING_WORDS + r"[*_]{0,3}\s*:?\s*[*_]{0,3}" 

31) 

32# One list line: a bullet, arrow, "[1]" or "1." marker and the rest of its line. 

33_CITE_LIST_LINE = r"[ \t]*(?:[-*•→\[]|\d+[.)])[^\n]*" 

34 

35# A heading line followed by a list. The list is required so prose discussing 

36# such a heading is not clipped; the match ends with the list, not end-of-text, 

37# so an answer resuming after its citations keeps the continuation. Items may be 

38# blank-line separated, which markdown does routinely; stopping at the first 

39# blank line would leave the rest of a fabricated list in the answer. 

40_LLM_CITATION_BLOCK_RE = re.compile( 

41 _CITE_HEADING + r"\n\s*" + _CITE_LIST_LINE + r"(?:\n+" + _CITE_LIST_LINE + r")*", 

42 re.IGNORECASE, 

43) 

44 

45# A citation-style heading at the very end of the text, nothing after it yet. 

46# Mid-stream this is ambiguous (the next line decides list vs prose), so it is 

47# held back rather than shown; at end of stream it is a dangling artifact of a 

48# citation block the model never finished, and is dropped either way. 

49_TRAILING_HEADING_RE = re.compile(_CITE_HEADING + r"\s*$", re.IGNORECASE) 

50 

51 

52def display_source_path(source: str) -> str: 

53 """Render a chunk's source as an absolute path with ``~`` expansion. 

54 

55 Source values in the store are stored relative to ``documents_dir`` so the 

56 database is portable across machines. For display we resolve back to the 

57 user's filesystem and substitute ``~`` for the home directory so the path 

58 is unambiguous without being noisy. 

59 

60 Falls back to the raw source string only if resolution itself fails (an 

61 exotic OSError such as a symlink loop). A missing file is not a failure: 

62 ``resolve(strict=False)`` still returns the absolute path to where the 

63 file would be, so a moved documents directory renders its old location. 

64 """ 

65 from lilbee.data.ingest.discovery import resolve_source_path 

66 

67 candidate = resolve_source_path(source) 

68 try: 

69 resolved = candidate.resolve(strict=False) 

70 except OSError: 

71 return source 

72 home = Path.home() 

73 try: 

74 return f"~/{resolved.relative_to(home)}" 

75 except ValueError: 

76 return str(resolved) 

77 

78 

79_WEB_PREFIX = "_web" 

80_SOURCE_SEP = " · " 

81 

82 

83def _source_label(source: str) -> str: 

84 """Readable name for a source. Web-ingested docs collapse to ``host · slug``; 

85 local files keep their documents-dir-relative path.""" 

86 prefix = f"{_WEB_PREFIX}/" 

87 if not source.startswith(prefix): 

88 return source 

89 segments = [p for p in source.removeprefix(prefix).split("/") if p != "index.md"] 

90 if not segments: 

91 return source 

92 host = segments[0].removeprefix("www.") 

93 slug = segments[-1].removesuffix(".md") 

94 return host if slug == host else f"{host}{_SOURCE_SEP}{slug}" 

95 

96 

97def _source_file_url(source: str) -> str | None: 

98 """A ``file://`` URL to the source on disk, so a reader can click it open, or 

99 None when the path can't be resolved to an absolute location.""" 

100 from lilbee.data.ingest.discovery import resolve_source_path 

101 

102 try: 

103 return resolve_source_path(source).resolve(strict=False).as_uri() 

104 except (OSError, ValueError): 

105 return None 

106 

107 

108def _source_locator(result: SearchChunk) -> str: 

109 """The ``, page N`` / ``, lines A-B`` suffix for a source line, or ''.""" 

110 location = _location_suffix(result) 

111 return f", {location}" if location else "" 

112 

113 

114def _format_citation(citation: CitationRecord) -> str: 

115 """Format a single wiki transitive citation as an indented attribution line.""" 

116 source_display = display_source_path(citation["source_filename"]) 

117 if citation["page_start"] or citation["page_end"]: 

118 ps, pe = citation["page_start"], citation["page_end"] 

119 pages = f"page {ps}" if ps == pe else f"pages {ps}-{pe}" 

120 return f"{source_display}, {pages}" 

121 if citation["line_start"] or citation["line_end"]: 

122 ls, le = citation["line_start"], citation["line_end"] 

123 lines = f"line {ls}" if ls == le else f"lines {ls}-{le}" 

124 return f"{source_display}, {lines}" 

125 return f"{source_display}" 

126 

127 

128def _location_suffix(result: SearchChunk) -> str: 

129 """The page or line span of a chunk, or empty when neither applies. 

130 

131 Zero means "no location": PDF chunks whose page metadata was missing are 

132 stored with page 0, so a locator is only rendered when at least one end 

133 of the span is set. Sole owner of the rule -- ``_source_locator`` adds 

134 the leading separator and nothing else. 

135 """ 

136 if result.content_type == "pdf" and (result.page_start or result.page_end): 

137 ps, pe = result.page_start, result.page_end 

138 return f"page {ps}" if ps == pe else f"pages {ps}-{pe}" 

139 if result.content_type == "code" and (result.line_start or result.line_end): 

140 ls, le = result.line_start, result.line_end 

141 return f"line {ls}" if ls == le else f"lines {ls}-{le}" 

142 return "" 

143 

144 

145def source_markdown_link(source: str) -> str: 

146 """A bare source name as the same clickable ``[label](file-url)`` markdown a 

147 live answer's Sources block uses; the plain label when no path resolves. 

148 Public so restored transcripts render sources identically to live ones.""" 

149 label = _source_label(source) 

150 if is_memory_source(source): 

151 return label 

152 url = _source_file_url(source) 

153 return f"[{label}]({url})" if url else label 

154 

155 

156def format_source(result: SearchChunk, citations: list[CitationRecord] | None = None) -> str: 

157 """Format a source as a clickable, readable citation: a ``[label](file-url)`` 

158 markdown link plus any page/line locator. Web docs render as ``host · slug``; 

159 wiki chunks append their indented transitive citations. Memory rows render 

160 as their plain ``memory:<id>`` label: they have no file to link. 

161 """ 

162 if result.memory_id is not None: 

163 return _source_label(result.source) 

164 head = source_markdown_link(result.source) 

165 if result.chunk_type is ChunkType.WIKI and citations: 

166 return "\n".join([head, *(_format_citation(c) for c in citations)]) 

167 return f"{head}{_source_locator(result)}" 

168 

169 

170def unique_sources(results: list[SearchChunk]) -> list[SearchChunk]: 

171 """The first chunk of each distinct source, in retrieval order. A source's 

172 1-based position here is the citation number the model emits (see 

173 ``build_context``) and the number shown in the Sources block, so the answer's 

174 ``[n]`` markers and the source list always agree.""" 

175 seen: set[str] = set() 

176 out: list[SearchChunk] = [] 

177 for r in results: 

178 if r.source not in seen: 

179 seen.add(r.source) 

180 out.append(r) 

181 return out 

182 

183 

184def _context_header(result: SearchChunk) -> str: 

185 """One-line provenance for a context block: source name plus location. 

186 

187 Without it the answering model sees bare numbered text: it cannot 

188 attribute a claim to a named document, notice two chunks share a source, 

189 or confirm it is reading the document the user asked about. 

190 """ 

191 location = _location_suffix(result) 

192 if location: 

193 return f"{result.source}, {location}" 

194 return result.source 

195 

196 

197def build_context(results: list[SearchChunk]) -> str: 

198 """Number each passage by its source file, not its position, so citation 

199 numbers are stable while streaming and map 1:1 to the Sources block. Passages 

200 from the same file share a number. 

201 

202 Each block carries a provenance header: without it the answering model sees 

203 bare numbered text and cannot attribute a claim to a named document, notice 

204 two passages share a source, or confirm it is reading the document asked for. 

205 """ 

206 order: dict[str, int] = {} 

207 for r in results: 

208 order.setdefault(r.source, len(order) + 1) 

209 return "\n\n".join(f"[{order[r.source]}] ({_context_header(r)})\n{r.chunk}" for r in results) 

210 

211 

212# Grepped by consumers to know an answer carries its own Sources list (the 

213# no-results toast; the pill row, which must not stack a second list). 

214SOURCES_BLOCK_MARKER = "\n\nSources:\n" 

215 

216 

217def format_sources_block( 

218 results: list[SearchChunk], 

219 citations_map: dict[str, list[CitationRecord]] | None = None, 

220) -> str: 

221 """The authoritative numbered ``Sources:`` block appended to an answer. Each 

222 unique source is numbered to match the ``[n]`` markers the model emitted, so 

223 every inline citation resolves to a line here. '' when there are no sources.""" 

224 sources = unique_sources(results) 

225 if not sources: 

226 return "" 

227 # A markdown ordered list, so a Markdown renderer puts each source on its own 

228 # line (plain " → path" lines collapse into one soft-wrapped paragraph) and 

229 # the list number is the citation number the model cited inline. 

230 lines = [ 

231 f"{i}. {format_source(r, citations=(citations_map or {}).get(r.source))}" 

232 for i, r in enumerate(sources, 1) 

233 ] 

234 return SOURCES_BLOCK_MARKER + "\n" + "\n".join(lines) 

235 

236 

237def _extract_cited_indices(text: str) -> set[int]: 

238 """Extract citation references from LLM answer text: [1], [1, 2], [1-3].""" 

239 indices: set[int] = set() 

240 for m in _CITE_GROUP_RE.finditer(text): 

241 group = m.group(1) 

242 remainder = _CITE_RANGE_RE.sub("", group) 

243 for start, end in _CITE_RANGE_RE.findall(group): 

244 lo, hi = int(start), int(end) 

245 if lo <= hi <= lo + _MAX_CITE_RANGE: 

246 indices.update(range(lo, hi + 1)) 

247 indices.update(int(n) for n in re.findall(r"\d+", remainder)) 

248 return indices 

249 

250 

251def _identifier_shaped(stem: str) -> bool: 

252 """Whether a filename stem is distinctive enough to match in prose. 

253 

254 A stem carrying a digit or a separator ("survey_report", "ARC-00000482") 

255 only appears in an answer when the model names the document; a bare word 

256 stem ("notes") collides with ordinary prose and cannot be trusted. 

257 """ 

258 return any(c.isdigit() or c in "_-" for c in stem) 

259 

260 

261def cited_subset(answer: str, sources: list[SearchChunk]) -> list[SearchChunk]: 

262 """The sources the answer actually referenced, in order (empty if none). 

263 

264 ``[n]`` markers are the primary signal, and ``n`` indexes the unique sources 

265 (matching ``build_context``/``format_sources_block``). Name mentions count 

266 too: context blocks show the model each source's name, and models often 

267 attribute by name ("according to survey_report.pdf") instead of by marker, 

268 which otherwise reads as an ungrounded answer to JSON consumers. 

269 """ 

270 uniq = unique_sources(sources) 

271 cited = _extract_cited_indices(answer) 

272 picked = {i - 1 for i in cited if 1 <= i <= len(uniq)} 

273 lowered = answer.lower() 

274 for i, source in enumerate(uniq): 

275 if i in picked: 

276 continue 

277 name = Path(source.source).name.lower() 

278 stem = Path(source.source).stem 

279 if _mentions(lowered, name) or ( 

280 _identifier_shaped(stem) and _mentions(lowered, stem.lower()) 

281 ): 

282 picked.add(i) 

283 return [uniq[i] for i in sorted(picked)] 

284 

285 

286def _mentions(answer_lower: str, needle: str) -> bool: 

287 """Whether *answer_lower* names *needle* as a whole token. 

288 

289 Plain containment marks a source cited whenever its name embeds in a 

290 longer one ("log-1" inside "catalog-10", "notes.md" inside 

291 "footnotes.md"), which inflates the grounding signal. Filename 

292 characters are what must not abut the match; surrounding punctuation 

293 and whitespace still count as a mention. 

294 """ 

295 return re.search(rf"(?<![\w.-]){re.escape(needle)}(?![\w-])", answer_lower) is not None 

296 

297 

298def _stream_safe_prefix(text: str) -> str: 

299 """The prefix of *text* safe to show: a model-authored citation block 

300 (heading plus list) is removed, and a bare trailing citation heading is 

301 withheld until whatever follows disambiguates it. 

302 

303 Kept rstrip-free so the streaming filter can track emitted length exactly; 

304 ``strip_llm_citations`` layers the final rstrip on top for one-shot answers. 

305 """ 

306 cleaned = _LLM_CITATION_BLOCK_RE.sub("", text) 

307 return _TRAILING_HEADING_RE.sub("", cleaned) 

308 

309 

310def strip_llm_citations(text: str) -> str: 

311 """Remove an LLM-generated trailing citation block (or dangling citation 

312 heading) from answer text. Prose that merely mentions such a heading stays.""" 

313 return _stream_safe_prefix(text).rstrip() 

314 

315 

316class StreamingCitationFilter: 

317 """Suppress a model-generated ``Sources:``/``References:`` citation block as 

318 it streams, so only lilbee's authoritative source list reaches the reader. 

319 

320 A one-shot answer can be stripped after the fact, but streamed tokens are 

321 already on screen by the time the block is recognizable. This feeds the 

322 answer in incrementally and only releases text once it is certain not to be 

323 the start of a citation block: everything up to the last newline is safe to 

324 show, the final (possibly partial) line is held back until more text 

325 arrives, and a bare citation heading is held until the next line shows 

326 whether a list (a citation block, dropped) or prose (a legitimate mention, 

327 shown) follows. A heading left dangling when the stream ends is dropped. 

328 """ 

329 

330 def __init__(self) -> None: 

331 self._buffer = "" 

332 self._emitted = 0 

333 

334 def feed(self, text: str) -> str: 

335 """Accept the next answer chunk; return the portion safe to show now.""" 

336 self._buffer += text 

337 stripped = _stream_safe_prefix(self._buffer) 

338 cut = stripped.rfind("\n") 

339 committed = stripped if cut == -1 else stripped[:cut] 

340 if len(committed) > self._emitted: 

341 out = committed[self._emitted :] 

342 self._emitted = len(committed) 

343 return out 

344 return "" 

345 

346 def flush(self) -> str: 

347 """Release any remaining safe text once the stream has ended.""" 

348 final = _stream_safe_prefix(self._buffer) 

349 if len(final) > self._emitted: 

350 out = final[self._emitted :] 

351 self._emitted = len(final) 

352 return out 

353 return "" 

354 

355 @property 

356 def answer(self) -> str: 

357 """The full answer shown so far, with any citation block removed.""" 

358 return strip_llm_citations(self._buffer)