Coverage for src/local_deep_research/text_optimization/citation_formatter.py: 97%

545 statements  

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

1"""Citation formatter for adding hyperlinks and alternative citation styles.""" 

2 

3import re 

4from enum import Enum 

5from typing import Any, Dict, List, Optional, Tuple 

6from urllib.parse import urlparse 

7 

8from loguru import logger 

9from slugify import slugify 

10 

11from ..content_fetcher.url_classifier import URLClassifier, URLType 

12 

13 

14# Marker emitted by ``IntegratedReportGenerator._format_final_report`` 

15# around the ``## Sources`` block it appends to the end of a detailed-mode 

16# report. Looked up by ``find_sources_section`` so the splitter can locate 

17# the appended sources section unambiguously, even when the LLM has 

18# emitted its own ``## Sources`` (or ``References:`` etc.) header earlier 

19# in the prose. The sentinel is a markdown HTML comment so it round-trips 

20# through markdown renderers without affecting the displayed output. 

21LDR_APPENDED_SOURCES_SENTINEL = "<!-- LDR_APPENDED_SOURCES_START -->" 

22 

23_SOURCES_SECTION_PATTERNS = [ 

24 re.compile( 

25 r"^#{1,3}\s*(?:Sources|References|Bibliography|Citations)", 

26 re.MULTILINE | re.IGNORECASE, 

27 ), 

28 re.compile( 

29 r"^(?:Sources|References|Bibliography|Citations):?\s*$", 

30 re.MULTILINE | re.IGNORECASE, 

31 ), 

32] 

33 

34 

35def find_sources_section( 

36 content: str, *, trust_sentinel: bool = True 

37) -> Tuple[int, bool]: 

38 """Locate the sources/references section in *content*. 

39 

40 Returns ``(position, on_sentinel)``. ``position`` is the character 

41 offset of the section start, or ``-1`` if no section is found. 

42 ``on_sentinel`` is ``True`` iff the unique sentinel emitted by the 

43 report generator was matched (in which case the boundary is correct 

44 by construction); ``False`` if the position came from the legacy 

45 regex patterns (which can match an early ``## Sources`` line inside 

46 the answer body and need an over-strip safety check downstream). 

47 

48 The sentinel is matched at its **last** occurrence (``rfind``) so a 

49 spoofed earlier occurrence inside LLM-generated prose — e.g. the 

50 LLM quoting the marker string verbatim while summarizing the 

51 codebase — cannot silently truncate the answer body or disable the 

52 over-strip safety check. Only a sentinel on its own line is trusted; 

53 inline occurrences are skipped while searching backwards. The report 

54 generator only ever emits the sentinel once at the end of the assembled 

55 report, so the last standalone occurrence is the trusted one. 

56 

57 When ``trust_sentinel`` is ``False`` (e.g. raw LLM output from the 

58 quick-mode save path, which never adds the sentinel itself), the 

59 sentinel branch is skipped — only the legacy regex patterns run, so 

60 a spurious ``on_sentinel=True`` cannot arise from the LLM quoting 

61 the marker. 

62 """ 

63 if trust_sentinel: 

64 sentinel_pos = content.rfind(LDR_APPENDED_SOURCES_SENTINEL) 

65 while sentinel_pos != -1: 

66 preceded = sentinel_pos == 0 or content[sentinel_pos - 1] in ( 

67 "\n", 

68 "\r", 

69 ) 

70 after = content[sentinel_pos + len(LDR_APPENDED_SOURCES_SENTINEL) :] 

71 followed = not after or after[0] in ("\n", "\r") 

72 if preceded and followed: 

73 return sentinel_pos, True 

74 sentinel_pos = content.rfind( 

75 LDR_APPENDED_SOURCES_SENTINEL, 0, sentinel_pos 

76 ) 

77 for pattern in _SOURCES_SECTION_PATTERNS: 

78 match = pattern.search(content) 

79 if match: 

80 return match.start(), False 

81 return -1, False 

82 

83 

84_BIB_SOURCES_PATTERN = re.compile( 

85 r"^\[(\d+(?:,\s*\d+)*)\]\s*(.+?)(?:\n\s*URL:\s*(.+?))?$", 

86 re.MULTILINE, 

87) 

88 

89 

90# Single-pass character map. A sequential replace() chain would re-escape 

91# the braces its own replacements introduce — the bug already present in 

92# _escape_latex, where "\\" becomes "\textbackslash{}" and the later 

93# brace rules then mangle it. 

94_BIBTEX_ESCAPES = { 

95 "\\": r"\textbackslash{}", 

96 # Balanced pairs, not backslash escapes: BibTeX's field scanner counts 

97 # brace CHARACTERS and has no escape mechanism, so "\{" is still an open 

98 # brace to it. A backslashed brace left the value unbalanced, and the 

99 # scanner ran to EOF swallowing the rest of the .bib. 

100 "{": r"{\textbraceleft}", 

101 "}": r"{\textbraceright}", 

102 "&": r"\&", 

103 "%": r"\%", 

104 "$": r"\$", 

105 "#": r"\#", 

106 "_": r"\_", 

107 "~": r"\textasciitilde{}", 

108 "^": r"\textasciicircum{}", 

109 '"': "'", 

110} 

111 

112 

113# U+0085 NEL already falls inside the 0x7F-0x9F range below; only these two 

114# separators sit outside it. 

115_UNICODE_LINE_SEPARATORS = "\u2028\u2029" 

116 

117 

118def is_line_breaking_char(char: str) -> bool: 

119 """True if *char* can begin a new line for some consumer of our output. 

120 

121 ONE definition, imported by every producer. The recurring bug in this 

122 area has been fixing one instance and leaving a sibling — CR handled 

123 but not NEL, the URL sanitised but not the citation index — so the 

124 union is deliberately wider than any single consumer needs: C0 and DEL 

125 for everything, C1 because such a codepoint is never legitimate text, 

126 and U+2028/U+2029 because CSS Text makes them mandatory breaks in 

127 rendered HTML even though Python's ``re`` and ``str.splitlines()`` 

128 ignore them. 

129 

130 Imported by ``search_utilities._sanitize_sources_field``; 

131 ``test_bibliography_escaping`` asserts the two agree on every BMP 

132 codepoint, so the claim cannot rot into a comment again. 

133 """ 

134 code = ord(char) 

135 return ( 

136 code < 0x20 or 0x7F <= code <= 0x9F or char in _UNICODE_LINE_SEPARATORS 

137 ) 

138 

139 

140def _escape_bibtex(value: str) -> str: 

141 r"""Escape a value for interpolation into a BibTeX field. 

142 

143 Titles come from search-result metadata — whatever a page calls itself 

144 — and were previously written into ``title = "{...}"`` raw. A title 

145 ending the field and opening another (``Benign}", url = "...", note = 

146 "{x``) injected an attacker-controlled ``url`` that BibTeX preferred, 

147 and an unbalanced brace ran the parser on into the rest of the file. 

148 """ 

149 return "".join( 

150 # Control characters are flattened, not escaped. The .bib is 

151 # embedded in a ```bibtex markdown fence by the Quarto exporter, 

152 # and a bare CR — which ``_BIB_SOURCES_PATTERN``'s ``.`` matches, 

153 # since only ``\n`` is excluded — closes that fence once a reader 

154 # normalises CRLF, turning the remainder into live markup. 

155 # ``_safe_bibtex_url`` already rejects these; this matches it. 

156 " " if is_line_breaking_char(ch) else _BIBTEX_ESCAPES.get(ch, ch) 

157 for ch in value 

158 ) 

159 

160 

161def _safe_bibtex_url(value: str) -> str: 

162 r"""Return *value* if it is safe to place in ``url = {...}``, else "". 

163 

164 Escaping is wrong for a URL — a ``\%`` would corrupt a percent-encoded 

165 octet. Nor may the offending characters simply be DROPPED: removing a 

166 backslash from ``//trusted.example\@evil.example/p`` turns the trusted 

167 host into userinfo, so the exported link points at a different host 

168 than the one the reader vetted in the report. A URL containing a 

169 breakout character is not a URL to repair — it is omitted, and the 

170 caller falls through to its existing URL-less branch. 

171 """ 

172 if any(ch in '{}"\\ ' or is_line_breaking_char(ch) for ch in value): 

173 # Local-document sources legitimately trip this: a LangChain 

174 # retriever sets a result's url from its ``source`` metadata, which 

175 # is often a filesystem path carrying spaces or backslashes. 

176 # Dropping the link silently would leave the user guessing why a 

177 # bibliography entry has none, so record it. 

178 # Truncated: a hostile result can carry a megabyte-long URL, and 

179 # this fires once per affected entry — and the Quarto exporter 

180 # builds the bibliography twice per export, so it fires twice per 

181 # entry in practice. ``!r`` keeps control characters escaped. 

182 logger.debug( 

183 "Omitting unsafe bibliography URL: {!r}{}", 

184 value[:200], 

185 "…" if len(value) > 200 else "", 

186 ) 

187 return "" 

188 return value 

189 

190 

191def _iter_citation_group(group: str): 

192 r"""Yield each index on one Sources line (``"1, 2, 3"`` -> "1", "2", "3"). 

193 

194 ``format_links_to_markdown`` collapses every citation of one source 

195 onto a single line carrying all of its indices, so a single-index 

196 pattern drops such a source from the bibliography entirely. Exporters 

197 emit one entry per index so each ``[N]`` in the body resolves. 

198 

199 Indices are yielded as STRINGS, exactly as written: the body citations 

200 are rewritten from the same text, so converting ``007`` to ``7`` here 

201 would key the entry ``ref7`` while the body still cites ``ref007``. 

202 No digit filtering either — the pattern's ``\d`` matches only decimal 

203 digits (category ``Nd``), which ``int()`` always accepts, so filtering 

204 could only discard valid indices such as Arabic-Indic ones. 

205 """ 

206 for part in group.split(","): 

207 part = part.strip() 

208 if part: 208 ↛ 206line 208 didn't jump to line 206 because the condition on line 208 was always true

209 yield part 

210 

211 

212def _sort_key(index: str): 

213 """Order bibliography entries numerically where possible.""" 

214 try: 

215 return (0, int(index), "") 

216 except ValueError: 

217 return (1, 0, index) 

218 

219 

220def _collect_bibliography_entries(content: str) -> dict: 

221 """Map each citation index to the ``(title, url)`` it names. 

222 

223 Selection rule: a URL-bearing entry never loses to a URL-less one, and 

224 among URL-bearing entries the LATER occurrence wins. 

225 

226 Both halves matter. A report repeats its Sources block once per section 

227 and then again as a combined block, and the combined block is both last 

228 and authoritative — so later-wins. And the group-aware pattern can match 

229 a line of prose that opens with ``[1, 2]`` where a single-index pattern 

230 could not; such a line carries no ``URL:``, whereas every line 

231 ``format_links_to_markdown`` renders does, so the URL preference is what 

232 keeps prose from taking a citation key. 

233 

234 Deliberately NOT scoped to a Sources section. Bounding the block at 

235 markers like ``\n---`` looked safer but silently emptied the 

236 bibliography whenever a thematic break, a setext underline or a table 

237 delimiter row came first — while the body was still rewritten to cite 

238 entries that no longer existed. 

239 """ 

240 entries: dict[str, tuple[str, str]] = {} 

241 for match in _BIB_SOURCES_PATTERN.finditer(content): 

242 title = match.group(2).strip() 

243 url = match.group(3).strip() if match.group(3) else "" 

244 for index in _iter_citation_group(match.group(1)): 

245 # Prefer an entry carrying a URL: a real Sources line has one, 

246 # a prose false positive does not. 

247 if url or index not in entries: 

248 entries[index] = (title, url) 

249 return entries 

250 

251 

252class CitationMode(Enum): 

253 """Available citation formatting modes.""" 

254 

255 NUMBER_HYPERLINKS = "number_hyperlinks" # [1] with hyperlinks 

256 DOMAIN_HYPERLINKS = "domain_hyperlinks" # [arxiv.org] with hyperlinks 

257 DOMAIN_ID_HYPERLINKS = ( 

258 "domain_id_hyperlinks" # [arxiv.org] or [arxiv.org-1] with smart IDs 

259 ) 

260 DOMAIN_ID_ALWAYS_HYPERLINKS = ( 

261 "domain_id_always_hyperlinks" # [arxiv.org-1] always with IDs 

262 ) 

263 SOURCE_TAGGED_HYPERLINKS = "source_tagged_hyperlinks" 

264 """Preserve the global citation number and prefix it with a short source 

265 tag derived from the URL: known academic sources via ``URLClassifier`` 

266 (``arxiv-7``, ``pubmed-3``), domain otherwise (``nytimes.com-9``), and 

267 ``local-N`` for empty / local URLs. Unlike DOMAIN_ID_* modes the 

268 suffix is the original citation number, so labels never collide and 

269 match the bibliography order: ``[1]`` arxiv + ``[2]`` openai + ``[3]`` 

270 arxiv -> ``arxiv-1``, ``openai-2``, ``arxiv-3``.""" 

271 NO_HYPERLINKS = "no_hyperlinks" # [1] without hyperlinks 

272 

273 

274class CitationFormatter: 

275 """Formats citations in markdown documents with various styles.""" 

276 

277 def __init__(self, mode: CitationMode = CitationMode.NUMBER_HYPERLINKS): 

278 self.mode = mode 

279 # Use negative lookbehind and lookahead to avoid matching already formatted citations 

280 # Also match Unicode lenticular brackets 【】 (U+3010 and U+3011) that LLMs sometimes generate 

281 self.citation_pattern = re.compile( 

282 r"(?<![\[【])[\[【](\d+)[\]】](?![\]】])" 

283 ) 

284 self.comma_citation_pattern = re.compile( 

285 r"[\[【](\d+(?:,\s*\d+)+)[\]】]" 

286 ) 

287 # Also match "Source X" or "source X" patterns 

288 self.source_word_pattern = re.compile(r"\b[Ss]ource\s+(\d+)\b") 

289 self.sources_pattern = re.compile( 

290 r"^\[(\d+(?:,\s*\d+)*)\]\s*(.+?)(?:\n\s*URL:\s*(.+?))?$", 

291 re.MULTILINE, 

292 ) 

293 

294 def _create_source_word_replacer(self, formatter_func): 

295 """Create a replacement function for 'Source X' patterns. 

296 

297 Args: 

298 formatter_func: A function that takes citation_num and returns formatted text 

299 

300 Returns: 

301 A replacement function for use with regex sub 

302 """ 

303 

304 def replace_source_word(match): 

305 citation_num = match.group(1) 

306 return formatter_func(citation_num) 

307 

308 return replace_source_word 

309 

310 def _create_citation_formatter(self, sources_dict, format_pattern): 

311 """Create a formatter function for citations. 

312 

313 Args: 

314 sources_dict: Dictionary mapping citation numbers to data 

315 format_pattern: A callable that takes (citation_num, data) and returns formatted string 

316 

317 Returns: 

318 A function that formats citations or returns fallback 

319 """ 

320 

321 def formatter(citation_num): 

322 if citation_num in sources_dict: 

323 data = sources_dict[citation_num] 

324 return format_pattern(citation_num, data) 

325 return f"[{citation_num}]" 

326 

327 return formatter 

328 

329 def _replace_comma_citations(self, content, lookup, format_one): 

330 """Replace comma-separated citations like [1, 2, 3] using *lookup* and *format_one*. 

331 

332 Args: 

333 content: Text to process 

334 lookup: Dict mapping citation number (str) to data 

335 format_one: ``(num, data) -> str`` callback that formats a single citation 

336 """ 

337 

338 def _replacer(match): 

339 nums = [n.strip() for n in match.group(1).split(",")] 

340 parts = [] 

341 for num in nums: 

342 if num in lookup: 

343 parts.append(format_one(num, lookup[num])) 

344 else: 

345 parts.append(f"[{num}]") 

346 return "".join(parts) 

347 

348 return self.comma_citation_pattern.sub(_replacer, content) 

349 

350 def format_document(self, content: str) -> str: 

351 """Format citations in *content* and return the full document. 

352 

353 This is the legacy single-string entry point: it concatenates 

354 the answer half and the sources half produced by 

355 :meth:`format_document_split`. New code that needs to know 

356 where the boundary between answer and sources actually fell 

357 should use :meth:`format_document_split` instead so the boundary 

358 is returned explicitly (no re-parsing of the concatenated output). 

359 """ 

360 formatted_answer, sources_md, _on_sentinel = self.format_document_split( 

361 content 

362 ) 

363 return formatted_answer + sources_md 

364 

365 def format_document_split( 

366 self, content: str, *, trust_sentinel: bool = True 

367 ) -> Tuple[str, str, bool]: 

368 """Format citations and return ``(answer, sources_md, on_sentinel)``. 

369 

370 The boundary between the LLM's answer and the trailing Sources 

371 section is computed inside this method. Callers that only want 

372 the answer (e.g. the chat-mode save site) get a clean split 

373 without re-applying a regex on concatenated output downstream. 

374 ``on_sentinel`` mirrors the second element of 

375 :func:`find_sources_section`'s return — ``True`` iff the split 

376 boundary came from the appended-sources sentinel emitted by 

377 ``IntegratedReportGenerator``, in which case the boundary is 

378 correct by construction and downstream over-strip safety checks 

379 should be skipped. Pass ``trust_sentinel=False`` for raw LLM 

380 output (quick-mode save path) where the sentinel would only 

381 appear if the LLM quoted the marker itself. 

382 

383 Returns ``(content, "", False)`` when the formatter is in 

384 NO_HYPERLINKS mode or when no Sources section can be found in 

385 ``content``. 

386 """ 

387 if self.mode == CitationMode.NO_HYPERLINKS: 

388 return content, "", False 

389 

390 sources_start, on_sentinel = find_sources_section( 

391 content, trust_sentinel=trust_sentinel 

392 ) 

393 if sources_start == -1: 

394 return content, "", False 

395 

396 document_content = content[:sources_start] 

397 sources_content = content[sources_start:] 

398 

399 sources = self._parse_sources(sources_content) 

400 

401 if self.mode == CitationMode.NUMBER_HYPERLINKS: 

402 formatted_content = self._format_number_hyperlinks( 

403 document_content, sources 

404 ) 

405 elif self.mode == CitationMode.DOMAIN_HYPERLINKS: 

406 formatted_content = self._format_domain_hyperlinks( 

407 document_content, sources 

408 ) 

409 elif self.mode == CitationMode.DOMAIN_ID_HYPERLINKS: 

410 formatted_content = self._format_domain_id_hyperlinks( 

411 document_content, sources 

412 ) 

413 elif self.mode == CitationMode.DOMAIN_ID_ALWAYS_HYPERLINKS: 

414 formatted_content = self._format_domain_id_always_hyperlinks( 

415 document_content, sources 

416 ) 

417 elif self.mode == CitationMode.SOURCE_TAGGED_HYPERLINKS: 417 ↛ 424line 417 didn't jump to line 424 because the condition on line 417 was always true

418 formatted_content = self._format_source_tagged_hyperlinks( 

419 document_content, 

420 sources, 

421 self._parse_collections(sources_content), 

422 ) 

423 else: 

424 formatted_content = document_content 

425 

426 return formatted_content, sources_content, on_sentinel 

427 

428 def apply_inline_hyperlinks( 

429 self, content: str, sources: List[Dict[str, Any]] 

430 ) -> str: 

431 """Hyperlink ``[N]`` refs using a structured source list. 

432 

433 Dispatches on ``self.mode`` so the user's chosen citation 

434 format (Settings → Report → Citation Format) is honored on 

435 the fallback path the same way it is in 

436 :meth:`format_document_split`. Inherits all the existing 

437 per-mode guards (lookbehind/lookahead against ``[[1]]``, 

438 comma-list handling like ``[1,2,3]``, ``Source N`` word form, 

439 missing-index pass-through, lenticular bracket support). 

440 

441 Used as the safe fallback at save time when the LLM does NOT 

442 emit a Sources section in its prose — the structured source 

443 list (e.g. ``search_system.all_links_of_system``) is the 

444 canonical source of URLs and indices. 

445 """ 

446 if not content or not sources: 

447 return content or "" 

448 if self.mode == CitationMode.NO_HYPERLINKS: 

449 return content 

450 

451 # Search-engine result dicts use either "url" or "link" for the 

452 # destination — Searxng emits {"link": ..., "title": ..., "snippet": ...} 

453 # (search_engine_searxng.py:538) and other engines use "url". 

454 # Looking up only `s["url"]` silently dropped every Searxng-sourced 

455 # citation, leaving the answer body with plain `[N]` brackets even 

456 # though the Sources section beneath was fully populated. Accept 

457 # both keys so the hyperlink fallback works regardless of engine. 

458 def _src_url(s): 

459 return s.get("url") or s.get("link") or "" 

460 

461 adapted: Dict[str, Tuple[str, str]] = { 

462 str(s["index"]): (s.get("title", "Untitled"), _src_url(s)) 

463 for s in sources 

464 if _src_url(s) and s.get("index") is not None 

465 } 

466 if not adapted: 

467 return content 

468 

469 # Per-mode dispatch — mirrors format_document_split so the user's 

470 # chosen citation format applies on this fallback path too. 

471 # Previously this was hard-coded to _format_number_hyperlinks, 

472 # which meant chat-mode answers (which always hit this fallback 

473 # because the langgraph-agent synthesis doesn't emit a ## Sources 

474 # block in its prose) ignored the report.citation_format setting 

475 # entirely — every chat answer came out as [[N]](url) even when 

476 # the user picked domain-based or source-tagged formatting. 

477 if self.mode == CitationMode.DOMAIN_HYPERLINKS: 

478 return self._format_domain_hyperlinks(content, adapted) 

479 if self.mode == CitationMode.DOMAIN_ID_HYPERLINKS: 

480 return self._format_domain_id_hyperlinks(content, adapted) 

481 if self.mode == CitationMode.DOMAIN_ID_ALWAYS_HYPERLINKS: 481 ↛ 482line 481 didn't jump to line 482 because the condition on line 481 was never true

482 return self._format_domain_id_always_hyperlinks(content, adapted) 

483 if self.mode == CitationMode.SOURCE_TAGGED_HYPERLINKS: 

484 # Pull collection names off the structured source dicts 

485 # (format_links_to_markdown uses the same shape: 

486 # link["metadata"]["collection_name"]) so the SOURCE_TAGGED 

487 # formatter can surface library/RAG tags as the citation 

488 # label when present. 

489 collections: Dict[str, str] = {} 

490 for s in sources: 

491 idx = s.get("index") 

492 if idx is None: 492 ↛ 493line 492 didn't jump to line 493 because the condition on line 492 was never true

493 continue 

494 meta = s.get("metadata") or {} 

495 coll = meta.get("collection_name") 

496 if coll: 

497 collections.setdefault(str(idx), str(coll)) 

498 return self._format_source_tagged_hyperlinks( 

499 content, adapted, collections 

500 ) 

501 # NUMBER_HYPERLINKS is the default and the catch-all for any 

502 # mode added later that doesn't have an explicit branch above. 

503 return self._format_number_hyperlinks(content, adapted) 

504 

505 def _find_sources_section( 

506 self, content: str, trust_sentinel: bool = True 

507 ) -> Tuple[int, bool]: 

508 """Find the start of the sources/references section. 

509 

510 Returns ``(position, on_sentinel)`` — see 

511 :func:`find_sources_section` for the semantics. ``on_sentinel`` 

512 is True iff the boundary came from the unique sentinel emitted 

513 by the report generator (i.e. it is correct by construction). 

514 """ 

515 return find_sources_section(content, trust_sentinel=trust_sentinel) 

516 

517 def _parse_sources( 

518 self, sources_content: str 

519 ) -> Dict[str, Tuple[str, str]]: 

520 """ 

521 Parse sources section to extract citation numbers, titles, and URLs. 

522 

523 Returns: 

524 Dictionary mapping citation number to (title, url) tuple 

525 """ 

526 sources = {} 

527 matches = list(self.sources_pattern.finditer(sources_content)) 

528 

529 for match in matches: 

530 citation_nums_str = match.group(1) 

531 title = match.group(2).strip() 

532 url = match.group(3).strip() if match.group(3) else "" 

533 

534 # Handle comma-separated citation numbers like [36, 3] 

535 # Split by comma and strip whitespace 

536 individual_nums = [ 

537 num.strip() for num in citation_nums_str.split(",") 

538 ] 

539 

540 # Add an entry for each individual number 

541 for num in individual_nums: 

542 sources[num] = (title, url) 

543 

544 return sources 

545 

546 def _format_number_hyperlinks( 

547 self, content: str, sources: Dict[str, Tuple[str, str]] 

548 ) -> str: 

549 """Replace [1] with hyperlinked version where only the number is linked.""" 

550 # Filter sources that have URLs 

551 url_sources = { 

552 num: (title, url) for num, (title, url) in sources.items() if url 

553 } 

554 

555 # Create formatter for citations with number hyperlinks 

556 def format_number_link(citation_num, data): 

557 _, url = data 

558 return f"[[{citation_num}]]({url})" 

559 

560 # Handle comma-separated citations like [1, 2, 3] 

561 content = self._replace_comma_citations( 

562 content, url_sources, format_number_link 

563 ) 

564 

565 formatter = self._create_citation_formatter( 

566 url_sources, format_number_link 

567 ) 

568 

569 # Handle individual citations 

570 def replace_citation(match): 

571 return ( 

572 formatter(match.group(1)) 

573 if match.group(1) in url_sources 

574 else match.group(0) 

575 ) 

576 

577 content = self.citation_pattern.sub(replace_citation, content) 

578 

579 # Also handle "Source X" patterns 

580 return self.source_word_pattern.sub( 

581 self._create_source_word_replacer(formatter), content 

582 ) 

583 

584 def _format_domain_hyperlinks( 

585 self, content: str, sources: Dict[str, Tuple[str, str]] 

586 ) -> str: 

587 """Replace [1] with [domain.com] hyperlinked version.""" 

588 

589 # Filter sources that have URLs 

590 url_sources = { 

591 num: (title, url) for num, (title, url) in sources.items() if url 

592 } 

593 

594 # Pre-compute labels so [1, 2, 3] lists and standalone [N] refs 

595 # use the same string for a given citation. Previously this 

596 # closure called _extract_domain on every match, so a relative 

597 # URL (``/library/document/...``) produced ``[[]]`` for every 

598 # occurrence. _citation_label falls back to a slugified title 

599 # for relative URLs, keeping the label non-empty and readable. 

600 label_cache: Dict[str, str] = { 

601 num: self._citation_label(num, title, url) 

602 for num, (title, url) in url_sources.items() 

603 } 

604 

605 # Create formatter for citations with domain hyperlinks 

606 def format_domain_link(citation_num, data): 

607 _, url = data 

608 label = label_cache[citation_num] 

609 return f"[[{label}]]({url})" 

610 

611 # Handle comma-separated citations like [1, 2, 3] 

612 content = self._replace_comma_citations( 

613 content, url_sources, format_domain_link 

614 ) 

615 

616 formatter = self._create_citation_formatter( 

617 url_sources, format_domain_link 

618 ) 

619 

620 # Handle individual citations 

621 def replace_citation(match): 

622 return ( 

623 formatter(match.group(1)) 

624 if match.group(1) in url_sources 

625 else match.group(0) 

626 ) 

627 

628 content = self.citation_pattern.sub(replace_citation, content) 

629 

630 # Also handle "Source X" patterns 

631 return self.source_word_pattern.sub( 

632 self._create_source_word_replacer(formatter), content 

633 ) 

634 

635 def _format_domain_id_hyperlinks( 

636 self, content: str, sources: Dict[str, Tuple[str, str]] 

637 ) -> str: 

638 """Replace [1] with [domain.com-1] hyperlinked version with hyphen-separated IDs.""" 

639 # First, create a mapping of domains to their citation numbers. 

640 # _citation_label (vs raw _extract_domain) means relative URLs 

641 # like ``/library/document/...`` get a slugified title as the 

642 # "domain" instead of an empty string. Without this, a RAG 

643 # result with one citation rendered as ``[[]](url)`` (the 

644 # label disappeared), and a multi-citation set rendered as 

645 # ``[[-1]](url)`` (the orphaned ``-N`` suffix leaked through). 

646 domain_citations: dict[str, list[Any]] = {} 

647 

648 for citation_num, (title, url) in sources.items(): 

649 if url: 

650 domain = self._citation_label(citation_num, title, url) 

651 if domain not in domain_citations: 

652 domain_citations[domain] = [] 

653 domain_citations[domain].append((citation_num, url)) 

654 

655 # Create a mapping from citation number to domain with ID 

656 citation_to_domain_id = {} 

657 for domain, citations in domain_citations.items(): 

658 if len(citations) > 1: 

659 # Multiple citations from same domain - add hyphen and number 

660 for idx, (citation_num, url) in enumerate(citations, 1): 

661 citation_to_domain_id[citation_num] = ( 

662 f"{domain}-{idx}", 

663 url, 

664 ) 

665 else: 

666 # Single citation from domain - no ID needed 

667 citation_num, url = citations[0] 

668 citation_to_domain_id[citation_num] = (domain, url) 

669 

670 # Create formatter for citations with domain_id hyperlinks 

671 def format_domain_id_link(citation_num, data): 

672 domain_id, url = data 

673 return f"[[{domain_id}]]({url})" 

674 

675 # Handle comma-separated citations 

676 content = self._replace_comma_citations( 

677 content, citation_to_domain_id, format_domain_id_link 

678 ) 

679 

680 formatter = self._create_citation_formatter( 

681 citation_to_domain_id, format_domain_id_link 

682 ) 

683 

684 # Handle individual citations 

685 def replace_citation(match): 

686 return ( 

687 formatter(match.group(1)) 

688 if match.group(1) in citation_to_domain_id 

689 else match.group(0) 

690 ) 

691 

692 content = self.citation_pattern.sub(replace_citation, content) 

693 

694 # Also handle "Source X" patterns 

695 return self.source_word_pattern.sub( 

696 self._create_source_word_replacer(formatter), content 

697 ) 

698 

699 def _format_domain_id_always_hyperlinks( 

700 self, content: str, sources: Dict[str, Tuple[str, str]] 

701 ) -> str: 

702 """Replace [1] with [domain.com-1] hyperlinked version, always with IDs.""" 

703 # Use _citation_label so relative URLs (e.g. RAG /library/document/... 

704 # results) produce a slugified-title prefix instead of an empty 

705 # string. Without this, every label here collapses to ``-N`` 

706 # (e.g. ``[[-1]](url)``) and the user has no idea what the link 

707 # references. See _format_domain_id_hyperlinks for the same fix. 

708 domain_citations: dict[str, list[Any]] = {} 

709 

710 for citation_num, (title, url) in sources.items(): 

711 if url: 

712 domain = self._citation_label(citation_num, title, url) 

713 if domain not in domain_citations: 

714 domain_citations[domain] = [] 

715 domain_citations[domain].append((citation_num, url)) 

716 

717 # Create a mapping from citation number to domain with ID 

718 citation_to_domain_id = {} 

719 for domain, citations in domain_citations.items(): 

720 # Always add hyphen and number for consistency 

721 for idx, (citation_num, url) in enumerate(citations, 1): 

722 citation_to_domain_id[citation_num] = (f"{domain}-{idx}", url) 

723 

724 # Create formatter for citations with domain_id hyperlinks 

725 def format_domain_id_link(citation_num, data): 

726 domain_id, url = data 

727 return f"[[{domain_id}]]({url})" 

728 

729 # Handle comma-separated citations 

730 content = self._replace_comma_citations( 

731 content, citation_to_domain_id, format_domain_id_link 

732 ) 

733 

734 formatter = self._create_citation_formatter( 

735 citation_to_domain_id, format_domain_id_link 

736 ) 

737 

738 # Handle individual citations 

739 def replace_citation(match): 

740 return ( 

741 formatter(match.group(1)) 

742 if match.group(1) in citation_to_domain_id 

743 else match.group(0) 

744 ) 

745 

746 content = self.citation_pattern.sub(replace_citation, content) 

747 

748 # Also handle "Source X" patterns 

749 return self.source_word_pattern.sub( 

750 self._create_source_word_replacer(formatter), content 

751 ) 

752 

753 # Sources section may carry a "Collection: <name>" line for RAG / 

754 # library hits (emitted by ``utilities/search_utilities.format_links_to_markdown``). 

755 # The line sits between this ``[N]`` entry's ``URL:`` line and the 

756 # next ``[N+1]`` entry. We anchor the match on a non-greedy span up 

757 # to the next citation header (or end of string) to scope correctly. 

758 _collection_line_pattern = re.compile( 

759 r"^\[(\d+(?:,\s*\d+)*)\][^\n]*\n" # the [N] header line 

760 r"(?:[^\n\[]*\n)*?" # any non-[ lines (typically URL: ...) 

761 r"\s*Collection:\s*(.+?)\s*$", 

762 re.MULTILINE, 

763 ) 

764 

765 def _parse_collections(self, sources_content: str) -> Dict[str, str]: 

766 """Extract ``{citation_num: collection_name}`` from a sources 

767 block. Returns an empty dict when no ``Collection:`` lines exist 

768 — the absence of collection info is the common case (web URLs) 

769 and must never raise.""" 

770 collections: Dict[str, str] = {} 

771 for match in self._collection_line_pattern.finditer(sources_content): 

772 citation_nums_str = match.group(1) 

773 collection = match.group(2).strip() 

774 if not collection: 774 ↛ 775line 774 didn't jump to line 775 because the condition on line 774 was never true

775 continue 

776 for num in (n.strip() for n in citation_nums_str.split(",")): 

777 collections[num] = collection 

778 return collections 

779 

780 def _format_source_tagged_hyperlinks( 

781 self, 

782 content: str, 

783 sources: Dict[str, Tuple[str, str]], 

784 collections: Dict[str, str], 

785 ) -> str: 

786 """Replace ``[N]`` with ``[[source-N]](url)``. 

787 

788 ``source`` resolves to (in order): the RAG ``Collection:`` 

789 tag for library hits, the short URLClassifier tag for known 

790 academic sources (``arxiv``, ``pubmed``, ...), the cleaned 

791 domain otherwise, or ``local`` for empty/file URLs. ``N`` is 

792 the original global citation number — labels never collide and 

793 the suffix always matches the bibliography ordering. 

794 

795 Args: 

796 content: Document body (sources section already split off). 

797 sources: ``{citation_num: (title, url)}`` parsed from the 

798 sources block. 

799 collections: ``{citation_num: collection_name}`` parsed from 

800 optional ``Collection:`` lines in the sources block 

801 (empty dict when no library/RAG hits are cited). Wins 

802 over URL-derived tags when present for a given citation. 

803 """ 

804 

805 # Pre-compute tags so each URL-having source resolves its label 

806 # once, however many [N] / [1, 2, 3] refs cite it. 

807 tag_cache: Dict[str, str] = { 

808 num: self._extract_source_label( 

809 url, collection=collections.get(num) 

810 ) 

811 for num, (_, url) in sources.items() 

812 if url 

813 } 

814 

815 def format_link(citation_num, data): 

816 _, url = data 

817 label = tag_cache.get(citation_num) 

818 if label is None: 

819 # Only URL-less sources are absent from tag_cache. Don't 

820 # pass this as dict.get()'s default — that would evaluate 

821 # the expensive lookup eagerly on every cache hit too. 

822 label = self._extract_source_label( 

823 url, collection=collections.get(citation_num) 

824 ) 

825 tag = f"{label}-{citation_num}" 

826 # Only emit a hyperlink for http(s) URLs — local/file URLs are 

827 # rendered as plain bracketed tags so the markdown stays clean 

828 # and viewers don't try to navigate to a server-local path. 

829 return ( 

830 f"[[{tag}]]({url})" 

831 if self._is_linkable_url(url) 

832 else f"[{tag}]" 

833 ) 

834 

835 # Handle comma-separated citations like [1, 2, 3] 

836 content = self._replace_comma_citations(content, sources, format_link) 

837 

838 formatter = self._create_citation_formatter(sources, format_link) 

839 

840 # Handle individual citations 

841 def replace_citation(match): 

842 return ( 

843 formatter(match.group(1)) 

844 if match.group(1) in sources 

845 else match.group(0) 

846 ) 

847 

848 content = self.citation_pattern.sub(replace_citation, content) 

849 

850 # Also handle "Source X" patterns 

851 return self.source_word_pattern.sub( 

852 self._create_source_word_replacer(formatter), content 

853 ) 

854 

855 @staticmethod 

856 def _slugify_collection(name: str) -> str: 

857 """Make a user-set collection name safe for inline citations. 

858 

859 Collection names are free-form strings (``"My Papers"``, 

860 ``"team/finance"``). Citations need a compact token that won't 

861 break markdown — strip whitespace, lowercase, replace runs of 

862 non-alphanumeric chars with a single hyphen, trim leading and 

863 trailing hyphens, and fall back to ``"local"`` if the result is 

864 empty. ``-N`` is appended downstream so we strip trailing 

865 hyphens to keep the join clean. 

866 """ 

867 slug = re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-") 

868 return slug or "local" 

869 

870 @staticmethod 

871 def _slugify_title(title: str, max_length: int = 32) -> Optional[str]: 

872 """Generate a slug from a document title using python-slugify. 

873 

874 Returns ``None`` when ``title`` is empty / whitespace / pure 

875 punctuation, or when ``slugify`` produces an empty result. The 

876 ``None`` sentinel lets callers distinguish "no meaningful slug" 

877 from "slug happens to be a real word" (e.g. a document literally 

878 titled ``"Doc"`` must yield ``"doc"`` as its label, not fall 

879 through to the citation-number fallback). 

880 """ 

881 if not title: 

882 return None 

883 slug = slugify(title, max_length=max_length, separator="-") 

884 return slug if slug else None 

885 

886 def _citation_label(self, citation_num: str, title: str, url: str) -> str: 

887 """Return the inline-citation label for a source. 

888 

889 Order of preference: 

890 1. The URL's domain (``arxiv.org``, ``example.com``) — used by 

891 web citations. Falls through when the URL is relative or 

892 empty, as is common for RAG / library documents. 

893 2. A slugified version of the document title (truncated to 

894 keep labels compact) — gives users a readable label for 

895 local-library hits. 

896 3. The citation number itself — last-resort guarantee the 

897 label is never empty (an empty label produces ``[[]]`` 

898 which renders as an uninformative ``[]`` link). 

899 """ 

900 domain = self._extract_domain(url) if url else "" 

901 if domain: 

902 return domain 

903 slug = self._slugify_title(title) 

904 if slug: 

905 return slug 

906 return str(citation_num) 

907 

908 @staticmethod 

909 def _is_linkable_url(url: str) -> bool: 

910 """Return True iff ``url`` is a http(s) URL safe to wrap in a 

911 markdown hyperlink. Empty strings and file:// / local: schemes 

912 are not linkable.""" 

913 if not url: 

914 return False 

915 try: 

916 scheme = (urlparse(url).scheme or "").lower() 

917 except (ValueError, AttributeError): 

918 return False 

919 return scheme in ("http", "https") 

920 

921 def _extract_source_label( 

922 self, url: str, collection: str | None = None 

923 ) -> str: 

924 """Return a short source tag for ``url``. 

925 

926 Resolution order: 

927 1. ``collection`` (when supplied) wins outright — RAG / library 

928 hits surface their collection name as the citation tag 

929 (``mypapers``, ``personal-notes``, ...). The renderer in 

930 ``utilities/search_utilities.format_links_to_markdown`` 

931 emits a ``Collection:`` line per source for library results, 

932 which the formatter parses back into this argument. 

933 2. Empty URL or non-http(s) scheme (``file://``, ``local:``, ...) → 

934 ``"local"``. Uniform fallback when no collection name is 

935 available. 

936 3. ``URLClassifier`` matches a known academic source → use the 

937 enum value (``arxiv``, ``pubmed``, ``pmc``, ``biorxiv``, 

938 ``medrxiv``, ``semantic_scholar``, ``doi``). 

939 4. Otherwise → fall back to ``_extract_domain`` (e.g. 

940 ``arxiv.org``, ``nytimes.com``). 

941 """ 

942 if collection: 

943 return self._slugify_collection(collection) 

944 if not url: 

945 return "local" 

946 try: 

947 parsed = urlparse(url) 

948 except (ValueError, AttributeError): 

949 return "local" 

950 scheme = (parsed.scheme or "").lower() 

951 if scheme not in ("http", "https"): 

952 return "local" 

953 

954 url_type = URLClassifier.classify(url) 

955 # Generic HTML/PDF/INVALID → fall back to domain. Everything else 

956 # is a known academic source whose enum value is the short tag. 

957 if url_type in (URLType.HTML, URLType.PDF, URLType.INVALID): 

958 return self._extract_domain(url) 

959 return str(url_type.value) 

960 

961 def _extract_domain(self, url: str) -> str: 

962 """Extract domain name from URL.""" 

963 try: 

964 parsed = urlparse(url) 

965 domain = parsed.netloc 

966 # Remove www. prefix if present 

967 if domain.startswith("www."): 

968 domain = domain[4:] 

969 # Keep known domains as-is 

970 known_domains = { 

971 "arxiv.org": "arxiv.org", 

972 "github.com": "github.com", 

973 "reddit.com": "reddit.com", 

974 "youtube.com": "youtube.com", 

975 "pypi.org": "pypi.org", 

976 "milvus.io": "milvus.io", 

977 "medium.com": "medium.com", 

978 } 

979 

980 for known, display in known_domains.items(): 

981 if known in domain: 

982 return display 

983 

984 # For other domains, extract main domain 

985 parts = domain.split(".") 

986 if len(parts) >= 2: 

987 return ".".join(parts[-2:]) 

988 return domain 

989 except (ValueError, AttributeError): 

990 return "source" 

991 

992 

993class QuartoExporter: 

994 """Export markdown documents to Quarto (.qmd) format.""" 

995 

996 def __init__(self): 

997 # Also match Unicode lenticular brackets 【】 (U+3010 and U+3011) that LLMs sometimes generate 

998 self.citation_pattern = re.compile( 

999 r"(?<![\[【])[\[【](\d+)[\]】](?![\]】])" 

1000 ) 

1001 self.comma_citation_pattern = re.compile( 

1002 r"[\[【](\d+(?:,\s*\d+)+)[\]】]" 

1003 ) 

1004 

1005 def export_to_quarto(self, content: str, title: str | None = None) -> str: 

1006 """ 

1007 Convert markdown document to Quarto format. 

1008 

1009 Args: 

1010 content: Markdown content 

1011 title: Document title (if None, will extract from content) 

1012 

1013 Returns: 

1014 Quarto formatted content 

1015 """ 

1016 # Extract title from markdown if not provided 

1017 if not title: 

1018 title_match = re.search(r"^#\s+(.+)$", content, re.MULTILINE) 

1019 title = title_match.group(1) if title_match else "Research Report" 

1020 

1021 # Create Quarto YAML header 

1022 from datetime import UTC, datetime 

1023 

1024 current_date = datetime.now(UTC).strftime("%Y-%m-%d") 

1025 yaml_header = f"""--- 

1026title: "{title}" 

1027author: "Local Deep Research" 

1028date: "{current_date}" 

1029format: 

1030 html: 

1031 toc: true 

1032 toc-depth: 3 

1033 number-sections: true 

1034 pdf: 

1035 toc: true 

1036 number-sections: true 

1037 colorlinks: true 

1038bibliography: references.bib 

1039csl: apa.csl 

1040--- 

1041 

1042""" 

1043 

1044 # Process content 

1045 processed_content = content 

1046 

1047 # First handle comma-separated citations like [1, 2, 3] 

1048 def replace_comma_citations(match): 

1049 citation_nums = match.group(1) 

1050 # Split by comma and strip whitespace 

1051 nums = [num.strip() for num in citation_nums.split(",")] 

1052 refs = [f"@ref{num}" for num in nums] 

1053 return f"[{', '.join(refs)}]" 

1054 

1055 processed_content = self.comma_citation_pattern.sub( 

1056 replace_comma_citations, processed_content 

1057 ) 

1058 

1059 # Then convert individual citations to Quarto format [@citation] 

1060 def replace_citation(match): 

1061 citation_num = match.group(1) 

1062 return f"[@ref{citation_num}]" 

1063 

1064 processed_content = self.citation_pattern.sub( 

1065 replace_citation, processed_content 

1066 ) 

1067 

1068 # Generate bibliography file content 

1069 bib_content = self._generate_bibliography(content) 

1070 

1071 # Add note about bibliography file 

1072 bibliography_note = ( 

1073 "\n\n::: {.callout-note}\n## Bibliography File Required\n\nThis document requires a `references.bib` file in the same directory with the following content:\n\n```bibtex\n" 

1074 + bib_content 

1075 + "\n```\n:::\n" 

1076 ) 

1077 

1078 return yaml_header + processed_content + bibliography_note 

1079 

1080 def _generate_bibliography(self, content: str) -> str: 

1081 """Generate BibTeX bibliography from sources.""" 

1082 entries = _collect_bibliography_entries(content) 

1083 

1084 bibliography = "" 

1085 for citation_num in sorted(entries, key=_sort_key): 

1086 title, url = entries[citation_num] 

1087 safe_title = _escape_bibtex(title) 

1088 safe_url = _safe_bibtex_url(url) 

1089 bib_entry = f"@misc{{ref{citation_num},\n" 

1090 bib_entry += f' title = "{{{safe_title}}}",\n' 

1091 if safe_url: 

1092 bib_entry += f" url = {{{safe_url}}},\n" 

1093 bib_entry += f' howpublished = "\\url{{{safe_url}}}",\n' 

1094 bib_entry += f" year = {{{2024}}},\n" 

1095 bib_entry += ' note = "Accessed: \\today"\n' 

1096 bib_entry += "}\n" 

1097 

1098 bibliography += bib_entry + "\n" 

1099 

1100 return bibliography.strip() 

1101 

1102 

1103class RISExporter: 

1104 """Export references to RIS format for reference managers like Zotero.""" 

1105 

1106 def __init__(self): 

1107 self.sources_pattern = re.compile( 

1108 r"^\[(\d+(?:,\s*\d+)*)\]\s*(.+?)(?:\n\s*URL:\s*(.+?))?$", 

1109 re.MULTILINE, 

1110 ) 

1111 

1112 def export_to_ris(self, content: str) -> str: 

1113 """ 

1114 Extract references from markdown and convert to RIS format. 

1115 

1116 Args: 

1117 content: Markdown content with sources 

1118 

1119 Returns: 

1120 RIS formatted references 

1121 """ 

1122 # Find sources section 

1123 sources_start, _on_sentinel = find_sources_section(content) 

1124 if sources_start == -1: 

1125 return "" 

1126 

1127 # Find the end of the first sources section (before any other major section) 

1128 sources_content = content[sources_start:] 

1129 

1130 # Look for the next major section to avoid duplicates 

1131 next_section_markers = [ 

1132 "\n## ALL SOURCES", 

1133 "\n### ALL SOURCES", 

1134 "\n## Research Metrics", 

1135 "\n### Research Metrics", 

1136 "\n## SEARCH QUESTIONS", 

1137 "\n### SEARCH QUESTIONS", 

1138 "\n## DETAILED FINDINGS", 

1139 "\n### DETAILED FINDINGS", 

1140 "\n---", # Horizontal rule often separates sections 

1141 ] 

1142 

1143 sources_end = len(sources_content) 

1144 for marker in next_section_markers: 

1145 pos = sources_content.find(marker) 

1146 if pos != -1 and pos < sources_end: 

1147 sources_end = pos 

1148 

1149 sources_content = sources_content[:sources_end] 

1150 

1151 # Parse sources and generate RIS entries 

1152 ris_entries = [] 

1153 seen_refs = set() # Track which references we've already processed 

1154 

1155 # Split sources into individual entries 

1156 import re 

1157 

1158 # Pattern to match each source entry. The opener is GROUP-AWARE 

1159 # (``\d+(?:,\s*\d+)*``, the spelling used by 

1160 # ``comma_citation_pattern`` and ``_BIB_SOURCES_PATTERN`` above): 

1161 # ``format_links_to_markdown`` collapses every citation of one source 

1162 # onto a single merged ``[1, 3] Title`` line, which is routine for 

1163 # library documents since per-document keying (#5685). A single-index 

1164 # ``(\d+)`` capture did not merely drop such a line — it MIS-CITED 

1165 # (#5687), because the lookahead did not terminate at a merged opener 

1166 # either, so the PRECEDING entry swallowed the merged line and the 

1167 # last-wins ``URL:`` scan below then handed that entry the merged 

1168 # line's URL: one source's title against another source's link. Hence 

1169 # the group spelling appears TWICE — once in the capture, once in the 

1170 # lookahead — and the loop emits one record PER INDEX so that every 

1171 # ``[N]`` in the body resolves to a reference of its own. 

1172 # 

1173 # Four properties here are deliberate; keep them when editing: 

1174 # 1. This parser stays bounded to the FIRST Sources section by the 

1175 # ``next_section_markers`` scan above. Do not swap in the 

1176 # module-level ``_collect_bibliography_entries`` — that helper 

1177 # is deliberately not section-scoped, which is why #5687 notes 

1178 # RIS is not a mechanical swap even though BibTeX, Quarto and 

1179 # LaTeX were fixed by adopting it. 

1180 # 2. Lenticular "【N】" openers/closers are accepted alongside 

1181 # ASCII "[N]": the inline citation patterns in this file already 

1182 # handle them (some LLMs emit them), so the source-list parser 

1183 # must stay consistent or it would silently drop those entries. 

1184 # 3. Entries are split by LOOKAHEAD under ``DOTALL`` so continuation 

1185 # lines (``URL:``, ``DOI:``, ``Published in``) stay attached to 

1186 # the entry they belong to; a line-anchored ``$`` split would 

1187 # strip them. 

1188 # 4. ``self.sources_pattern`` above is group-aware but UNUSED, and 

1189 # is not this fix — it is single-line and section-unaware, so 

1190 # wiring it in would lose properties 1 and 3. 

1191 source_entry_pattern = re.compile( 

1192 r"^[\[【](\d+(?:,\s*\d+)*)[\]】]\s*(.+?)" 

1193 r"(?=^[\[【]\d+(?:,\s*\d+)*[\]】]|\Z)", 

1194 re.MULTILINE | re.DOTALL, 

1195 ) 

1196 

1197 for match in source_entry_pattern.finditer(sources_content): 

1198 citation_group = match.group(1) 

1199 entry_text = match.group(2).strip() 

1200 

1201 # Extract the title (first line) 

1202 lines = entry_text.split("\n") 

1203 title = lines[0].strip() 

1204 

1205 # Extract URL, DOI, and other metadata from subsequent lines 

1206 url = "" 

1207 metadata = {} 

1208 for line in lines[1:]: 

1209 line = line.strip() 

1210 if line.startswith("URL:"): 

1211 url = line[4:].strip() 

1212 elif line.startswith("DOI:"): 

1213 metadata["doi"] = line[4:].strip() 

1214 elif line.startswith("Published in"): 

1215 metadata["journal"] = line[12:].strip() 

1216 # Add more metadata parsing as needed 

1217 elif line: 

1218 # Store other lines as additional metadata 

1219 if "additional" not in metadata: 1219 ↛ 1221line 1219 didn't jump to line 1221 because the condition on line 1219 was always true

1220 metadata["additional"] = [] 

1221 additional = metadata["additional"] 

1222 if isinstance(additional, list): 1222 ↛ 1208line 1222 didn't jump to line 1208 because the condition on line 1222 was always true

1223 additional.append(line) 

1224 

1225 # Combine title with additional metadata lines for full context 

1226 full_text = entry_text 

1227 

1228 # One record per index, keyed on the INDIVIDUAL index rather than 

1229 # the group text: a report repeats its Sources block, and the same 

1230 # source may be written ``[1]`` in one block and ``[1, 3]`` in 

1231 # another, so keying on the raw group would let ``ref1`` be emitted 

1232 # twice. ``metadata`` is shared across the indices of one line; 

1233 # ``_create_ris_entry`` only reads it. 

1234 for citation_num in _iter_citation_group(citation_group): 

1235 ref_key = (citation_num, title, url) 

1236 if ref_key not in seen_refs: 

1237 seen_refs.add(ref_key) 

1238 # Create RIS entry with full text for metadata extraction 

1239 ris_entry = self._create_ris_entry( 

1240 citation_num, full_text, url, metadata 

1241 ) 

1242 ris_entries.append(ris_entry) 

1243 

1244 return "\n".join(ris_entries) 

1245 

1246 def _create_ris_entry( 

1247 self, 

1248 ref_id: str, 

1249 full_text: str, 

1250 url: str = "", 

1251 metadata: dict | None = None, 

1252 ) -> str: 

1253 """Create a single RIS entry.""" 

1254 lines = [] 

1255 

1256 # Parse metadata from full text 

1257 import re 

1258 

1259 if metadata is None: 

1260 metadata = {} 

1261 

1262 # Extract title from first line. NB: split into a *separate* variable — 

1263 # ``lines`` is the RIS-output accumulator initialized above and appended 

1264 # to below; reusing it here previously overwrote it with the source 

1265 # text, so every entry emitted the raw source body before the mandatory 

1266 # leading ``TY - `` tag and reference managers rejected the file. 

1267 text_lines = full_text.split("\n") 

1268 title = text_lines[0].strip() 

1269 

1270 # Extract year from full text (looks for 4-digit year) 

1271 year_match = re.search(r"\b(19\d{2}|20\d{2})\b", full_text) 

1272 year = year_match.group(1) if year_match else None 

1273 

1274 # Extract authors if present (looks for "by Author1, Author2") 

1275 authors_match = re.search( 

1276 r"\bby\s+([^.\n]+?)(?:\.|\n|$)", full_text, re.IGNORECASE 

1277 ) 

1278 authors = [] 

1279 if authors_match: 

1280 authors_text = authors_match.group(1) 

1281 # Split by 'and' or ',' 

1282 author_parts = re.split(r"\s*(?:,|\sand\s|&)\s*", authors_text) 

1283 authors = [a.strip() for a in author_parts if a.strip()] 

1284 

1285 # Extract DOI from metadata or text 

1286 doi = metadata.get("doi") 

1287 if not doi: 

1288 doi_match = re.search( 

1289 r"DOI:\s*([^\s\n]+)", full_text, re.IGNORECASE 

1290 ) 

1291 doi = doi_match.group(1) if doi_match else None 

1292 

1293 # Clean title - remove author and metadata info for cleaner title 

1294 clean_title = title 

1295 if authors_match and authors_match.start() < len(title): 

1296 clean_title = ( 

1297 title[: authors_match.start()] + title[authors_match.end() :] 

1298 if authors_match.end() < len(title) 

1299 else title[: authors_match.start()] 

1300 ) 

1301 clean_title = re.sub( 

1302 r"\s*DOI:\s*[^\s]+", "", clean_title, flags=re.IGNORECASE 

1303 ) 

1304 clean_title = re.sub( 

1305 r"\s*Published in.*", "", clean_title, flags=re.IGNORECASE 

1306 ) 

1307 clean_title = re.sub( 

1308 r"\s*Volume.*", "", clean_title, flags=re.IGNORECASE 

1309 ) 

1310 clean_title = re.sub( 

1311 r"\s*Pages.*", "", clean_title, flags=re.IGNORECASE 

1312 ) 

1313 clean_title = clean_title.strip() 

1314 

1315 # TY - Type of reference (ELEC for electronic source/website) 

1316 lines.append("TY - ELEC") 

1317 

1318 # ID - Reference ID 

1319 lines.append(f"ID - ref{ref_id}") 

1320 

1321 # TI - Title 

1322 lines.append(f"TI - {clean_title if clean_title else title}") 

1323 

1324 # AU - Authors 

1325 for author in authors: 

1326 lines.append(f"AU - {author}") 

1327 

1328 # DO - DOI 

1329 if doi: 

1330 lines.append(f"DO - {doi}") 

1331 

1332 # PY - Publication year (if found in title) 

1333 if year: 

1334 lines.append(f"PY - {year}") 

1335 

1336 # UR - URL 

1337 if url: 

1338 lines.append(f"UR - {url}") 

1339 

1340 # Try to extract domain as publisher 

1341 try: 

1342 from urllib.parse import urlparse 

1343 

1344 parsed = urlparse(url) 

1345 domain = parsed.netloc 

1346 if domain.startswith("www."): 

1347 domain = domain[4:] 

1348 # Extract readable publisher name from domain 

1349 if domain == "github.com" or domain.endswith(".github.com"): 

1350 lines.append("PB - GitHub") 

1351 elif domain == "arxiv.org" or domain.endswith(".arxiv.org"): 

1352 lines.append("PB - arXiv") 

1353 elif domain == "reddit.com" or domain.endswith(".reddit.com"): 

1354 lines.append("PB - Reddit") 

1355 elif ( 

1356 domain == "youtube.com" 

1357 or domain == "m.youtube.com" 

1358 or domain.endswith(".youtube.com") 

1359 ): 

1360 lines.append("PB - YouTube") 

1361 elif domain == "medium.com" or domain.endswith(".medium.com"): 

1362 lines.append("PB - Medium") 

1363 elif domain == "pypi.org" or domain.endswith(".pypi.org"): 

1364 lines.append("PB - Python Package Index (PyPI)") 

1365 else: 

1366 # Use domain as publisher 

1367 lines.append(f"PB - {domain}") 

1368 except (ValueError, AttributeError): 

1369 pass 

1370 

1371 # Y1 - Year accessed (current year) 

1372 from datetime import UTC, datetime 

1373 

1374 current_year = datetime.now(UTC).year 

1375 lines.append(f"Y1 - {current_year}") 

1376 

1377 # DA - Date accessed 

1378 current_date = datetime.now(UTC).strftime("%Y/%m/%d") 

1379 lines.append(f"DA - {current_date}") 

1380 

1381 # LA - Language 

1382 lines.append("LA - en") 

1383 

1384 # ER - End of reference 

1385 lines.append("ER - ") 

1386 

1387 return "\n".join(lines) 

1388 

1389 

1390class LaTeXExporter: 

1391 """Export markdown documents to LaTeX format.""" 

1392 

1393 def __init__(self): 

1394 # Also match Unicode lenticular brackets 【】 (U+3010 and U+3011) that LLMs sometimes generate 

1395 self.citation_pattern = re.compile(r"[\[【](\d+)[\]】]") 

1396 self.heading_patterns = [ 

1397 (re.compile(r"^# (.+)$", re.MULTILINE), r"\\section{\1}"), 

1398 (re.compile(r"^## (.+)$", re.MULTILINE), r"\\subsection{\1}"), 

1399 (re.compile(r"^### (.+)$", re.MULTILINE), r"\\subsubsection{\1}"), 

1400 ] 

1401 self.emphasis_patterns = [ 

1402 (re.compile(r"\*\*(.+?)\*\*"), r"\\textbf{\1}"), 

1403 (re.compile(r"\*(.+?)\*"), r"\\textit{\1}"), 

1404 (re.compile(r"`(.+?)`"), r"\\texttt{\1}"), 

1405 ] 

1406 

1407 def export_to_latex(self, content: str) -> str: 

1408 """ 

1409 Convert markdown document to LaTeX format. 

1410 

1411 Args: 

1412 content: Markdown content 

1413 

1414 Returns: 

1415 LaTeX formatted content 

1416 """ 

1417 latex_content = self._create_latex_header() 

1418 

1419 # Convert markdown to LaTeX 

1420 body_content = content 

1421 

1422 # Escape special LaTeX characters but preserve math mode 

1423 # Split by $ to preserve math sections 

1424 parts = body_content.split("$") 

1425 for i in range(len(parts)): 

1426 # Even indices are outside math mode 

1427 if i % 2 == 0: 

1428 # Only escape if not inside $$ 

1429 if not ( 

1430 i > 0 

1431 and parts[i - 1] == "" 

1432 and i < len(parts) - 1 

1433 and parts[i + 1] == "" 

1434 ): 

1435 # Preserve certain patterns that will be processed later 

1436 # like headings (#), emphasis (*), and citations ([n]) 

1437 lines = parts[i].split("\n") 

1438 for j, line in enumerate(lines): 

1439 # Don't escape lines that start with # (headings) 

1440 if not line.strip().startswith("#"): 

1441 # Don't escape emphasis markers or citations for now 

1442 # They'll be handled by their own patterns 

1443 temp_line = line 

1444 # Escape special chars except *, #, [, ] 

1445 temp_line = temp_line.replace("&", r"\&") 

1446 temp_line = temp_line.replace("%", r"\%") 

1447 temp_line = temp_line.replace("_", r"\_") 

1448 # Don't escape { } inside citations 

1449 lines[j] = temp_line 

1450 parts[i] = "\n".join(lines) 

1451 body_content = "$".join(parts) 

1452 

1453 # Convert headings 

1454 for pattern, replacement in self.heading_patterns: 

1455 body_content = pattern.sub(replacement, body_content) 

1456 

1457 # Convert emphasis 

1458 for pattern, replacement in self.emphasis_patterns: 

1459 body_content = pattern.sub(replacement, body_content) 

1460 

1461 # Convert citations to LaTeX \cite{} format 

1462 body_content = self.citation_pattern.sub(r"\\cite{\1}", body_content) 

1463 

1464 # Convert lists 

1465 body_content = self._convert_lists(body_content) 

1466 

1467 # Add body content 

1468 latex_content += body_content 

1469 

1470 # Add bibliography section 

1471 latex_content += self._create_bibliography(content) 

1472 

1473 # Add footer 

1474 latex_content += self._create_latex_footer() 

1475 

1476 return latex_content 

1477 

1478 def _create_latex_header(self) -> str: 

1479 """Create LaTeX document header.""" 

1480 return r"""\documentclass[12pt]{article} 

1481\usepackage[utf8]{inputenc} 

1482\usepackage{hyperref} 

1483\usepackage{cite} 

1484\usepackage{url} 

1485 

1486\title{Research Report} 

1487\date{\today} 

1488 

1489\begin{document} 

1490\maketitle 

1491 

1492""" 

1493 

1494 def _create_latex_footer(self) -> str: 

1495 """Create LaTeX document footer.""" 

1496 return "\n\\end{document}\n" 

1497 

1498 def _escape_latex(self, text: str) -> str: 

1499 """Escape special LaTeX characters in text.""" 

1500 # Escape special LaTeX characters 

1501 replacements = [ 

1502 ("\\", r"\textbackslash{}"), # Must be first 

1503 ("&", r"\&"), 

1504 ("%", r"\%"), 

1505 ("$", r"\$"), 

1506 ("#", r"\#"), 

1507 ("_", r"\_"), 

1508 ("{", r"\{"), 

1509 ("}", r"\}"), 

1510 ("~", r"\textasciitilde{}"), 

1511 ("^", r"\textasciicircum{}"), 

1512 ] 

1513 

1514 for old, new in replacements: 

1515 text = text.replace(old, new) 

1516 

1517 return text 

1518 

1519 def _convert_lists(self, content: str) -> str: 

1520 """Convert markdown lists to LaTeX format.""" 

1521 # Simple conversion for bullet points 

1522 content = re.sub(r"^- (.+)$", r"\\item \1", content, flags=re.MULTILINE) 

1523 

1524 # Add itemize environment around list items 

1525 lines = content.split("\n") 

1526 result = [] 

1527 in_list = False 

1528 

1529 for line in lines: 

1530 if line.strip().startswith("\\item"): 

1531 if not in_list: 

1532 result.append("\\begin{itemize}") 

1533 in_list = True 

1534 result.append(line) 

1535 else: 

1536 if in_list and line.strip(): 

1537 result.append("\\end{itemize}") 

1538 in_list = False 

1539 result.append(line) 

1540 

1541 if in_list: 

1542 result.append("\\end{itemize}") 

1543 

1544 return "\n".join(result) 

1545 

1546 def _create_bibliography(self, content: str) -> str: 

1547 """Extract sources and create LaTeX bibliography.""" 

1548 sources_start, _on_sentinel = find_sources_section(content) 

1549 if sources_start == -1: 

1550 return "" 

1551 

1552 sources_content = content[sources_start:] 

1553 entries = _collect_bibliography_entries(sources_content) 

1554 

1555 bibliography = "\n\\begin{thebibliography}{99}\n" 

1556 

1557 for citation_num in sorted(entries, key=_sort_key): 

1558 title, url = entries[citation_num] 

1559 escaped_title = self._escape_latex(title) 

1560 

1561 # NOTE: thebibliography numbers entries by POSITION, so a gap 

1562 # in the index sequence makes \\cite{5} print [3]. That is 

1563 # pre-existing and unchanged here — an explicit 

1564 # ``\\bibitem[label]{key}`` would fix it but changes an output 

1565 # format existing tests pin, so it belongs in its own PR. 

1566 # Sorting at least keeps the common contiguous case correct. 

1567 item = f"\\bibitem{{{citation_num}}}" 

1568 safe_url = _safe_bibtex_url(url) 

1569 if safe_url: 

1570 bibliography += f"{item} {escaped_title}. \\url{{{safe_url}}}\n" 

1571 else: 

1572 bibliography += f"{item} {escaped_title}.\n" 

1573 

1574 bibliography += "\\end{thebibliography}\n" 

1575 

1576 return bibliography