Coverage for src/local_deep_research/utilities/search_utilities.py: 91%

746 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1import re 

2from typing import Dict, List, Optional, Set, Tuple 

3from urllib.parse import urlsplit 

4 

5from loguru import logger 

6 

7from local_deep_research.text_optimization.citation_formatter import ( 

8 is_line_breaking_char, 

9 LDR_APPENDED_SOURCES_SENTINEL, 

10) 

11from .url_utils import ( 

12 CHUNK_DISPLAY_KEY, 

13 FETCHED_TITLE_KEY, 

14 _is_wayback_replay_url, 

15 canonical_url_key, 

16 library_display_url, 

17 preferred_chunk_display, 

18) 

19 

20 

21LANGUAGE_CODE_MAP = { 

22 "english": "en", 

23 "french": "fr", 

24 "german": "de", 

25 "spanish": "es", 

26 "italian": "it", 

27 "japanese": "ja", 

28 "chinese": "zh", 

29 "hindi": "hi", 

30 "arabic": "ar", 

31 "bengali": "bn", 

32 "portuguese": "pt", 

33 "russian": "ru", 

34 "korean": "ko", 

35} 

36 

37 

38def remove_think_tags(text: str) -> str: 

39 # NOTE: Fresh LLM responses from get_llm() are already <think>-stripped 

40 # centrally by ProcessingLLMWrapper (config/llm_config.py). Use this only on 

41 # text NOT from a fresh wrapped invoke (accumulated/concatenated text, or 

42 # agent/bind_tools output that bypasses the wrapper). 

43 # Remove paired <think>...</think> tags 

44 text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL) 

45 # Remove any orphaned opening or closing think tags 

46 text = re.sub(r"</think>", "", text) 

47 text = re.sub(r"<think>", "", text) 

48 return text.strip() 

49 

50 

51# Sentinel values used by the journal reputation filter alongside the 

52# numeric 1-10 quality scores. Distinguish structurally different 

53# "not scored" cases so the renderer can show the user *why* the tag 

54# isn't a numeric quality tier: 

55# 

56# - QUALITY_PENDING: reference DB hadn't finished building when the 

57# search ran (first-search-during-install case). 

58# - QUALITY_PREPRINT: result has no journal_ref at all (pure arxiv 

59# preprint or similar); there's no venue to score. Distinct from 

60# "venue unknown to our catalog" (that becomes score 3, rendered 

61# as Unranked). 

62QUALITY_PENDING = "pending" 

63QUALITY_PREPRINT = "preprint" 

64 

65 

66def _format_quality_tag(quality) -> str: 

67 """Format a journal quality score as a compact tag for source lists. 

68 

69 The output is plaintext / Markdown. **Do NOT** render the containing 

70 string through a template filter like ``{{ foo|safe }}`` or 

71 ``DOMPurify.sanitize(..., {ALLOWED_TAGS:['a']})`` without first HTML- 

72 escaping the surrounding title — the tag itself is safe, but a 

73 downstream caller that concatenates ``title + quality_tag`` and 

74 emits the result as HTML will leak any tags in ``title`` (XSS). 

75 

76 See :func:`_format_quality_tag_html` for the HTML-safe variant. 

77 

78 Accepts int | None for scored journals, plus the string sentinels 

79 ``QUALITY_PENDING`` and ``QUALITY_PREPRINT``. Every numeric value 

80 in VALID_QUALITY_SCORES has its own explicit branch so a bad 

81 scoring-logic change can't silently rebucket a score — unexpected 

82 values fall through to a debug tag that shows the raw value. 

83 """ 

84 if quality is None: 

85 return "" 

86 if quality == QUALITY_PENDING: 

87 return ( 

88 " [journal quality data is downloading in the background; " 

89 "by the time you open /metrics/journals it may already " 

90 "be complete — re-run this search in a minute to get " 

91 "real quality scores]" 

92 ) 

93 if quality == QUALITY_PREPRINT: 

94 # No venue at all (arxiv preprint / working paper / dataset). 

95 # Distinct from score 3 ("we looked and didn't find the 

96 # venue") — here there's nothing *to* look up. 

97 return " [preprint — not in journal catalog]" 

98 # Numeric tiers. Explicit per-score branches instead of ``>=`` 

99 # ranges so boundary changes can't silently shift a bucket. 

100 if quality == 10: 

101 return " [Q1 ★★★★★]" 

102 # KNOWN-DEFERRED: quality == 9 is a dead branch — 

103 # constants.VALID_QUALITY_SCORES excludes 9 and the filter rejects 

104 # any LLM output of that value. Kept defensively so a future change 

105 # to VALID_QUALITY_SCORES does not require editing the formatter. 

106 # Post-merge candidate for removal together with any score-9 

107 # reintroduction work. 

108 if quality == 9: 

109 return " [Q1 ★★★★★]" 

110 if quality == 8: 

111 return " [Q1 ★★★★]" 

112 if quality == 7: 

113 return " [Q1 ★★★★]" 

114 if quality == 6: 

115 return " [Q2 ★★★]" 

116 if quality == 5: 

117 return " [Q2 ★★★]" 

118 if quality == 4: 

119 # JOURNAL_QUALITY_DEFAULT — venue found in the catalog but 

120 # with no h-index / quartile / DOAJ signal. 

121 return " [Unranked ★]" 

122 if quality == 3: 

123 # Low-confidence fallback — venue didn't match any tier. We 

124 # don't know the journal, not "we know it's low-quality". 

125 return " [Unranked ★]" 

126 if quality == 2: 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true

127 return " [Q4 ★]" 

128 if quality == 1: 

129 # Predatory. Usually auto-removed before this renderer sees 

130 # it, but surfaces if whitelisted or the threshold is 1. 

131 return " [Q4 ★]" 

132 # Out-of-set value — VALID_QUALITY_SCORES gates the inputs so this 

133 # is unreachable in normal operation. Show the raw value so bad 

134 # data surfaces visibly instead of silently bucketing into Q4. 

135 return f" [quality={quality!r}]" 

136 

137 

138def _format_quality_tag_html(quality, *, title: str = "") -> str: 

139 """HTML-safe wrapper for :func:`_format_quality_tag`. 

140 

141 Callers that render search-result titles + quality tags into an 

142 HTML page must use this variant and pass the raw ``title`` so both 

143 are escaped together. The quality tag itself is plaintext, but the 

144 brackets and stars are safe to emit verbatim — the danger is the 

145 untrusted ``title`` that a downstream HTML template may concatenate 

146 alongside the tag. 

147 

148 Returns: 

149 ``"{escaped_title}{quality_tag}"`` where ``escaped_title`` is 

150 HTML-escaped with ``html.escape(..., quote=True)`` so quotes, 

151 angle brackets, and ampersands are rendered as text. 

152 """ 

153 import html as _html 

154 

155 return _html.escape(title, quote=True) + _format_quality_tag(quality) 

156 

157 

158def extract_links_from_search_results(search_results: List[Dict]) -> List[Dict]: 

159 """ 

160 Extracts links and titles from a list of search result dictionaries. 

161 

162 Each dictionary is expected to have at least the keys "title" and "link". 

163 

164 Returns a list of dictionaries with 'title' and 'url' keys. 

165 """ 

166 links = [] 

167 if not search_results: 

168 return links 

169 

170 for result in search_results: 

171 try: 

172 # Ensure we handle None values safely before calling strip() 

173 title = result.get("title", "") 

174 url = result.get("link", "") 

175 index = result.get("index", "") 

176 

177 # Apply strip() only if the values are not None 

178 title = title.strip() if title is not None else "" 

179 url = url.strip() if url is not None else "" 

180 index = index.strip() if index is not None else "" 

181 

182 if title and url: 

183 link = { 

184 "title": title, 

185 "url": url, 

186 "index": index, 

187 "journal_quality": result.get("journal_quality"), 

188 } 

189 # Preserve citation-relevant fields from search engines 

190 # so they reach the database (previously lost here) 

191 for key in ( 

192 "doi", 

193 "authors", 

194 "published", 

195 "publication_date", 

196 "year", 

197 "date", 

198 "volume", 

199 "issue", 

200 "pages", 

201 "journal_ref", 

202 "journal", 

203 "venue", 

204 "publisher", 

205 "source_type", 

206 # Keep verified capture provenance through the live 

207 # Sources rebuild, including its archived-date suffix. 

208 "original_url", 

209 "requested_url", 

210 "timestamp", 

211 "formatted_date", 

212 "openalex_source_id", 

213 "source", 

214 "source_engine", 

215 "pmid", 

216 "pmcid", 

217 "arxiv_id", 

218 "isbn", 

219 "citations", 

220 "is_open_access", 

221 "abstract", 

222 "metadata", 

223 # The collector's own page title. This rebuild drops 

224 # every key not named here, so the live report path 

225 # (``_format_citations`` -> here -> the renderer) lost 

226 # it while a direct render kept it. 

227 FETCHED_TITLE_KEY, 

228 ): 

229 val = result.get(key) 

230 if val is not None: 

231 link[key] = val 

232 links.append(link) 

233 except Exception: 

234 # Log the specific error for debugging 

235 logger.exception("Error extracting link from result") 

236 continue 

237 return links 

238 

239 

240def _sanitize_sources_field(value: str) -> str: 

241 """Flatten a value being rendered into the Sources block. 

242 

243 Titles come from search-result metadata — i.e. from whatever a page 

244 calls itself — and were previously rendered verbatim, so a crafted 

245 title could forge a whole extra numbered citation pointing anywhere. 

246 URLs get the same treatment because the non-library canonical-key 

247 fallback returns arbitrary scheme-less paths unchanged. 

248 

249 Control characters are replaced with a space rather than dropped, so a 

250 forged ``\n[9] Fake`` degrades to visible text on the same line instead 

251 of silently vanishing. 

252 """ 

253 if not value: 

254 return value 

255 return "".join( 

256 " " if is_line_breaking_char(ch) else ch for ch in value 

257 ).strip() 

258 

259 

260def _owned_chunk_display(recorded: object, canon: str) -> str | None: 

261 """Return a recorded chunk anchor only if it names *this* citation. 

262 

263 .. note:: 

264 This check is the PRIMARY control, not defence in depth. Only 

265 ``SearchResultsCollector`` strips a producer-supplied key at 

266 ingest, and it exists solely in the LangGraph strategy — 

267 ``source_based``, ``focused_iteration`` and 

268 ``topic_organization`` all extend ``all_links_of_system`` with 

269 raw engine dicts. So on those paths a key present here is 

270 producer-supplied by construction, and the residual is bounded to 

271 what this function permits: a wrong chunk or view segment of the 

272 correctly-identified document, never a foreign document or an 

273 arbitrary URL. 

274 

275 Shape validation alone is not enough. The value is preferred over the 

276 entry's own url, so a well-formed anchor for a DIFFERENT document — 

277 which arrives for free, since ``add_results`` copies the engine's dict 

278 — would render and persist under this citation. The writer refuses to 

279 record a foreign anchor; the reader has to refuse to read one, or the 

280 two disagree about which spelling is authoritative. 

281 

282 Comparing canonical keys is exact here: ``canonical_url_key`` collapses 

283 every view of a library document onto one key, so an anchor for the 

284 same document matches and an anchor for any other does not. 

285 """ 

286 if not isinstance(recorded, str): 

287 return None 

288 display = preferred_chunk_display(recorded) 

289 if display is None: 289 ↛ 290line 289 didn't jump to line 290 because the condition on line 289 was never true

290 return None 

291 return display if canonical_url_key(display) == canon else None 

292 

293 

294def source_url_field(link: Dict) -> object: 

295 """The field a source's identity is read from: ``link`` first. 

296 

297 ``SearchResultsCollector`` keys citations on 

298 ``_citation_dedup_key(result["link"])``, and records the authoritative 

299 chunk anchor or normalized URL in ``link``. A result carrying BOTH 

300 fields with divergent values (e.g. ``link`` carrying the collector's 

301 rebuilt anchor while ``url`` retains an anchor-less engine string) must 

302 consistently read ``link`` so the entry groups under its intended chunk 

303 anchor rather than fanning out into an unintended second group. 

304 

305 ``link`` wins because it is what the collector keys on, what 

306 ``_format_results`` shows the agent next to the ``[N]`` marker, and 

307 what ``find_by_url`` resolves — i.e. it is what the citation index 

308 actually means. ``url`` remains the fallback for the raw engine dicts 

309 the non-LangGraph strategies extend ``all_links_of_system`` with, 

310 some of which set only that key. 

311 """ 

312 return link.get("link") or link.get("url") or "" 

313 

314 

315# --------------------------------------------------------------------------- 

316# Uncited-source filter engine (#5379). 

317# 

318# Heuristic parser: extracts citation signals (numeric bracket indices, 

319# tagged citations, "Source N" phrases, inline URLs) from report prose so 

320# the final ``## Sources`` bibliography can drop accumulated search hits 

321# that were never cited. Over-inclusion is safe (extra bibliography line); 

322# under-inclusion silently drops a cited source, so every ambiguous or 

323# indeterminate case falls back to the full list in ``fallback`` mode. 

324# --------------------------------------------------------------------------- 

325 

326_MAX_RANGE_SPAN = 1000 

327_MAX_TOTAL_EXPANSION = 5000 

328_MAX_RANGE_ENDPOINT_DIGITS = 18 

329# This bounds candidate expansion work, not the report or its context. On 

330# exhaustion the parser becomes indeterminate and keeps the full bibliography. 

331_MAX_URL_CANDIDATE_CHARS = 1_000_000 

332 

333 

334class _URLCandidateBudgetExceeded(Exception): 

335 """The citation parser must fall back before expanding more candidates.""" 

336 

337 

338class _URLCandidateBudget: 

339 def __init__(self): 

340 self.remaining = _MAX_URL_CANDIDATE_CHARS 

341 

342 def consume(self, characters: int) -> None: 

343 if characters > self.remaining: 

344 raise _URLCandidateBudgetExceeded 

345 self.remaining -= characters 

346 

347 

348_FENCE_MARKERS = ("```", "~~~") 

349 

350# Ordinary prose tokens that must NOT force the indeterminate fallback: 

351# ordinals ("1st", "2nd", "3rd"), units with g-z suffixes ("5x", "100m"), 

352# multi-letter suffixes ("12px"), percentages ("50%"). Single bracket 

353# tokens of digits immediately followed by such suffixes are prose, not 

354# attempted citations. Only contents with list/range syntax (",", "-", 

355# "–", "—", "and", "&") look like attempted citation syntax and may 

356# trigger indeterminate. 

357# 

358# Single-letter a-f suffixes ("3a", "4b", "12c") are DELIBERATELY excluded: 

359# they are regex-identical to sub-reference citations, so they stay on the 

360# safe (indeterminate → fallback) side instead of being silently ignored. 

361_PROSE_TOKEN_RE = re.compile(r"\d+[A-Za-z%]+") 

362_SUBREF_SINGLE_LETTERS = frozenset("abcdefABCDEF") 

363_LIST_SYNTAX_RE = re.compile(r",|[\-–—]|&|\band\b", re.IGNORECASE) 

364 

365# Single/double ASCII + CJK full-width brackets with numeric content: 

366# [N], [N, M], [[N]], 【N】, [N-M], [N–M], [ 1, 2 ] (outer spaces allowed). 

367# Letters excluded so [GPT-4]/[COVID-19] never match here 

368# (handled/rejected as tagged). 

369_BRACKET_NUMERIC_RE = re.compile( 

370 r"[\[【]\[?\^?\s*([0-9][0-9\s,\-–—]*[0-9]|[0-9])\s*\]?[\]】]" 

371) 

372 

373# Tagged citations: [arxiv-1], [arxiv.org-2], [openai.com-2]. Tag must 

374# contain a letter or dot so pure ranges ([1-3]) never match as tag "1". 

375_TAGGED_CITATION_RE = re.compile(r"\[(?P<tag>[A-Za-z0-9._/\-]+)-(?P<idx>\d+)\]") 

376 

377# "Source 1", "Sources 1 and 2", "sources 1, 2 & 3", "Sources 1-3" 

378# (case-insensitive). Dash/en-dash/em-dash ranges are part of the capture 

379# so "Sources 1-3" parses the whole range instead of just the "1" prefix 

380# (which previously kept only source 1 with no fallback). 

381_SOURCE_PHRASE_RE = re.compile( 

382 r"(?i)\bsources?\s+(?P<nums>\d+(?:\s*[\-–—]\s*\d+)?" 

383 r"(?:\s*(?:,|and|&)\s*\d+(?:\s*[\-–—]\s*\d+)?)*)" 

384) 

385 

386# Broad capture; paren-balance + trailing-punct peeling happens per match. 

387# Bracketed IP-literal authorities need their own branch: brackets delimit 

388# the host here, while brackets elsewhere still delimit prose citations. 

389_IP_LITERAL_AUTHORITY = ( 

390 r"https?://(?:[^\s<>\[\]\"/?#@]*@)?\[[^\s<>\[\]\"/?#@]+\]" 

391) 

392_IP_LITERAL_URL_RE = re.compile(_IP_LITERAL_AUTHORITY, re.IGNORECASE) 

393 

394# Replay targets can contain IP authorities and bracketed path/query values. 

395# A replay-specific scanner retains nested pairs while unmatched closing 

396# brackets still delimit surrounding prose, as they do for ordinary URLs. 

397_WAYBACK_REPLAY_PREFIX = ( 

398 r"https?://web\.archive\.org(?::(?:80|443))?/web/" 

399 r"[0-9]{14}(?:id_|if_)?/https?://" 

400) 

401_WAYBACK_REPLAY_PREFIX_RE = re.compile(_WAYBACK_REPLAY_PREFIX, re.IGNORECASE) 

402_WAYBACK_REPLAY_URL = _WAYBACK_REPLAY_PREFIX + r'[^\s<>"]+' 

403 

404# In addition to absolute URLs and markdown-link destinations 

405# (``](/library/…)``), bare library routes (``/library/document/…``, 

406# ``/lib/document/…``) are in scope by design: prose commonly mentions 

407# them outside link syntax, and dropping a bare-path mention is the same 

408# citation-loss class as dropping a markdown destination. 

409_ORDINARY_ABSOLUTE_URL = ( 

410 rf"{_IP_LITERAL_AUTHORITY}[^\s<>\[\]\"]*" 

411 r"|https?://[^\s<>\[\]\"]+" 

412) 

413_RELATIVE_CITATION_URL = ( 

414 r"|\]\([ \t]*(?P<relative>/[^\s<>\"]*)" 

415 r"|(?P<bare_library>/(?:library|lib)/document/[^\s<>\[\]\"]+)" 

416) 

417_URL_RE = re.compile( 

418 rf"(?P<absolute>{_WAYBACK_REPLAY_URL}|{_ORDINARY_ABSOLUTE_URL})" 

419 + _RELATIVE_CITATION_URL, 

420 re.IGNORECASE, 

421) 

422# Find a replay prefix before scanning its target. Capturing the whole 

423# remaining suffix here would rescan adjacent wrapped links quadratically. 

424_URL_START_RE = re.compile( 

425 rf"(?P<absolute>{_WAYBACK_REPLAY_PREFIX}|{_ORDINARY_ABSOLUTE_URL})" 

426 + _RELATIVE_CITATION_URL, 

427 re.IGNORECASE, 

428) 

429 

430# ``canonical_url_key`` lowercases scheme/host but keeps path case, and the 

431# library-route parser matches ``/library/document/`` case-sensitively 

432# (like the Flask route and ``library_resolver``). The prose regex above 

433# captures ``/LIBRARY/Document/…`` case variants, so normalize just the 

434# route prefix to lowercase before keying — the doc id itself keeps its 

435# case. Without this the mention is captured but never matches its link. 

436_BARE_LIBRARY_PREFIX_RE = re.compile( 

437 r"^/(?:library|lib)/document/", re.IGNORECASE 

438) 

439 

440 

441def _normalize_bare_library_prefix(candidate: str) -> str: 

442 """Lowercase only the ``/library|lib/document/`` route prefix.""" 

443 return _BARE_LIBRARY_PREFIX_RE.sub("/library/document/", candidate) 

444 

445 

446# Sentence-final + CJK punctuation stripped from prose URLs. Deliberately 

447# excludes closing delimiters ( ) ] } > ' " ` ) — those are balanced 

448# separately so a balanced ")" before a final "." survives. 

449_URL_SENTENCE_PUNCT = ".,;:!?。、,!?;:「」『』、…·—–" 

450_URL_WRAPPER_PUNCT = "'\"`]}>】”’»》“‘«›" 

451_URL_WRAPPERS = { 

452 "(": ")", 

453 "[": "]", 

454 "{": "}", 

455 "【": "】", 

456 "「": "」", 

457 "『": "』", 

458 "《": "》", 

459 "〈": "〉", 

460 "“": "”", 

461 "„": "“", 

462 "‘": "’", 

463 "‚": "‘", 

464 "«": "»", 

465 "»": "«", 

466 "‹": "›", 

467 "'": "'", 

468 '"': '"', 

469 "`": "`", 

470 "*": "*", 

471 "_": "_", 

472} 

473_MARKDOWN_URL_END_RE = re.compile( 

474 r"[ \t\r\n]*(?:(?:\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'" 

475 r"|\((?:\\.|[^()\\])*\))[ \t\r\n]*)?\)" 

476) 

477_MARKDOWN_URL_ESCAPE_RE = re.compile(r"\\([!-/:-@\[-`{-~])") 

478 

479UNCITED_SOURCES_MODE_KEY = "report.uncited_sources_mode" 

480UNCITED_SOURCES_MODES = ("fallback", "strict", "disabled") 

481UNCITED_SOURCES_DEFAULT = "fallback" 

482 

483 

484def resolve_uncited_sources_mode(raw=None, settings_snapshot=None) -> str: 

485 """Normalize the ``report.uncited_sources_mode`` setting. 

486 

487 ``raw`` is an explicitly provided value (e.g. from a strategy's 

488 settings snapshot, already unwrapped). ``None`` resolves from 

489 ``settings_snapshot`` (when given) or the thread settings context, 

490 defaulting to ``"fallback"`` when no context is bound. Unknown values 

491 degrade to ``"fallback"`` so a stale or hand-edited setting can never 

492 wipe out a bibliography. Settings lookup failures also degrade to the 

493 default: rendering a report must never crash on a preference read. 

494 """ 

495 if raw is None: 

496 try: 

497 from ..config.thread_settings import get_setting_from_snapshot 

498 

499 raw = get_setting_from_snapshot( 

500 UNCITED_SOURCES_MODE_KEY, 

501 UNCITED_SOURCES_DEFAULT, 

502 settings_snapshot=settings_snapshot, 

503 ) 

504 except Exception: 

505 return UNCITED_SOURCES_DEFAULT 

506 if isinstance(raw, dict): 506 ↛ 507line 506 didn't jump to line 507 because the condition on line 506 was never true

507 raw = raw.get("value", UNCITED_SOURCES_DEFAULT) 

508 if isinstance(raw, str) and raw.strip() in UNCITED_SOURCES_MODES: 508 ↛ 510line 508 didn't jump to line 510 because the condition on line 508 was always true

509 return raw.strip() 

510 return UNCITED_SOURCES_DEFAULT 

511 

512 

513def _strip_fence_prefix(s: str) -> str: 

514 """Strip Markdown quote/list prefixes from a line-start string. 

515 

516 Handles ``>``, ``-``/``*``/``+``, ``1.``/``1)`` nesting (e.g. 

517 ``> - ```js``). Capped at 32 iterations so a hostile single line 

518 of ``\"1. \"`` repetitions cannot turn the per-line slice into a 

519 quadratic scan; realistic nesting is single digits. 

520 """ 

521 for _ in range(32): 521 ↛ 536line 521 didn't jump to line 536 because the loop on line 521 didn't complete

522 if s.startswith(">"): 

523 s = s[1:].lstrip(" \t") 

524 continue 

525 if len(s) >= 2 and s[0] in "-*+" and s[1] in " \t": 

526 i = 1 

527 while i < len(s) and s[i] in " \t": 

528 i += 1 

529 s = s[i:] 

530 continue 

531 m = re.match(r"\d+[.)][ \t]+", s) 

532 if m: 

533 s = s[m.end() :] 

534 continue 

535 break 

536 return s 

537 

538 

539def _is_fence_line(line: str, marker: str) -> bool: 

540 """True when ``line`` can *open* a ``marker`` code block. 

541 

542 A fence is recognized at the start of a line after optional 

543 spaces/tabs indent *and* optional Markdown quote/list prefixes 

544 (``>``, ``-``/``*``/``+``, ``1.``/``1)`` — e.g. ``> ```json`` or 

545 ``- ```js``). Those shapes are how models quote tool output and 

546 search results; ignoring the opener while counting a later plain 

547 ````` closer pairs the closer with the next real opener and strips 

548 real citations between them (source loss in the default mode). 

549 

550 A line whose stripped remainder starts with ``marker`` but contains 

551 a second ``marker`` *after the initial fence run* (````` decorate 

552 [8] ``` end``) is a decorative single-line run, not an opener, and 

553 returns False so it never opens a block. A longer single run 

554 (`` `````` ``) has no second marker after its run and still opens. 

555 Closers are deliberately not vetted here — see 

556 :func:`_is_fence_closer_line`. 

557 """ 

558 content = line.rstrip("\r\n") 

559 s = _strip_fence_prefix(content.lstrip(" \t")) 

560 if not s.startswith(marker): 

561 return False 

562 # Initial fence run (3+ backticks/tildes): a longer single run is 

563 # one fence, not two. 

564 fence_char = marker[0] 

565 run = 0 

566 while run < len(s) and s[run] == fence_char: 

567 run += 1 

568 # Second marker after the run → decorative single-line run. 

569 if marker in s[run:]: 

570 return False 

571 return True 

572 

573 

574def _is_fence_closer_line(line: str, marker: str) -> bool: 

575 """True when ``line`` can *close* an open ``marker`` code block. 

576 

577 Same prefix rule as the opener, but with no double-marker veto: a 

578 closer carrying trailing text (````` done ``` ``) or a longer fence 

579 (`` `````` `` closing a `` ``` `` block) must still close. Vetoing 

580 it here pairs the opener with the *next* opener and strips real 

581 prose between them with no indeterminate fallback. 

582 """ 

583 content = line.rstrip("\r\n") 

584 s = _strip_fence_prefix(content.lstrip(" \t")) 

585 return s.startswith(marker) 

586 

587 

588def _find_fence_positions(prose: str, marker: str) -> list[int]: 

589 """Return line-start offsets of ``marker`` fence *openers*. 

590 

591 Recognizes line-anchored markers plus blockquoted/bulleted forms 

592 (``> ```json``, ``- ```js``) via :func:`_is_fence_line`; inline 

593 decorative runs and single-line double-marker runs are not openers. 

594 Closers (which carry no veto) are paired statefully in 

595 :func:`_strip_marker_blocks`, not by blind consecutive pairing. 

596 """ 

597 positions: list[int] = [] 

598 offset = 0 

599 for line in prose.splitlines(keepends=True): 

600 if _is_fence_line(line, marker): 

601 positions.append(offset) 

602 offset += len(line) 

603 return positions 

604 

605 

606def _strip_marker_blocks(prose: str, marker: str) -> tuple[str, bool]: 

607 """Strip paired ``marker`` blocks; True dangling if odd. 

608 

609 Stateful pairing: the double-marker veto applies only to *openers* 

610 (so ````` decorate [8] ``` end`` never opens), while any 

611 line-anchored ``marker`` *closes* an open block (so ````` done ``` 

612 `` and six-backtick `` `````` `` closers still close instead of 

613 letting the opener pair across real prose). Full opener-through- 

614 closer lines are excluded; an unterminated block returns the 

615 original prose with dangling True so the caller falls back to the 

616 full bibliography. 

617 """ 

618 lines = prose.splitlines(keepends=True) 

619 if not lines: 619 ↛ 620line 619 didn't jump to line 620 because the condition on line 619 was never true

620 return prose, False 

621 spans: list[tuple[int, int]] = [] 

622 in_block = False 

623 block_start = 0 

624 for i, line in enumerate(lines): 

625 if not in_block: 

626 if _is_fence_line(line, marker): 

627 in_block = True 

628 block_start = i 

629 else: 

630 if _is_fence_closer_line(line, marker): 

631 in_block = False 

632 spans.append((block_start, i)) 

633 if in_block: 

634 return prose, True 

635 if not spans: 

636 return prose, False 

637 exclude: set[int] = set() 

638 for span_start, span_end in spans: 

639 for i in range(span_start, span_end + 1): 

640 exclude.add(i) 

641 return "".join( 

642 line for i, line in enumerate(lines) if i not in exclude 

643 ), False 

644 

645 

646def _strip_fenced_code(prose: str) -> tuple[str, bool]: 

647 """Strip fenced code blocks (``` and ~~~); dangling either → indeterminate. 

648 

649 Fence lines include blockquoted/bulleted forms (``> ```json``, 

650 ``- ```js``) so a quoted opener pairs with its closer instead of 

651 letting a later plain marker pair across real prose. Inline 

652 decorative runs (``~~~ config [2] ~~~``) and single-line 

653 double-marker runs (```` ``` decorate [8] ``` end``) never *open* a 

654 block, but any line-anchored marker still *closes* an open block 

655 (so ````` done ``` `` and six-backtick closers close rather than 

656 pairing the opener across real prose). Each marker type pairs only 

657 with itself so a ``` block never swallows across a ~~~ fence. A 

658 dangling (unterminated) marker of either type marks the parse 

659 indeterminate instead of stripping half the report (a single stray 

660 fence in quoted web content would otherwise swallow real citations 

661 between it and the next block). 

662 """ 

663 indeterminate = False 

664 text = prose 

665 for marker in _FENCE_MARKERS: 

666 text, dangling = _strip_marker_blocks(text, marker) 

667 if dangling: 

668 indeterminate = True 

669 return text, indeterminate 

670 

671 

672def _is_all_uppercase_tag(tag: str) -> bool: 

673 letters = [ch for ch in tag if ch.isalpha()] 

674 return bool(letters) and all(ch.isupper() for ch in letters) 

675 

676 

677def _is_ignorable_prose_singleton(content: str) -> bool: 

678 """True when a digit-led singleton is ordinary prose, not a sub-ref.""" 

679 if "%" in content: 

680 return True 

681 m = re.fullmatch(r"\d+([A-Za-z]+)", content) 

682 if m is None: 682 ↛ 683line 682 didn't jump to line 683 because the condition on line 682 was never true

683 return True 

684 suffix = m.group(1) 

685 if len(suffix) >= 2: 

686 return True 

687 return suffix not in _SUBREF_SINGLE_LETTERS 

688 

689 

690def _is_malformed_numeric_content(content: str) -> bool: 

691 """True when a digit-led bracket looks like a broken citation list. 

692 

693 Ordinary prose singletons (ordinals ``2nd``/``1st``, ``g``-``z`` units 

694 ``5x``/``100m``, multi-letter ``12px``, percentages ``50%``) are 

695 explicitly NOT malformed: they are digits immediately followed by a 

696 safe suffix with no list/range syntax, so they are ignored instead of 

697 forcing the full-bibliography fallback (which would defeat ``strict`` 

698 mode's cited-only contract). Single-letter ``a``-``f`` suffixes 

699 (``3a``/``4b``/``12c``) are sub-reference-shaped and stay indeterminate 

700 so fallback preserves the bibliography. Only contents containing 

701 list/range syntax (``,``, ``-``/``–``/``—``, ``and``/``&``) look like 

702 attempted citation syntax; a bad chunk there 

703 (``[1,abc,3]``, ``[1-abc]``) stays indeterminate so fallback preserves 

704 the bibliography. Other singletons without list syntax (``[1 abc]``, 

705 ``[1 and 2]``) also stay indeterminate — ignoring them would risk 

706 silently dropping an intended citation. 

707 """ 

708 content = content.strip() 

709 if not content or not content[0].isascii() or not content[0].isdigit(): 

710 return False 

711 if not _LIST_SYNTAX_RE.search(content): 

712 # Single token, no list/range syntax. Prose shapes (digits + 

713 # letters/% suffix) are ignored; anything else falls through to 

714 # the chunk check below (e.g. "1 abc" has a space so it does not 

715 # match _PROSE_TOKEN_RE and is still indeterminate). 

716 if _PROSE_TOKEN_RE.fullmatch(content): 

717 if _is_ignorable_prose_singleton(content): 

718 return False 

719 for chunk in content.split(","): 

720 chunk = chunk.strip() 

721 if not chunk: 721 ↛ 722line 721 didn't jump to line 722 because the condition on line 721 was never true

722 return True 

723 if chunk.isascii() and chunk.isdigit(): 

724 continue 

725 if re.fullmatch(r"[0-9]+\s*[\-–—]\s*[0-9]+", chunk): 

726 continue 

727 return True 

728 return False 

729 

730 

731def _peel_url_candidate(raw: str) -> str: 

732 """Peel punctuation and unmatched delimiters from an ambiguous prose URL. 

733 

734 Sentence punctuation is peeled BEFORE balancing delimiters: a balanced 

735 Wikipedia-style ``...(lang).`` first loses the final ``.`` leaving a 

736 balanced ``...(lang)`` which is kept. The old order stripped ``.`` and 

737 ``)`` together via a single ``rstrip``, turning the balanced URL into 

738 the unbalanced ``...(lang`` and dropping the source. 

739 """ 

740 url = raw 

741 authority_match = _IP_LITERAL_URL_RE.match(url) 

742 # Parentheses in credentials or an IPvFuture host belong to the 

743 # authority, so they cannot balance path or prose-wrapper parentheses. 

744 suffix = url[authority_match.end() :] if authority_match else url 

745 unmatched_closing = max(0, suffix.count(")") - suffix.count("(")) 

746 unmatched_brackets = max(0, url.count("]") - url.count("[")) 

747 end = len(url) 

748 while end: 748 ↛ 763line 748 didn't jump to line 763 because the condition on line 748 was always true

749 trailing = url[end - 1] 

750 if trailing == "]" and not unmatched_brackets: 750 ↛ 752line 750 didn't jump to line 752 because the condition on line 750 was never true

751 # A balanced closing bracket belongs to an IP-literal host. 

752 break 

753 if trailing in _URL_SENTENCE_PUNCT or trailing in _URL_WRAPPER_PUNCT: 

754 if trailing == "]": 754 ↛ 755line 754 didn't jump to line 755 because the condition on line 754 was never true

755 unmatched_brackets -= 1 

756 end -= 1 

757 continue 

758 if trailing == ")" and unmatched_closing: 

759 unmatched_closing -= 1 

760 end -= 1 

761 continue 

762 break 

763 return url[:end] 

764 

765 

766def _url_citation_candidates( 

767 match: re.Match, budget: Optional[_URLCandidateBudget] = None 

768) -> Set[str]: 

769 """Preserve exact destinations; keep ambiguous bare URL interpretations.""" 

770 if budget is None: 770 ↛ 771line 770 didn't jump to line 771 because the condition on line 770 was never true

771 budget = _URLCandidateBudget() 

772 group = next( 

773 name 

774 for name in ("absolute", "relative", "bare_library") 

775 if match.group(name) is not None 

776 ) 

777 raw = match.group(group) 

778 budget.consume(len(raw)) 

779 start = match.start(group) 

780 text = match.string 

781 following = text[match.end() : match.end() + 1] 

782 if start and text[start - 1] == "<" and following == ">": 

783 # Angle brackets delimit URL content, including punctuation. 

784 return {_MARKDOWN_URL_ESCAPE_RE.sub(r"\1", raw)} 

785 if start and text[start - 1] == '"' and following == '"': 

786 # Quotes delimit the URL, but sentence punctuation inside them may 

787 # belong to the surrounding sentence; keep both readings. 

788 budget.consume(len(raw)) 

789 return {raw, _peel_url_candidate(raw)} 

790 

791 prefix = start 

792 while prefix and text[prefix - 1] in " \t\r\n": 

793 prefix -= 1 

794 if text[max(0, prefix - 2) : prefix] == "](": 

795 depth = 0 

796 escaped = False 

797 for position, char in enumerate(raw): 

798 if escaped: 798 ↛ 799line 798 didn't jump to line 799 because the condition on line 798 was never true

799 escaped = False 

800 elif char == "\\": 800 ↛ 801line 800 didn't jump to line 801 because the condition on line 800 was never true

801 escaped = True 

802 elif char == "(": 

803 depth += 1 

804 elif char == ")": 

805 if not depth: 

806 budget.consume(position) 

807 return {_MARKDOWN_URL_ESCAPE_RE.sub(r"\1", raw[:position])} 

808 depth -= 1 

809 if not depth and _MARKDOWN_URL_END_RE.match(text, match.end()): 809 ↛ 814line 809 didn't jump to line 814 because the condition on line 809 was always true

810 return {_MARKDOWN_URL_ESCAPE_RE.sub(r"\1", raw)} 

811 

812 # Remove one sentence mark outside any surrounding wrappers. Further 

813 # punctuation, underscores and stars may be significant URL content. 

814 if raw and raw[-1] in _URL_SENTENCE_PUNCT: 

815 budget.consume(len(raw) - 1) 

816 url = raw[:-1] 

817 else: 

818 url = raw 

819 wrapped = False 

820 for _ in range(32): 820 ↛ 842line 820 didn't jump to line 842 because the loop on line 820 didn't complete

821 if not start: 

822 break 

823 closer = _URL_WRAPPERS.get(text[start - 1]) 

824 if not closer or not url.endswith(closer): 

825 break 

826 authority = _IP_LITERAL_URL_RE.match(url) 

827 if closer == "]" and authority and authority.end() == len(url): 827 ↛ 829line 827 didn't jump to line 829 because the condition on line 827 was never true

828 # The captured bracket closes the host, not an outer wrapper. 

829 break 

830 if ( 

831 closer in "])" 

832 and _WAYBACK_REPLAY_PREFIX_RE.match(url) 

833 and url.count(text[start - 1]) == url.count(closer) 

834 ): 

835 # The replay branch excludes an unmatched outer closer, so a 

836 # balanced final bracket/parenthesis belongs to the target. 

837 break 

838 budget.consume(len(url) - len(closer)) 

839 url = url[: -len(closer)] 

840 start -= 1 

841 wrapped = True 

842 if wrapped: 

843 # The wrapper closer is prose, but any sentence mark it hides may 

844 # be URL content or sentence punctuation; keep both readings so 

845 # ``"https://x.example/a."`` cites ``a`` and ``a.`` alike. 

846 budget.consume(len(url)) 

847 return {url, url.rstrip(_URL_SENTENCE_PUNCT)} 

848 

849 candidates = {raw, url} 

850 if _WAYBACK_REPLAY_PREFIX_RE.match(url): 

851 # A bare replay followed immediately by [N] can spell either a 

852 # target suffix or a prose reference. Keep both readings; exact 

853 # angle/Markdown destinations already returned above. 

854 # Find the final contiguous run without retaining a match object 

855 # for every bracket. Charge each interpretation BEFORE slicing it: 

856 # preserving all prefixes as strings otherwise takes quadratic space. 

857 run_start = None 

858 previous_end = -1 

859 for reference in _BRACKET_NUMERIC_RE.finditer(url): 

860 if reference.start() != previous_end: 

861 run_start = reference.start() 

862 previous_end = reference.end() 

863 if previous_end == len(url) and run_start is not None: 

864 for reference in _BRACKET_NUMERIC_RE.finditer(url, run_start): 

865 end = reference.start() 

866 budget.consume(end) 

867 candidates.add(url[:end]) 

868 # A run of three or more trailing sentence marks reads as an 

869 # ellipsis-sized prose unit (``See https://x.example/a... Also``), so 

870 # the fully-peeled reading stays plausible too. Shorter runs peel 

871 # exactly one mark: a sentence contributes a single terminator, and 

872 # peeling deeper would strip real URL content (``Read 

873 # https://x.example/a..`` where the path genuinely ends in ``.``). 

874 peeled_end = len(raw) 

875 while peeled_end and raw[peeled_end - 1] in _URL_SENTENCE_PUNCT: 

876 peeled_end -= 1 

877 if len(raw) - peeled_end >= 3: 877 ↛ 878line 877 didn't jump to line 878 because the condition on line 877 was never true

878 budget.consume(peeled_end) 

879 candidates.add(raw[:peeled_end]) 

880 authority = _IP_LITERAL_URL_RE.match(url) 

881 suffix = url[authority.end() :] if authority else url 

882 if suffix.count(")") > suffix.count("("): 

883 # A closing parenthesis can be URL content or belong to prose 

884 # whose opening parenthesis precedes intervening words. 

885 budget.consume(len(raw)) 

886 candidates.add(_peel_url_candidate(raw)) 

887 return candidates 

888 

889 

890def _has_malformed_numeric_brackets(text: str) -> bool: 

891 """Check whole bracket contents in one pass, including long/multiline ones.""" 

892 start: Optional[int] = None 

893 for position, char in enumerate(text): 

894 if char in "[【": 

895 if start is None or position == start: 

896 start = position + 1 

897 elif char in "]】" and start is not None: 

898 content = text[start:position].lstrip() 

899 if content.startswith("^"): 

900 content = content[1:] 

901 if _is_malformed_numeric_content(content): 

902 return True 

903 start = None 

904 return False 

905 

906 

907def _mask_replay_ip_literal(match: re.Match) -> str: 

908 """Mask only a parseable IP authority inside a verified replay target.""" 

909 authority = match.group(0) 

910 try: 

911 _ = urlsplit(authority).port 

912 except ValueError: 

913 return authority 

914 return authority.replace("[", "(").replace("]", ")") 

915 

916 

917def _citation_url_matches(text: str) -> List[re.Match]: 

918 """Scan replay targets once, retaining balanced URL brackets/wrappers.""" 

919 matches: List[re.Match] = [] 

920 position = 0 

921 while (match := _URL_START_RE.search(text, position)) is not None: 

922 raw = match.group("absolute") 

923 if raw and _WAYBACK_REPLAY_PREFIX_RE.match(raw): 

924 prefix = match.start("absolute") 

925 while prefix and text[prefix - 1] in " \t\r\n": 

926 prefix -= 1 

927 parenthesized = bool(prefix and text[prefix - 1] == "(") 

928 bracket_depth = 0 

929 paren_depth = 0 

930 escaped = False 

931 end = match.end() 

932 while end < len(text): 

933 character = text[end] 

934 if character.isspace() or character in '<>"': 

935 break 

936 if escaped: 936 ↛ 937line 936 didn't jump to line 937 because the condition on line 936 was never true

937 escaped = False 

938 elif parenthesized and character == "\\": 938 ↛ 939line 938 didn't jump to line 939 because the condition on line 938 was never true

939 escaped = True 

940 elif character == "[": 

941 bracket_depth += 1 

942 elif character == "]": 

943 if not bracket_depth: 

944 break 

945 bracket_depth -= 1 

946 elif parenthesized and character == "(": 

947 paren_depth += 1 

948 elif parenthesized and character == ")": 

949 if not paren_depth: 

950 break 

951 paren_depth -= 1 

952 end += 1 

953 bounded = _URL_RE.match(text, match.start(), end) 

954 if bounded is not None: 954 ↛ 956line 954 didn't jump to line 956 because the condition on line 954 was always true

955 match = bounded 

956 matches.append(match) 

957 position = match.end() 

958 return matches 

959 

960 

961def _mask_ip_literal_authority( 

962 match: re.Match, budget: Optional[_URLCandidateBudget] = None 

963) -> str: 

964 """Keep valid captured URL hosts out of the numeric citation parser.""" 

965 url = match.group("absolute") 

966 if not url: 

967 return match.group(0) 

968 authority_match = _IP_LITERAL_URL_RE.match(url) 

969 if authority_match is None: 

970 if _WAYBACK_REPLAY_PREFIX_RE.match(url): 

971 for candidate in _url_citation_candidates(match, budget): 971 ↛ 983line 971 didn't jump to line 983 because the loop on line 971 didn't complete

972 try: 

973 if not _is_wayback_replay_url( 973 ↛ 976line 973 didn't jump to line 976 because the condition on line 973 was never true

974 candidate, urlsplit(candidate) 

975 ): 

976 continue 

977 except ValueError: 

978 continue 

979 # Only IP-host brackets are masked. Path/query brackets 

980 # and surrounding numeric or malformed references remain 

981 # visible to the normal citation parser. 

982 return _IP_LITERAL_URL_RE.sub(_mask_replay_ip_literal, url) 

983 return url 

984 for candidate in _url_citation_candidates(match, budget): 

985 if _IP_LITERAL_URL_RE.match(candidate) is None: 

986 # A malformed Markdown destination can end before its host. 

987 continue 

988 try: 

989 # Accessing port rejects malformed or out-of-range ports. 

990 _ = urlsplit(candidate).port 

991 except ValueError: 

992 continue 

993 break 

994 else: 

995 # An invalid authority must not hide an ambiguous citation bracket. 

996 return url 

997 authority = authority_match.group(0) 

998 # Retain URL text so masking cannot join citation phrases or ranges 

999 # across an authority. Non-whitespace delimiters also keep a zone ID 

1000 # ending in "Sources" separate from any number following the URL. 

1001 masked_authority = authority.replace("[", "(").replace("]", ")") 

1002 return masked_authority + url[authority_match.end() :] 

1003 

1004 

1005def _parse_bracket_content( 

1006 content: str, indices: Set[int], state: dict 

1007) -> None: 

1008 """Expand comma lists + dash/en-dash ranges into ``indices``. 

1009 

1010 Sets ``state["indeterminate"]`` when any range exceeds span, digit, 

1011 or aggregate budgets instead of keeping endpoints-only (which would 

1012 suppress the safety-net fallback with a partial result). 

1013 """ 

1014 for chunk in content.split(","): 

1015 chunk = chunk.strip() 

1016 if not chunk: 1016 ↛ 1017line 1016 didn't jump to line 1017 because the condition on line 1016 was never true

1017 continue 

1018 m = re.fullmatch(r"(\d+)\s*[\-–—]\s*(\d+)", chunk) 

1019 if m: 

1020 lo_s, hi_s = m.group(1), m.group(2) 

1021 if ( 

1022 len(lo_s) > _MAX_RANGE_ENDPOINT_DIGITS 

1023 or len(hi_s) > _MAX_RANGE_ENDPOINT_DIGITS 

1024 ): 

1025 state["indeterminate"] = True 

1026 continue 

1027 try: 

1028 lo, hi = int(lo_s), int(hi_s) 

1029 except ValueError: 

1030 state["indeterminate"] = True 

1031 continue 

1032 if lo > hi: 1032 ↛ 1033line 1032 didn't jump to line 1033 because the condition on line 1032 was never true

1033 lo, hi = hi, lo 

1034 if hi - lo > _MAX_RANGE_SPAN: 

1035 state["indeterminate"] = True 

1036 continue 

1037 if state["expanded"] + (hi - lo + 1) > _MAX_TOTAL_EXPANSION: 1037 ↛ 1038line 1037 didn't jump to line 1038 because the condition on line 1037 was never true

1038 state["indeterminate"] = True 

1039 continue 

1040 for i in range(lo, hi + 1): 

1041 indices.add(i) 

1042 state["expanded"] += hi - lo + 1 

1043 elif chunk.isascii() and chunk.isdigit(): 1043 ↛ 1052line 1043 didn't jump to line 1052 because the condition on line 1043 was always true

1044 if len(chunk) > _MAX_RANGE_ENDPOINT_DIGITS: 1044 ↛ 1045line 1044 didn't jump to line 1045 because the condition on line 1044 was never true

1045 state["indeterminate"] = True 

1046 continue 

1047 try: 

1048 indices.add(int(chunk)) 

1049 except ValueError: 

1050 state["indeterminate"] = True 

1051 else: 

1052 continue 

1053 

1054 

1055def extract_cited_indices_and_urls( 

1056 prose: str, 

1057) -> Tuple[Set[int], Set[str], bool]: 

1058 """Parse citation signals from report body prose. 

1059 

1060 Returns ``(cited_indices, cited_url_keys, indeterminate)`` where 

1061 ``cited_url_keys`` are ``canonical_url_key`` values. ``indeterminate`` 

1062 is True when a range, bracket, or fence is ambiguous or the shared URL 

1063 candidate-work budget is exhausted. The result must then be treated as 

1064 unreliable (caller falls back to the full list without truncating prose). 

1065 

1066 Recognized signals: numeric brackets (outer spaces allowed: 

1067 ``[ 1, 2 ]``), tagged citations, ``Source N`` phrases, absolute URLs, 

1068 markdown-link library destinations (``](/library/…)``) AND bare 

1069 library routes (``/library/document/…``, ``/lib/document/…``) mentioned 

1070 outside link syntax. Route-prefix case is normalized for both bare 

1071 mentions and markdown destinations, so ``/LIBRARY/Document/…`` 

1072 retains like the lowercase form. Fenced code (``` and ~~~, 

1073 including blockquoted/bulleted forms like ``> ```json``) is stripped; 

1074 inline runs and single-line double-marker runs never swallow 

1075 citations; a dangling fence of either type marks the parse 

1076 indeterminate. 

1077 

1078 Ordinary prose singletons (ordinals ``2nd``, ``g``-``z`` units 

1079 ``5x``/``100m``, multi-letter ``12px``, percentages ``50%``) are 

1080 ignored — they never set indeterminate — so normal writing does not 

1081 force the full-bibliography fallback in ``strict`` mode. 

1082 Single-letter ``a``-``f`` suffixes (``3a``) stay indeterminate as 

1083 sub-reference-shaped. Only digit-led brackets with list/range syntax 

1084 that fail to parse (``[1,abc,3]``) are indeterminate. 

1085 

1086 Heuristic: over-includes on years/array indices; over-inclusion is 

1087 harmless because the fallback keeps extra lines, while 

1088 under-inclusion drops cited sources. 

1089 """ 

1090 indices: Set[int] = set() 

1091 url_keys: Set[str] = set() 

1092 state = {"indeterminate": False, "expanded": 0} 

1093 candidate_budget = _URLCandidateBudget() 

1094 if not prose or not prose.strip(): 1094 ↛ 1095line 1094 didn't jump to line 1095 because the condition on line 1094 was never true

1095 return indices, url_keys, False 

1096 

1097 text, fence_indeterminate = _strip_fenced_code(prose) 

1098 if fence_indeterminate: 

1099 state["indeterminate"] = True 

1100 

1101 # Keep the original text for URL extraction. A digit-led IPv6 address 

1102 # must not look like a broken [N] citation and force all sources back in. 

1103 # Use the same capture boundaries as URL extraction below. An IPv6 

1104 # literal inside another URL's path or query is not a separate host. 

1105 url_matches = _citation_url_matches(text) 

1106 citation_parts: List[str] = [] 

1107 position = 0 

1108 try: 

1109 for match in url_matches: 

1110 citation_parts.append(text[position : match.start()]) 

1111 citation_parts.append( 

1112 _mask_ip_literal_authority(match, candidate_budget) 

1113 ) 

1114 position = match.end() 

1115 except _URLCandidateBudgetExceeded: 

1116 return indices, url_keys, True 

1117 citation_parts.append(text[position:]) 

1118 citation_text = "".join(citation_parts) 

1119 

1120 for m in _BRACKET_NUMERIC_RE.finditer(citation_text): 

1121 _parse_bracket_content(m.group(1), indices, state) 

1122 

1123 if _has_malformed_numeric_brackets(citation_text): 

1124 state["indeterminate"] = True 

1125 

1126 for m in _TAGGED_CITATION_RE.finditer(citation_text): 

1127 tag, idx_s = m.group("tag"), m.group("idx") 

1128 # Check this after matching instead of using overlapping greedy 

1129 # groups: those take quadratic time on long malformed brackets. 

1130 if not re.search(r"[A-Za-z.]", tag): 

1131 continue 

1132 if _is_all_uppercase_tag(tag): 

1133 continue 

1134 if len(idx_s) > _MAX_RANGE_ENDPOINT_DIGITS: 

1135 # Implausibly large tag index (no bibliography has 10^18 

1136 # entries): clearly prose/hostile, not a citation. Ignore 

1137 # instead of marking indeterminate so a single token like 

1138 # [a-999…] cannot force the fallback for every reader. 

1139 continue 

1140 try: 

1141 indices.add(int(idx_s)) 

1142 except ValueError: 

1143 state["indeterminate"] = True 

1144 

1145 for m in _SOURCE_PHRASE_RE.finditer(citation_text): 

1146 # Expand comma lists AND dash ranges ("Sources 1-3") with the same 

1147 # budget-checked helper as bracket citations, so "1-3" yields 

1148 # {1, 2, 3} instead of just endpoints. "and"/"&" normalize to ",". 

1149 nums = m.group("nums") 

1150 nums_norm = re.sub(r"\s*(?:and|&)\s*", ",", nums, flags=re.IGNORECASE) 

1151 _parse_bracket_content(nums_norm, indices, state) 

1152 

1153 for m in url_matches: 

1154 try: 

1155 candidates = _url_citation_candidates(m, candidate_budget) 

1156 except _URLCandidateBudgetExceeded: 

1157 return indices, url_keys, True 

1158 # Case-variant library routes (``/LIBRARY/Document/d2``): the regex 

1159 # captures them (bare mentions and markdown destinations) but the 

1160 # canonical key is prefix-case-sensitive, so also key the 

1161 # prefix-normalized spelling. Applies to both branches — a 

1162 # markdown destination is the stronger citation signal and must 

1163 # not lose while the bare mention of the same route retains. 

1164 for base in list(candidates): 

1165 if _BARE_LIBRARY_PREFIX_RE.match(base): 

1166 try: 

1167 # /lib/ can expand to /library/; account before copying. 

1168 candidate_budget.consume(len(base) + 4) 

1169 except _URLCandidateBudgetExceeded: 

1170 return indices, url_keys, True 

1171 candidates.add(_normalize_bare_library_prefix(base)) 

1172 for candidate in candidates: 

1173 if not candidate: 1173 ↛ 1174line 1173 didn't jump to line 1174 because the condition on line 1173 was never true

1174 continue 

1175 try: 

1176 key = canonical_url_key(candidate) 

1177 except Exception: 

1178 continue 

1179 if key: 1179 ↛ 1172line 1179 didn't jump to line 1172 because the condition on line 1179 was always true

1180 url_keys.add(key) 

1181 

1182 return indices, url_keys, state["indeterminate"] 

1183 

1184 

1185def filter_cited_links( 

1186 all_links: List[Dict], 

1187 prose: Optional[str], 

1188 mode: str = "fallback", 

1189) -> List[Dict]: 

1190 """Keep only links cited in ``prose`` (by index or URL). 

1191 

1192 Modes: ``fallback`` (default) returns ``all_links`` when extraction 

1193 is empty/indeterminate or nothing matches; ``strict`` returns only 

1194 matches (possibly empty); ``disabled`` returns ``all_links``. 

1195 Indeterminate input falls back to ``all_links`` in every mode. 

1196 ``None`` resolves from the ``report.uncited_sources_mode`` setting. 

1197 """ 

1198 mode = resolve_uncited_sources_mode(mode) 

1199 if not all_links: 

1200 return all_links 

1201 if mode == "disabled" or not prose or not prose.strip(): 

1202 return all_links 

1203 

1204 cited_indices, cited_urls, indeterminate = extract_cited_indices_and_urls( 

1205 prose 

1206 ) 

1207 if indeterminate: 

1208 logger.warning( 

1209 "filter_cited_links: indeterminate citation parse; " 

1210 "falling back to all {} links", 

1211 len(all_links), 

1212 ) 

1213 return all_links 

1214 if not cited_indices and not cited_urls: 

1215 if mode == "fallback": 

1216 logger.warning( 

1217 "filter_cited_links: no citations parsed from prose " 

1218 "({} links); falling back to full bibliography", 

1219 len(all_links), 

1220 ) 

1221 return all_links 

1222 return [] 

1223 

1224 kept: List[Dict] = [] 

1225 for link in all_links: 

1226 if not isinstance(link, dict): 1226 ↛ 1227line 1226 didn't jump to line 1227 because the condition on line 1226 was never true

1227 continue 

1228 idx = link.get("index", "") 

1229 try: 

1230 idx_int = int(str(idx).strip()) 

1231 except (ValueError, TypeError): 

1232 idx_int = None 

1233 if idx_int is not None and idx_int in cited_indices: 

1234 kept.append(link) 

1235 continue 

1236 raw = source_url_field(link) 

1237 if isinstance(raw, str) and raw: 1237 ↛ 1225line 1237 didn't jump to line 1225 because the condition on line 1237 was always true

1238 try: 

1239 key = canonical_url_key(raw) 

1240 except Exception: 

1241 key = "" 

1242 if key and key in cited_urls: 

1243 kept.append(link) 

1244 

1245 if not kept and mode == "fallback": 

1246 logger.warning( 

1247 "filter_cited_links: {} cited indices / {} cited urls matched " 

1248 "nothing in {} links; falling back to full bibliography", 

1249 len(cited_indices), 

1250 len(cited_urls), 

1251 len(all_links), 

1252 ) 

1253 return all_links 

1254 return kept 

1255 

1256 

1257def count_distinct_sources(all_links: List[Dict]) -> int: 

1258 """How many distinct sources a report cites (document-level count). 

1259 

1260 ``len(all_links_of_system)`` counts OCCURRENCES, not sources: the 

1261 LangGraph collector stores one entry per ``(url, snippet)`` pair, and 

1262 ``source_based`` / ``focused_iteration`` / ``topic_organization`` 

1263 extend the list with raw engine dicts and no URL dedup at all. Both 

1264 shapes put one source in the list several times, so every "N sources" 

1265 number must group at the document level rather than take a length. 

1266 

1267 Counts by canonical URL key, so distinct library documents are counted 

1268 once regardless of how many distinct chunks or views are cited. 

1269 The relation across reporting layers is: 

1270 distinct sources <= bibliography lines <= citation indices. 

1271 """ 

1272 seen: set[str] = set() 

1273 for link in all_links or []: 

1274 if not isinstance(link, dict): 

1275 continue 

1276 raw = source_url_field(link) 

1277 if not isinstance(raw, str): 

1278 continue 

1279 canon = canonical_url_key(raw) 

1280 if canon: 

1281 seen.add(canon) 

1282 return len(seen) 

1283 

1284 

1285def format_links_to_markdown( 

1286 all_links: List[Dict], 

1287 prose: Optional[str] = None, 

1288 uncited_mode: Optional[str] = "fallback", 

1289) -> str: 

1290 parts: list[str] = [] 

1291 uncited_mode = resolve_uncited_sources_mode(uncited_mode) 

1292 if prose and uncited_mode != "disabled": 

1293 all_links = filter_cited_links(all_links, prose, mode=uncited_mode) 

1294 logger.info(f"Formatting {len(all_links)} links to markdown...") 

1295 

1296 if all_links: 

1297 # Group and display ordinary URLs in canonical form. Timestamped 

1298 # Wayback replays keep their complete spelling: their embedded 

1299 # target path/query identifies the historical resource. 

1300 # Group links by (canonical URL, chunk display URL). 

1301 # Canonical key remains per-document so count_distinct_sources, 

1302 # MCP sources, and news metrics do not inflate, while grouping on 

1303 # chunk anchor ensures distinct cited chunks (/chunks#chunk-N) each 

1304 # render their own Sources entry with their own anchor. Unanchored 

1305 # views of the same document (e.g. /pdf and base route) merge together; 

1306 # an unanchored /pdf view deliberately renders on its own line when 

1307 # cited alongside chunks (reversing #5685's collapse so anchorless 

1308 # indices honestly link to /pdf rather than mis-pointing at an arbitrary chunk). 

1309 # Non-library sources display their canonical URL, including the 

1310 # exact replay spelling preserved by canonical_url_key. 

1311 url_to_indices: dict[tuple[str, str], list] = {} 

1312 group_to_title: dict[tuple[str, str], str] = {} 

1313 group_to_capture_date: dict[tuple[str, str], str] = {} 

1314 # Keyed by canonical URL alone, unlike its neighbours. A document 

1315 # cited through both its chunk anchor and its bare URL renders one 

1316 # line per group, and its page title is the same on both; keying 

1317 # this by the group left whichever line did not hold the key 

1318 # showing the search listing's spelling. 

1319 doc_to_fetched_title: dict[str, str] = {} 

1320 group_to_quality: dict[tuple[str, str], int] = {} 

1321 group_to_collection: dict[tuple[str, str], str] = {} 

1322 group_to_display: dict[tuple[str, str], str] = {} 

1323 for link in all_links: 

1324 # Same skip as count_distinct_sources, so a malformed entry 

1325 # costs one bibliography line rather than the whole block. 

1326 if not isinstance(link, dict): 

1327 continue 

1328 raw = source_url_field(link) 

1329 # Skipped, not coerced. These dicts reach here straight from 

1330 # engine output on the non-LangGraph strategies, and 

1331 # canonical_url_key raises on a non-str. Stringifying instead 

1332 # renders a Python repr as a clickable URL and can merge two 

1333 # distinct sources onto one citation — and it would disagree 

1334 # with ``_citation_dedup_key``, which refuses a non-str link 

1335 # outright and whose docstring requires the two to group 

1336 # identically. 

1337 if not isinstance(raw, str): 

1338 continue 

1339 canon = canonical_url_key(raw) 

1340 if not canon: 

1341 continue 

1342 chunk_disp = preferred_chunk_display(raw) or _owned_chunk_display( 

1343 link.get(CHUNK_DISPLAY_KEY), canon 

1344 ) 

1345 if chunk_disp: 

1346 key = (canon, chunk_disp) 

1347 disp = chunk_disp 

1348 else: 

1349 key = (canon, "") 

1350 disp = library_display_url(raw) or canon 

1351 

1352 url_to_indices.setdefault(key, []).append(link.get("index", "")) 

1353 group_to_title.setdefault(key, link.get("title", "Untitled")) 

1354 if link.get("source_type") == "archive": 

1355 capture_date = link.get("formatted_date") 

1356 if isinstance(capture_date, str) and capture_date.strip(): 

1357 group_to_capture_date.setdefault(key, capture_date.strip()) 

1358 # A page's own title, recorded by the fetch ingest path beside a 

1359 # citation whose stored title a search engine scraped. First 

1360 # non-empty wins, as for quality and collection below. 

1361 if canon not in doc_to_fetched_title: 

1362 fetched = link.get(FETCHED_TITLE_KEY) 

1363 if isinstance(fetched, str) and fetched.strip(): 

1364 doc_to_fetched_title[canon] = fetched.strip() 

1365 # Prefer /pdf over bare /library/document/<id> for unanchored display 

1366 curr_disp = group_to_display.get(key) 

1367 if curr_disp is None or ( 

1368 curr_disp.endswith(canon) and "/pdf" in disp 

1369 ): 

1370 group_to_display[key] = disp 

1371 

1372 # Track journal quality per group (first non-None wins) 

1373 if key not in group_to_quality and link.get("journal_quality"): 1373 ↛ 1374line 1373 didn't jump to line 1374 because the condition on line 1373 was never true

1374 group_to_quality[key] = link["journal_quality"] 

1375 # First non-empty collection name wins (mirrors title/quality). 

1376 # Note: per-group collection tracking is display-only today and 

1377 # anticipates #5722 encoding collection identity into chunk anchors. 

1378 if key not in group_to_collection: 

1379 metadata = link.get("metadata") or {} 

1380 if not isinstance(metadata, dict): 

1381 metadata = {} 

1382 collection = metadata.get("collection_name") 

1383 if collection: 

1384 group_to_collection[key] = str(collection) 

1385 

1386 # Emit each unique source once, in first-seen order. 

1387 seen: set[tuple[str, str]] = set() 

1388 for link in all_links: 

1389 if not isinstance(link, dict): 

1390 continue 

1391 raw = source_url_field(link) 

1392 if not isinstance(raw, str): 

1393 continue 

1394 canon = canonical_url_key(raw) 

1395 if not canon: 

1396 continue 

1397 chunk_disp = preferred_chunk_display(raw) or _owned_chunk_display( 

1398 link.get(CHUNK_DISPLAY_KEY), canon 

1399 ) 

1400 key = (canon, chunk_disp) if chunk_disp else (canon, "") 

1401 if key in seen: 

1402 continue 

1403 title = doc_to_fetched_title.get(canon) or group_to_title[key] 

1404 # Coerced for the same reason the url is skipped: it comes 

1405 # from the same engine dict, and ``.replace`` below raises on 

1406 # a non-str, taking the whole Sources block with it. 

1407 if title is not None and not isinstance(title, str): 

1408 title = str(title) 

1409 capture_date = group_to_capture_date.get(key) 

1410 if capture_date: 

1411 suffix = f" (archived {capture_date})" 

1412 if not (title or "").endswith(suffix): 

1413 title = f"{title or 'Untitled'}{suffix}" 

1414 if title: 

1415 title = _sanitize_sources_field( 

1416 title.replace(LDR_APPENDED_SOURCES_SENTINEL, "") 

1417 ) 

1418 # Indices arrive as int (from strategy enumeration) or str (from 

1419 # _build_sources_markdown's fallback). Coerce so dedup collapses 

1420 # 1 and "1", and sorted() doesn't TypeError on mixed types. 

1421 indices = sorted( 

1422 {str(i) for i in url_to_indices[key]}, 

1423 # ``isdigit()`` is True for non-ASCII digit characters that 

1424 # ``int()`` rejects — e.g. the superscript "\u00b9" — so it 

1425 # alone would raise ValueError here and crash the whole 

1426 # bibliography. Require ASCII before converting. 

1427 key=lambda s: ( 

1428 (0, int(s)) if s.isascii() and s.isdigit() else (1, s) 

1429 ), 

1430 ) 

1431 # Sanitised like the title, collection and URL. Indices are 

1432 # LDR-internal enumerations today, but ``_create_documents`` 

1433 # PRESERVES a pre-existing ``index`` on a raw engine dict and 

1434 # ``_build_sources_markdown`` reads it back out of persisted 

1435 # ``original_data`` — so "no engine emits this key" is a 

1436 # coincidence, not a control. 

1437 # 

1438 # ``quality_tag`` below is the one field still interpolated 

1439 # raw. It reaches the renderer by the same persisted route, 

1440 # and is safe only incidentally: the fall-through branch that 

1441 # builds it uses ``repr()``, which escapes newlines and 

1442 # non-printables. Incidental, not designed — do not remove 

1443 # that ``repr()`` without sanitising here instead. 

1444 indices = [_sanitize_sources_field(i) for i in indices] 

1445 indices_str = f"[{', '.join(indices)}]" 

1446 quality_tag = _format_quality_tag(group_to_quality.get(key)) 

1447 collection = group_to_collection.get(key, "") 

1448 if collection: 

1449 collection = _sanitize_sources_field( 

1450 collection.replace(LDR_APPENDED_SOURCES_SENTINEL, "") 

1451 ) 

1452 collection_line = ( 

1453 f" Collection: {collection}\n" if collection else "" 

1454 ) 

1455 display = group_to_display[key] 

1456 parts.append( 

1457 f"{indices_str} {title}{quality_tag} " 

1458 f"(source nr: {', '.join(map(str, indices))})\n" 

1459 f" URL: {_sanitize_sources_field(display)}\n" 

1460 f"{collection_line}" 

1461 f"\n" 

1462 ) 

1463 seen.add(key) 

1464 

1465 parts.append("\n") 

1466 

1467 return "".join(parts) 

1468 

1469 

1470def format_findings( 

1471 findings_list: List[Dict], 

1472 synthesized_content: str, 

1473 questions_by_iteration: Dict[int, List[str]], 

1474 uncited_mode: Optional[str] = None, 

1475) -> str: 

1476 """Format findings into a detailed text output. 

1477 

1478 Args: 

1479 findings_list: List of finding dictionaries 

1480 synthesized_content: The synthesized content from the LLM. 

1481 questions_by_iteration: Dictionary mapping iteration numbers to lists of questions 

1482 uncited_mode: ``report.uncited_sources_mode`` override. ``None`` 

1483 (default) resolves from settings. Only the global source 

1484 dumps (top list and ``## ALL SOURCES`` footer) are filtered, 

1485 using the synthesized content as the citation prose; 

1486 per-section ``SOURCES USED IN THIS SECTION`` lists stay 

1487 unfiltered. 

1488 

1489 Returns: 

1490 str: Formatted text output 

1491 """ 

1492 logger.info( 

1493 f"Inside format_findings utility. Findings count: {len(findings_list)}, Questions iterations: {len(questions_by_iteration)}" 

1494 ) 

1495 parts: list[str] = [] 

1496 mode = resolve_uncited_sources_mode(uncited_mode) 

1497 prose = ( 

1498 synthesized_content 

1499 if isinstance(synthesized_content, str) 

1500 else str(synthesized_content or "") 

1501 ) 

1502 

1503 # Extract all sources from findings 

1504 all_links = [] 

1505 for finding in findings_list: 

1506 search_results = finding.get("search_results", []) 

1507 if search_results: 

1508 try: 

1509 links = extract_links_from_search_results(search_results) 

1510 all_links.extend(links) 

1511 except Exception: 

1512 logger.exception("Error processing search results/links") 

1513 

1514 # Start with the synthesized content (passed as synthesized_content) 

1515 parts.append(f"{synthesized_content}\n\n") 

1516 

1517 # Add sources section after synthesized content if sources exist. 

1518 # Filtered by the synthesized prose; per-section lists below stay 

1519 # unfiltered. 

1520 parts.append( 

1521 format_links_to_markdown(all_links, prose=prose, uncited_mode=mode) 

1522 ) 

1523 

1524 parts.append("\n\n") # Separator after synthesized content 

1525 

1526 # Add Search Questions by Iteration section 

1527 if questions_by_iteration: 

1528 parts.append("## SEARCH QUESTIONS BY ITERATION\n") 

1529 parts.append("\n") 

1530 for iter_num, questions in questions_by_iteration.items(): 

1531 parts.append(f"\n #### Iteration {iter_num}:\n") 

1532 for i, q in enumerate(questions, 1): 

1533 parts.append(f"{i}. {q}\n") 

1534 parts.append("\n\n\n") 

1535 else: 

1536 logger.warning("No questions by iteration found to format.") 

1537 

1538 # Add Detailed Findings section 

1539 if findings_list: 

1540 parts.append("## DETAILED FINDINGS\n\n") 

1541 logger.info(f"Formatting {len(findings_list)} detailed finding items.") 

1542 

1543 for idx, finding in enumerate(findings_list): 

1544 logger.debug( 

1545 f"Formatting finding item {idx}. Keys: {list(finding.keys())}" 

1546 ) 

1547 # Use .get() for safety 

1548 phase = finding.get("phase", "Unknown Phase") 

1549 content = finding.get("content", "No content available.") 

1550 search_results = finding.get("search_results", []) 

1551 

1552 # Phase header 

1553 parts.append(f"\n### {phase}\n\n\n") 

1554 

1555 question_displayed = False 

1556 # If this is a follow-up phase, try to show the corresponding question 

1557 if isinstance(phase, str) and phase.startswith("Follow-up"): 

1558 try: 

1559 phase_parts = phase.replace( 

1560 "Follow-up Iteration ", "" 

1561 ).split(".") 

1562 if len(phase_parts) == 2: 

1563 iteration = int(phase_parts[0]) 

1564 question_index = int(phase_parts[1]) - 1 

1565 if ( 

1566 iteration in questions_by_iteration 

1567 and 0 

1568 <= question_index 

1569 < len(questions_by_iteration[iteration]) 

1570 ): 

1571 parts.append( 

1572 f"#### {questions_by_iteration[iteration][question_index]}\n\n" 

1573 ) 

1574 question_displayed = True 

1575 else: 

1576 logger.warning( 

1577 f"Could not find matching question for phase: {phase}" 

1578 ) 

1579 else: 

1580 logger.warning( 

1581 f"Could not parse iteration/index from phase: {phase}" 

1582 ) 

1583 except ValueError: 

1584 logger.warning( 

1585 f"Could not parse iteration/index from phase: {phase}" 

1586 ) 

1587 # Handle Sub-query phases from IterDRAG strategy 

1588 elif isinstance(phase, str) and phase.startswith("Sub-query"): 

1589 try: 

1590 # Extract the index number from "Sub-query X" 

1591 query_index = int(phase.replace("Sub-query ", "")) - 1 

1592 # In IterDRAG, sub-queries are stored in iteration 0 

1593 if 0 in questions_by_iteration and query_index < len( 

1594 questions_by_iteration[0] 

1595 ): 

1596 parts.append( 

1597 f"#### {questions_by_iteration[0][query_index]}\n\n" 

1598 ) 

1599 question_displayed = True 

1600 else: 

1601 logger.warning( 

1602 f"Could not find matching question for phase: {phase}" 

1603 ) 

1604 except ValueError: 

1605 logger.warning( 

1606 f"Could not parse question index from phase: {phase}" 

1607 ) 

1608 

1609 # If the question is in the finding itself, display it 

1610 if ( 

1611 not question_displayed 

1612 and "question" in finding 

1613 and finding["question"] 

1614 ): 

1615 parts.append(f"### SEARCH QUESTION:\n{finding['question']}\n\n") 

1616 

1617 # Content 

1618 parts.append(f"\n\n{content}\n\n") 

1619 

1620 # Search results if they exist 

1621 if search_results: 

1622 try: 

1623 links = extract_links_from_search_results(search_results) 

1624 if links: 

1625 parts.append("### SOURCES USED IN THIS SECTION:\n") 

1626 parts.append(format_links_to_markdown(links) + "\n\n") 

1627 except Exception: 

1628 logger.exception( 

1629 f"Error processing search results/links for finding {idx}" 

1630 ) 

1631 else: 

1632 logger.debug(f"No search_results found for finding item {idx}.") 

1633 

1634 parts.append(f"{'_' * 80}\n\n") 

1635 else: 

1636 logger.warning("No detailed findings found to format.") 

1637 

1638 # Add summary of all sources at the end, filtered the same way. 

1639 if all_links: 

1640 parts.append("## ALL SOURCES:\n") 

1641 parts.append( 

1642 format_links_to_markdown(all_links, prose=prose, uncited_mode=mode) 

1643 ) 

1644 else: 

1645 logger.info("No unique sources found across all findings to list.") 

1646 

1647 logger.info("Finished format_findings utility.") 

1648 return "".join(parts)