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

234 statements  

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

1import re 

2from typing import Dict, List 

3 

4from loguru import logger 

5 

6from local_deep_research.text_optimization.citation_formatter import ( 

7 is_line_breaking_char, 

8 LDR_APPENDED_SOURCES_SENTINEL, 

9) 

10from .url_utils import ( 

11 CHUNK_DISPLAY_KEY, 

12 canonical_url_key, 

13 library_display_url, 

14 preferred_chunk_display, 

15) 

16 

17 

18LANGUAGE_CODE_MAP = { 

19 "english": "en", 

20 "french": "fr", 

21 "german": "de", 

22 "spanish": "es", 

23 "italian": "it", 

24 "japanese": "ja", 

25 "chinese": "zh", 

26 "hindi": "hi", 

27 "arabic": "ar", 

28 "bengali": "bn", 

29 "portuguese": "pt", 

30 "russian": "ru", 

31 "korean": "ko", 

32} 

33 

34 

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

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

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

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

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

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

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

42 # Remove any orphaned opening or closing think tags 

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

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

45 return text.strip() 

46 

47 

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

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

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

51# isn't a numeric quality tier: 

52# 

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

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

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

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

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

58# as Unranked). 

59QUALITY_PENDING = "pending" 

60QUALITY_PREPRINT = "preprint" 

61 

62 

63def _format_quality_tag(quality) -> str: 

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

65 

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

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

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

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

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

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

72 

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

74 

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

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

77 in VALID_QUALITY_SCORES has its own explicit branch so a bad 

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

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

80 """ 

81 if quality is None: 

82 return "" 

83 if quality == QUALITY_PENDING: 

84 return ( 

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

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

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

88 "real quality scores]" 

89 ) 

90 if quality == QUALITY_PREPRINT: 

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

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

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

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

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

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

97 if quality == 10: 

98 return " [Q1 ★★★★★]" 

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

100 # constants.VALID_QUALITY_SCORES excludes 9 and the filter rejects 

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

102 # to VALID_QUALITY_SCORES does not require editing the formatter. 

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

104 # reintroduction work. 

105 if quality == 9: 

106 return " [Q1 ★★★★★]" 

107 if quality == 8: 

108 return " [Q1 ★★★★]" 

109 if quality == 7: 

110 return " [Q1 ★★★★]" 

111 if quality == 6: 

112 return " [Q2 ★★★]" 

113 if quality == 5: 

114 return " [Q2 ★★★]" 

115 if quality == 4: 

116 # JOURNAL_QUALITY_DEFAULT — venue found in the catalog but 

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

118 return " [Unranked ★]" 

119 if quality == 3: 

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

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

122 return " [Unranked ★]" 

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

124 return " [Q4 ★]" 

125 if quality == 1: 

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

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

128 return " [Q4 ★]" 

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

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

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

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

133 

134 

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

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

137 

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

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

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

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

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

143 alongside the tag. 

144 

145 Returns: 

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

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

148 angle brackets, and ampersands are rendered as text. 

149 """ 

150 import html as _html 

151 

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

153 

154 

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

156 """ 

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

158 

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

160 

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

162 """ 

163 links = [] 

164 if not search_results: 

165 return links 

166 

167 for result in search_results: 

168 try: 

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

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

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

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

173 

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

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

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

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

178 

179 if title and url: 

180 link = { 

181 "title": title, 

182 "url": url, 

183 "index": index, 

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

185 } 

186 # Preserve citation-relevant fields from search engines 

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

188 for key in ( 

189 "doi", 

190 "authors", 

191 "published", 

192 "publication_date", 

193 "year", 

194 "date", 

195 "volume", 

196 "issue", 

197 "pages", 

198 "journal_ref", 

199 "journal", 

200 "venue", 

201 "publisher", 

202 "source_type", 

203 "openalex_source_id", 

204 "source", 

205 "source_engine", 

206 "pmid", 

207 "pmcid", 

208 "arxiv_id", 

209 "isbn", 

210 "citations", 

211 "is_open_access", 

212 "abstract", 

213 "metadata", 

214 ): 

215 val = result.get(key) 

216 if val is not None: 

217 link[key] = val 

218 links.append(link) 

219 except Exception: 

220 # Log the specific error for debugging 

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

222 continue 

223 return links 

224 

225 

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

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

228 

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

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

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

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

233 fallback returns arbitrary scheme-less paths unchanged. 

234 

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

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

237 of silently vanishing. 

238 """ 

239 if not value: 

240 return value 

241 return "".join( 

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

243 ).strip() 

244 

245 

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

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

248 

249 .. note:: 

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

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

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

253 ``source_based``, ``focused_iteration`` and 

254 ``topic_organization`` all extend ``all_links_of_system`` with 

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

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

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

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

259 arbitrary URL. 

260 

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

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

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

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

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

266 two disagree about which spelling is authoritative. 

267 

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

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

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

271 """ 

272 if not isinstance(recorded, str): 

273 return None 

274 display = preferred_chunk_display(recorded) 

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

276 return None 

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

278 

279 

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

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

282 

283 ``SearchResultsCollector`` keys citations on 

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

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

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

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

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

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

290 

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

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

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

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

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

296 some of which set only that key. 

297 """ 

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

299 

300 

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

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

303 

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

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

306 ``source_based`` / ``focused_iteration`` / ``topic_organization`` 

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

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

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

310 

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

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

313 The relation across reporting layers is: 

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

315 """ 

316 seen: set[str] = set() 

317 for link in all_links or []: 

318 if not isinstance(link, dict): 

319 continue 

320 raw = source_url_field(link) 

321 if not isinstance(raw, str): 

322 continue 

323 canon = canonical_url_key(raw) 

324 if canon: 

325 seen.add(canon) 

326 return len(seen) 

327 

328 

329def format_links_to_markdown(all_links: List[Dict]) -> str: 

330 parts: list[str] = [] 

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

332 

333 if all_links: 

334 # Group links by canonical URL (collapses trailing slash, utm 

335 # params, fragments, default ports, scheme/host case, userinfo). 

336 # The canonical form is also what gets displayed so the Sources 

337 # section stays clean — no utm_*/fbclid clutter, no embedded 

338 # credentials, no scheme/host casing noise. Click-through is 

339 # unaffected (tracking params carry no content). 

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

341 # Canonical key remains per-document so count_distinct_sources, 

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

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

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

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

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

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

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

349 # Non-library sources display their canonical URL, so tracking params 

350 # and credentials stay out of the report. 

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

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

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

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

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

356 for link in all_links: 

357 raw = source_url_field(link) 

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

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

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

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

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

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

364 # outright and whose docstring requires the two to group 

365 # identically. 

366 if not isinstance(raw, str): 

367 continue 

368 canon = canonical_url_key(raw) 

369 if not canon: 

370 continue 

371 chunk_disp = preferred_chunk_display(raw) or _owned_chunk_display( 

372 link.get(CHUNK_DISPLAY_KEY), canon 

373 ) 

374 if chunk_disp: 

375 key = (canon, chunk_disp) 

376 disp = chunk_disp 

377 else: 

378 key = (canon, "") 

379 disp = library_display_url(raw) or canon 

380 

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

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

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

384 curr_disp = group_to_display.get(key) 

385 if curr_disp is None or ( 

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

387 ): 

388 group_to_display[key] = disp 

389 

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

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

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

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

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

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

396 if key not in group_to_collection: 

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

398 if not isinstance(metadata, dict): 

399 metadata = {} 

400 collection = metadata.get("collection_name") 

401 if collection: 

402 group_to_collection[key] = str(collection) 

403 

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

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

406 for link in all_links: 

407 raw = source_url_field(link) 

408 if not isinstance(raw, str): 

409 continue 

410 canon = canonical_url_key(raw) 

411 if not canon: 

412 continue 

413 chunk_disp = preferred_chunk_display(raw) or _owned_chunk_display( 

414 link.get(CHUNK_DISPLAY_KEY), canon 

415 ) 

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

417 if key in seen: 

418 continue 

419 title = group_to_title[key] 

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

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

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

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

424 title = str(title) 

425 if title: 

426 title = _sanitize_sources_field( 

427 title.replace(LDR_APPENDED_SOURCES_SENTINEL, "") 

428 ) 

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

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

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

432 indices = sorted( 

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

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

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

436 # alone would raise ValueError here and crash the whole 

437 # bibliography. Require ASCII before converting. 

438 key=lambda s: ( 

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

440 ), 

441 ) 

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

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

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

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

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

447 # coincidence, not a control. 

448 # 

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

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

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

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

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

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

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

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

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

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

459 if collection: 

460 collection = _sanitize_sources_field( 

461 collection.replace(LDR_APPENDED_SOURCES_SENTINEL, "") 

462 ) 

463 collection_line = ( 

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

465 ) 

466 display = group_to_display[key] 

467 parts.append( 

468 f"{indices_str} {title}{quality_tag} " 

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

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

471 f"{collection_line}" 

472 f"\n" 

473 ) 

474 seen.add(key) 

475 

476 parts.append("\n") 

477 

478 return "".join(parts) 

479 

480 

481def format_findings( 

482 findings_list: List[Dict], 

483 synthesized_content: str, 

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

485) -> str: 

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

487 

488 Args: 

489 findings_list: List of finding dictionaries 

490 synthesized_content: The synthesized content from the LLM. 

491 questions_by_iteration: Dictionary mapping iteration numbers to lists of questions 

492 

493 Returns: 

494 str: Formatted text output 

495 """ 

496 logger.info( 

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

498 ) 

499 parts: list[str] = [] 

500 

501 # Extract all sources from findings 

502 all_links = [] 

503 for finding in findings_list: 

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

505 if search_results: 

506 try: 

507 links = extract_links_from_search_results(search_results) 

508 all_links.extend(links) 

509 except Exception: 

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

511 

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

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

514 

515 # Add sources section after synthesized content if sources exist 

516 parts.append(format_links_to_markdown(all_links)) 

517 

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

519 

520 # Add Search Questions by Iteration section 

521 if questions_by_iteration: 

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

523 parts.append("\n") 

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

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

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

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

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

529 else: 

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

531 

532 # Add Detailed Findings section 

533 if findings_list: 

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

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

536 

537 for idx, finding in enumerate(findings_list): 

538 logger.debug( 

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

540 ) 

541 # Use .get() for safety 

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

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

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

545 

546 # Phase header 

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

548 

549 question_displayed = False 

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

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

552 try: 

553 phase_parts = phase.replace( 

554 "Follow-up Iteration ", "" 

555 ).split(".") 

556 if len(phase_parts) == 2: 

557 iteration = int(phase_parts[0]) 

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

559 if ( 

560 iteration in questions_by_iteration 

561 and 0 

562 <= question_index 

563 < len(questions_by_iteration[iteration]) 

564 ): 

565 parts.append( 

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

567 ) 

568 question_displayed = True 

569 else: 

570 logger.warning( 

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

572 ) 

573 else: 

574 logger.warning( 

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

576 ) 

577 except ValueError: 

578 logger.warning( 

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

580 ) 

581 # Handle Sub-query phases from IterDRAG strategy 

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

583 try: 

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

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

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

587 if 0 in questions_by_iteration and query_index < len( 

588 questions_by_iteration[0] 

589 ): 

590 parts.append( 

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

592 ) 

593 question_displayed = True 

594 else: 

595 logger.warning( 

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

597 ) 

598 except ValueError: 

599 logger.warning( 

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

601 ) 

602 

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

604 if ( 

605 not question_displayed 

606 and "question" in finding 

607 and finding["question"] 

608 ): 

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

610 

611 # Content 

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

613 

614 # Search results if they exist 

615 if search_results: 

616 try: 

617 links = extract_links_from_search_results(search_results) 

618 if links: 

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

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

621 except Exception: 

622 logger.exception( 

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

624 ) 

625 else: 

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

627 

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

629 else: 

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

631 

632 # Add summary of all sources at the end 

633 if all_links: 

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

635 parts.append(format_links_to_markdown(all_links)) 

636 else: 

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

638 

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

640 return "".join(parts)