Coverage for src/local_deep_research/utilities/chunk_anchor.py: 98%

102 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1"""Helpers for building chunk-targeted library/RAG citation anchors. 

2 

3Centralizes the validation and URL-fragment construction that RAG search 

4engines and the LangGraph result collector all share. Every producer 

5must route chunk metadata through :func:`extract_chunk_index` before 

6interpolating it into a ``#chunk-...`` URL fragment — a UUID or boolean 

7``chunk_id`` would otherwise slip into ``document_chunks.html`` as a 

8fragment that points at a chunk that does not exist. This module is the 

9single source of truth so producers and the collector agree on what a 

10valid chunk index looks like. 

11""" 

12 

13from __future__ import annotations 

14 

15import re 

16from typing import Any, Mapping, Optional 

17 

18 

19# The document views a library route may address. Shared by the two 

20# route-matching regexes — ``library_resolver._LIBRARY_PATH_RE`` and 

21# ``url_utils._LIBRARY_ROUTE_PATH_RE`` — so adding a view teaches both at 

22# once instead of relying on one to be hand-mirrored from the other. 

23# 

24# It does NOT unify every route predicate in the codebase: 

25# ``url_utils._is_library_route`` is a prefix test that answers a 

26# different question (is this string shaped like a library route at all) 

27# and is deliberately looser. Do not read this constant as making all 

28# three agree. 

29LIBRARY_ROUTE_SUFFIXES = ("pdf", "chunks") 

30# Escaped: these are interpolated into regexes, so a future view containing 

31# a metacharacter (``v1.2``, ``raw+text``) would otherwise silently widen 

32# what counts as a library route. 

33LIBRARY_ROUTE_SUFFIX_ALTERNATION = "|".join( 

34 re.escape(suffix) for suffix in LIBRARY_ROUTE_SUFFIXES 

35) 

36 

37# Root-relative routes the library / collection RAG engines emit as citation 

38# URLs, plus the absolute alias the agent sometimes types instead. Shared 

39# with ``utilities.url_utils`` (which imports from here — this module stays 

40# dependency-free so the import can only go one way). 

41LIBRARY_ROUTE_PREFIXES = ("/library/document/", "/lib/document/") 

42LIBRARY_ALIAS_HOST = "library.document" 

43_LIBRARY_ALIAS_SCHEME_PREFIX = "https://" 

44 

45# Chunk indices are small ordinals within one document. The bound is far 

46# above any realistic chunk count and exists so an absurd value (a 

47# timestamp, a float that rounded to 10**30, a 64-bit id mistaken for an 

48# index) cannot be interpolated into a URL fragment. 

49MAX_CHUNK_INDEX = 1_000_000 

50 

51# Document ids are UUIDs / hex hashes / slugs. ASCII only: ``str.isalnum()`` 

52# accepts every Unicode letter and digit, so a fullwidth "\uff17" would pass 

53# and then key separately from its percent-encoded form. 

54_SAFE_ID_RE = re.compile(r"^[0-9A-Za-z_-]+$") 

55 

56 

57def extract_chunk_index(metadata: Mapping[str, Any] | None) -> Optional[int]: 

58 """Return a validated non-negative chunk index from a result's metadata, 

59 or ``None`` if the metadata does not carry a usable value. 

60 

61 Reads (in order): 

62 

63 * ``metadata["chunk_index"]`` — preferred, always an ``int`` in the 

64 production schema. 

65 * ``metadata["chunk_id"]`` — legacy field that may carry either an 

66 ``int`` or an int-as-string. UUIDs and other non-int-like values 

67 are rejected. 

68 

69 Accepts: 

70 

71 * ``int`` (excluding ``bool``) — used as-is. 

72 * ``str`` composed solely of ASCII digits. 

73 * ``float`` whose value is a whole non-negative integer (catches 

74 e.g. ``0.0`` from numpy conversion). 

75 

76 Rejects (returns ``None``): 

77 

78 * ``bool`` (avoids ``True`` being printed as ``#chunk-True``). 

79 * Negative integers — zero is allowed because chunks are 0-indexed. 

80 * Values above :data:`MAX_CHUNK_INDEX`. No document has a million 

81 chunks, so a value that large is a timestamp, a 64-bit id, or the 

82 silent binary rounding of a float literal such as ``1e30`` — none of 

83 which addresses a real anchor. 

84 * Strings that are not pure ASCII digits: ``"550e8400-..."`` UUIDs, 

85 signed forms (``"+5"``/``"-1"``), surrounding whitespace (``" 5 "``), 

86 and non-ASCII digit characters that ``str.isdigit()`` accepts but 

87 that no anchor id can contain (Arabic-Indic ``"\u0667"``, 

88 mathematical ``"\U0001d7dd"``, ...). 

89 * Floats with a fractional part. 

90 * Anything else (``None``, dicts, lists, ...). 

91 """ 

92 if not isinstance(metadata, Mapping): 

93 return None 

94 

95 chunk_idx_raw: Any = None 

96 if metadata.get("chunk_index") is not None: 

97 chunk_idx_raw = metadata["chunk_index"] 

98 elif metadata.get("chunk_id") is not None: 

99 chunk_idx_raw = metadata["chunk_id"] 

100 else: 

101 return None 

102 

103 try: 

104 if isinstance(chunk_idx_raw, bool): 

105 # bool is a subclass of int but True/False are never chunk ids. 

106 return None 

107 if isinstance(chunk_idx_raw, int): 

108 value = chunk_idx_raw 

109 elif isinstance(chunk_idx_raw, str): 

110 # Reject surrounding whitespace, signs, and every non-ASCII 

111 # digit: the value is interpolated verbatim into an anchor, and 

112 # ``str.isdigit()`` alone is Unicode-wide (it accepts "\u0667" 

113 # and "\U0001d7dd", which ``int()`` then happily converts to 7 

114 # and 5). Deliberately no ``.strip()`` — the docstring has 

115 # always promised whitespace is rejected. 

116 if not chunk_idx_raw.isascii() or not chunk_idx_raw.isdigit(): 

117 return None 

118 value = int(chunk_idx_raw) 

119 elif isinstance(chunk_idx_raw, float): 

120 if not chunk_idx_raw.is_integer(): 

121 return None 

122 value = int(chunk_idx_raw) 

123 else: 

124 return None 

125 except (ValueError, AttributeError): 

126 return None 

127 

128 # Negative chunk ids never match a real chunk — reject. Zero is 

129 # allowed: the document_chunks.html template renders ``chunk.index`` 

130 # directly as the HTML anchor id, so a 0-indexed chunk anchors at 

131 # ``#chunk-0``. 

132 if value < 0 or value > MAX_CHUNK_INDEX: 

133 return None 

134 return value 

135 

136 

137def extract_document_id( 

138 metadata: Mapping[str, Any] | None, *top_level: Any 

139) -> Optional[str]: 

140 """Return a sanitised library document id from a result's metadata or 

141 top-level fields, or ``None`` if no usable id is present. 

142 

143 Reads (in order): 

144 

145 * ``metadata["doc_id"]`` — legacy key. 

146 * ``metadata["source_id"]`` — used by the current LibraryRAGSearchEngine. 

147 * ``metadata["document_id"]`` — alternate key. 

148 * ``top_level[0]``'s ``source_id`` / ``document_id`` — for results that 

149 promote the id to the top-level dict instead of metadata. 

150 

151 Sanitisation: 

152 

153 * Non-strings are coerced: ``int`` → ``str``. 

154 * Strings are stripped; non-empty values containing only 

155 alphanumerics, dashes, and underscores pass. Anything else 

156 (whitespace, path traversal, control chars, ``/``) is rejected 

157 because it would be interpolated into a URL path. 

158 """ 

159 candidates: list[Any] = [] 

160 if isinstance(metadata, Mapping): 

161 candidates.append(metadata.get("doc_id")) 

162 candidates.append(metadata.get("source_id")) 

163 candidates.append(metadata.get("document_id")) 

164 for obj in top_level: 

165 if isinstance(obj, Mapping): 

166 candidates.append(obj.get("doc_id")) 

167 candidates.append(obj.get("source_id")) 

168 candidates.append(obj.get("document_id")) 

169 

170 for raw in candidates: 

171 if raw is None: 

172 continue 

173 if isinstance(raw, int) and not isinstance(raw, bool): 

174 return str(raw) 

175 if isinstance(raw, str): 

176 stripped = raw.strip() 

177 if not stripped: 

178 continue 

179 if not is_safe_document_id(stripped): 

180 continue 

181 return stripped 

182 return None 

183 

184 

185def is_safe_document_id(value: str) -> bool: 

186 """Return ``True`` if *value* is safe to interpolate into a URL path. 

187 

188 **ASCII** alphanumerics, dashes and underscores only — which excludes 

189 ``/``, ``?``, ``#``, ``%``, whitespace, control characters and ``..`` 

190 traversal. ASCII matters: ``str.isalnum()`` is true for every Unicode 

191 letter and digit, so a fullwidth ``"\uff17"`` would pass, be emitted 

192 unencoded into a URL path, and then key separately from its 

193 ``%EF%BC%97`` form — one document, two bibliography entries. 

194 

195 Shared by :func:`extract_document_id`, :func:`build_chunk_anchor_url` 

196 and ``url_utils``'s library-route parser so none of them can drift 

197 apart. 

198 """ 

199 return isinstance(value, str) and _SAFE_ID_RE.match(value) is not None 

200 

201 

202def is_library_chunk_result(result: Mapping[str, Any] | None) -> bool: 

203 """Return ``True`` if *result* is a library/RAG hit that should receive 

204 a chunk anchor. 

205 

206 Heuristic: the result's ``source`` / ``source_type`` field is 

207 ``"library"`` or its link *is* a library-document route (see 

208 :func:`is_library_document_link`). Mirrors the producer-side checks in 

209 :class:`langgraph_agent_strategy.SearchResultsCollector` so the two 

210 agree on the eligibility rule. 

211 """ 

212 if not isinstance(result, Mapping): 

213 return False 

214 if result.get("source") == "library": 

215 return True 

216 if result.get("source_type") == "library": 

217 return True 

218 return is_library_document_link(result.get("link") or result.get("url")) 

219 

220 

221def _alias_host(stripped: str) -> str | None: 

222 """Return the lowercased host of an ``https://<host>/...`` URL, or ``None``. 

223 

224 Userinfo and port are removed so ``https://u@library.document:443/x`` 

225 and ``https://library.document/x`` give the same host. A trailing 

226 ``/`` is required, so a bare authority with no path is not a document 

227 link — matching what the literal-prefix test accepted before. 

228 """ 

229 if not stripped.lower().startswith(_LIBRARY_ALIAS_SCHEME_PREFIX): 

230 return None 

231 rest = stripped[len(_LIBRARY_ALIAS_SCHEME_PREFIX) :] 

232 authority, sep, _ = rest.partition("/") 

233 if not sep: 

234 return None 

235 # Userinfo may itself contain ``@``; the host is after the LAST one. 

236 authority = authority.rpartition("@")[2] 

237 if authority.startswith("["): 237 ↛ 239line 237 didn't jump to line 239 because the condition on line 237 was never true

238 # IPv6 literal: the port, if any, follows the closing bracket. 

239 host = authority.partition("]")[0] + "]" 

240 else: 

241 host = authority.partition(":")[0] 

242 return host.lower() 

243 

244 

245def is_library_document_link(link: Any) -> bool: 

246 """Return ``True`` if *link* addresses a local library document. 

247 

248 Anchored at the start of the URL on purpose. A substring test 

249 (``"/library/document/" in link``) also matches 

250 ``https://evil.example/library/document/7/chunks``, and the caller 

251 reacts by REPLACING the link with a local route — silently relabelling 

252 an external result as a document in the user's own library. 

253 """ 

254 if not isinstance(link, str): 

255 return False 

256 stripped = link.strip() 

257 if stripped.startswith(LIBRARY_ROUTE_PREFIXES): 

258 return True 

259 # The absolute alias the agent sometimes emits. Matched on the HOST, 

260 # not on a literal ``https://library.document/`` prefix: the prefix 

261 # form misses the ``:443`` and userinfo spellings that 

262 # ``url_utils._normalize_library_alias`` deliberately accepts as the 

263 # same document, so a caller asking "is this a library citation" got 

264 # False for a string the renderer would happily normalise. Callers 

265 # that strip an unusable fragment then skipped those spellings 

266 # entirely, and the raw value reached the DB and the MCP payload. 

267 # 

268 # Parsed by hand rather than with ``urlsplit``, which silently DELETES 

269 # embedded tab/newline/CR — the very characters that make a crafted 

270 # alias worth catching here, and which would otherwise let 

271 # ``library.doc\tument`` answer for ``library.document``. 

272 return _alias_host(stripped) == LIBRARY_ALIAS_HOST 

273 

274 

275def build_chunk_anchor_url( 

276 link: str, 

277 doc_id: Optional[str], 

278 chunk_index: Optional[int], 

279) -> Optional[str]: 

280 """Build a ``/library/document/<doc_id>/chunks#chunk-<n>`` URL when 

281 *doc_id* and *chunk_index* are both valid, otherwise return ``None``. 

282 

283 Returns ``None`` (rather than mutating *link*) when: 

284 

285 * the chunk index failed validation (UUID, bool, negative, string, 

286 float with a fractional part, or any other non-int-like value), or 

287 * the document id is missing, or is not composed solely of 

288 alphanumerics, dashes and underscores (see 

289 :func:`is_safe_document_id`). 

290 

291 Callers should leave the original *link* unchanged on ``None`` — 

292 appending ``#chunk-...`` to whatever route was already in the link 

293 would point the anchor at an unrelated route when the doc id failed 

294 sanitisation. 

295 """ 

296 # Re-validate ``chunk_index`` defensively. Most callers should have 

297 # already passed it through :func:`extract_chunk_index`, but a 

298 # future caller that passes a raw value (e.g. a producer that 

299 # forgot to validate) must not be able to inject a malformed 

300 # fragment. ``int`` excludes ``bool`` (which is technically an 

301 # ``int`` subclass) and ``None``. 

302 # Enforce the doc-id contract here rather than trusting the caller. 

303 # Every in-tree caller runs ``extract_document_id`` first, but this 

304 # function BUILDS the URL, so an unvalidated id would be interpolated 

305 # straight into the path — a ``../..`` id yields a traversal route, 

306 # and an id containing ``#`` yields two fragments. The 

307 # docstring above has always promised a rejected id returns ``None``; 

308 # this makes that true. 

309 # Accept exactly what ``extract_document_id`` accepts — ``int`` 

310 # (excluding ``bool``) or ``str`` — so the two genuinely cannot drift, 

311 # and build the URL from the STRIPPED value that was validated rather 

312 # than from the raw argument, or leading/trailing control characters 

313 # survive into the returned URL. 

314 if doc_id is None or isinstance(doc_id, bool): 

315 return None 

316 if not isinstance(doc_id, (int, str)): 

317 return None 

318 safe_doc_id = str(doc_id).strip() 

319 if not is_safe_document_id(safe_doc_id): 

320 return None 

321 if ( 

322 chunk_index is None 

323 or isinstance(chunk_index, bool) 

324 or not isinstance(chunk_index, int) 

325 or chunk_index < 0 

326 or chunk_index > MAX_CHUNK_INDEX 

327 ): 

328 return None 

329 return f"/library/document/{safe_doc_id}/chunks#chunk-{chunk_index}"