Coverage for src/local_deep_research/report_generator.py: 88%

612 statements  

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

1import importlib 

2import re 

3from typing import Any, Dict, List, Optional 

4from datetime import datetime, UTC 

5 

6from langchain_core.language_models import BaseChatModel 

7from loguru import logger 

8 

9# Fix circular import by importing directly from source modules 

10from .config.llm_config import get_llm 

11from .config.thread_settings import get_setting_from_snapshot 

12from .search_system import AdvancedSearchSystem 

13from .text_optimization.citation_formatter import ( 

14 LDR_APPENDED_SOURCES_SENTINEL, 

15) 

16from .utilities.json_utils import get_llm_response_text 

17from .utilities.llm_utils import invoke_llm_sync 

18 

19# Default constants for context accumulation to avoid repetition 

20# These are used as fallbacks when settings are not available 

21DEFAULT_MAX_CONTEXT_SECTIONS = ( 

22 3 # Number of previous sections to include as context 

23) 

24DEFAULT_MAX_CONTEXT_CHARS = ( 

25 4000 # Max characters for context (safe for smaller local models) 

26) 

27 

28# Spelled-out output rules appended to every per-subsection prompt. Small 

29# and/or quantized local models routinely ignore long, vague instructions, 

30# so this is intentionally short, numbered, and refers to the actual 

31# rendering consequences (a duplicate heading, a duplicate bibliography) 

32# rather than abstract guidance. 

33# 

34# The framework always (a) inserts `## i.j Name` itself before appending 

35# LLM content and (b) appends one consolidated `## Sources` block to the 

36# whole report at the end of _format_final_report. The LLM does not need 

37# to repeat either of those things, and historically has — producing 

38# visibly stacked duplicate headings and duplicate "## Sources" sections 

39# per subsection. 

40_SUBSECTION_OUTPUT_GUIDANCE = ( 

41 "\n\nOUTPUT FORMAT RULES (FOLLOW EXACTLY):\n" 

42 "1. Do NOT start your output with a Markdown heading of any level " 

43 "(#, ##, ###, ####, etc.). The framework already inserts the " 

44 "subsection heading for you; starting with your own heading line " 

45 "creates a visible duplicate next to it in the final report.\n" 

46 "2. Do NOT end your output with a sources, references, bibliography, " 

47 "citations, key references, or selected bibliography section or list. " 

48 "Do NOT include any trailing heading or label (such as 'Sources:', " 

49 "'**Sources:**', or 'References:'), and do NOT append a list or block " 

50 "of citations/links at the end of your response. Citations must ONLY " 

51 "appear inline within sentences (e.g. 'According to [1], ...'). " 

52 "The framework automatically compiles and appends the consolidated " 

53 "source list after all subsections are completed; including your own " 

54 "trailing sources or bibliography duplicates it.\n" 

55 "3. Begin your output with prose (a paragraph or a table), not a " 

56 "heading and not an italic purpose statement. You may use '###' / " 

57 "'####' / deeper levels for internal sub-subheadings inside this " 

58 "subsection only." 

59) 

60 

61# Pre-compiled normalisers used by _strip_subsection_boilerplate. These 

62# are best-effort cleanups that run on the LLM's raw `current_knowledge` 

63# before it is appended to the section — they do NOT touch the 

64# framework's own headings or its trailing '## Sources' block. 

65# 

66# Leading heading: any ATX heading (level 1-6) at the very start of the 

67# subsection content. Limited to a single match so we never strip a real 

68# sub-subheading used to organise the body. Real reports show the LLM 

69# almost always opens with a redundant ``### <subsection name>`` (or a 

70# sibling's name, or a Roman-numeral-prefixed restatement) immediately 

71# under the framework's own ``## i.j Name`` heading. 

72_LEADING_HEADING_RE = re.compile( 

73 r"\A[ \t\n]*[ \t]{0,3}#{1,6}[ \t]+[^\n]*", 

74) 

75 

76# Leading italic purpose statement that mirrors the framework's 

77# ``_<purpose>_`` subtitle (e.g. ``_To summarise the section's scope..._``). 

78# Supports single underscores (_..._) or single asterisks (*...*). 

79# Limited to a single match at the start. 

80_LEADING_ITALIC_PURPOSE_RE = re.compile( 

81 r"\A[ \t\n]*(?:_[^_\n]+_|(?:\*(?!\*)[^*\n]+\*))[ \t]*", 

82) 

83 

84_BIB_KEYWORD_PATTERN = ( 

85 r"(?:" 

86 r"Sources?|References?|Bibliograph(?:y|ies)|Citations?|" 

87 r"Key[ \t]+References?|Selected[ \t]+Bibliograph(?:y|ies)|" 

88 r"Works?[ \t]+Cited|Cited[ \t]+Works?|" 

89 r"Additional[ \t]+Resources?|Further[ \t]+Reading" 

90 r")" 

91) 

92 

93_BIB_HEADINGS_CHAIN = ( 

94 rf"{_BIB_KEYWORD_PATTERN}" 

95 rf"(?:[ \t]*(?:,|and|&|/)[ \t]+{_BIB_KEYWORD_PATTERN})*" 

96) 

97 

98# ATX-style heading (with #) allowing: 

99# - levels 1-6 with optional empty heading line preceding it (e.g. "###\n**Sources:**") 

100# - optional section numbers (e.g. "1." or "1.1") 

101# - optional bold/italic markers ("**Sources**", "*References:*") 

102# - optional trailing colons, dashes, periods, or qualifiers 

103_ATX_BIB_HEADING = ( 

104 rf"[ \t]{{0,3}}#{{1,6}}[ \t]*(?:\n[ \t]*)?" 

105 rf"(?:\d+(?:\.\d+)*\.?[ \t]+)?" 

106 rf"(?:\*\*|\*|__|_)?{_BIB_HEADINGS_CHAIN}(?:\*\*|\*|__|_)?(?:" 

107 rf"[ \t]*[:\-\u2013\u2014.][ \t]*[^\n]*" 

108 rf"|[ \t]+for[ \t]+[^\n]+" 

109 rf"|[ \t]*\([^\n]+\)" 

110 rf")?[ \t]*$" 

111) 

112 

113# Pseudo-heading (without #) where the keyword line acts as a section header: 

114# - bold or italic wrapped: "**Sources:**", "**Sources**", "*References:*" 

115# - or plain keyword ending with a colon or dash: "Sources:", "References:" 

116_PSEUDO_BIB_HEADING = ( 

117 r"[ \t]{0,3}" 

118 rf"(?:" 

119 rf"(?:\*\*|\*|__|_){_BIB_HEADINGS_CHAIN}[:\-\u2013\u2014.]?(?:\*\*|\*|__|_)" 

120 rf"|" 

121 rf"{_BIB_HEADINGS_CHAIN}[ \t]*[:\-\u2013\u2014]" 

122 rf")[ \t]*$" 

123) 

124 

125# Bibliography-style heading at levels 1-6 or pseudo-heading line. 

126_BIBLIOGRAPHY_HEADING_RE = re.compile( 

127 rf"(?m)^(?:{_ATX_BIB_HEADING}|{_PSEUDO_BIB_HEADING})", 

128 re.IGNORECASE, 

129) 

130 

131# Next ATX heading at levels 1-6 — marks the end of a bibliography block 

132# when more subsection content follows it (common when the LLM appends a 

133# "Selected Bibliography" mid-stream and then continues writing). 

134_NEXT_HEADING_RE = re.compile(r"(?m)^[ \t]{0,3}#{1,6}[ \t]+") 

135 

136# Decorative horizontal rules the LLM often wraps around bibliography 

137# blocks (``---``, ``***``, or ``___`` on their own line). 

138_HR_LINE_RE = re.compile( 

139 r"(?m)^[ \t]{0,3}(?:(?:-[ \t]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})[ \t]*\n?" 

140) 

141 

142# Empty heading line (e.g. `###`) or decorative horizontal rule line (`---`, `***`, `___`) 

143_EMPTY_HEADING_OR_HR_RE = re.compile( 

144 r"^[ \t]*(?:#{1,6}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})[ \t]*$" 

145) 

146 

147# Reference-style link definitions in markdown: [1]: https://... or [name]: url 

148_MD_LINK_DEF_RE = re.compile(r"^[ \t]*\[[^\]]+\]:[ \t]*\S+") 

149 

150# Ordinary ordered or bulleted list item prefix 

151_ORDERED_OR_BULLET_LIST_RE = re.compile(r"^[ \t]*(?:[-*•]|\d+[\.\)])\s+") 

152 

153# Citation-list shape for ATX headings (## Sources, ### References): 

154# digit+dot, bullet, bracket citation start (single or double brackets), 

155# lenticular brackets, URL, DOI, or markdown link. 

156# Excludes markdown reference-style link definitions ("[1]: https://..."). 

157_CITATION_LINE_RE = re.compile( 

158 r"^\s*(?:" 

159 r"\d+[\.\)]\s+" 

160 r"|[-*•]\s+" 

161 r"|\[\[\d+\]\](?:\([^)]*\))?" 

162 r"|\[\d+\](?!\:)" 

163 r"|【\d+】" 

164 r"|\[cite\d*\]" 

165 r"|\bhttps?://" 

166 r"|\bdoi:\s*\S+" 

167 r"|\[[^\]]+\]\((?:https?://|/library/)[^)]+\)" 

168 r")", 

169 re.IGNORECASE, 

170) 

171 

172# Citation-list shape for pseudo-headings (Sources:, **Sources** without #): 

173# Must carry explicit citation markers (brackets, links, URLs, DOIs), NOT plain numbered/bullet lists. 

174_PSEUDO_BIB_CITATION_LINE_RE = re.compile( 

175 r"^\s*(?:(?:[-*•]|\d+[\.\)])\s+)?" 

176 r"(?:" 

177 r"\[\[\d+\]\](?:\([^)]*\))?" 

178 r"|\[\d+\](?!\:)" 

179 r"|【\d+】" 

180 r"|\[cite\d*\]" 

181 r"|\bhttps?://" 

182 r"|\bdoi:\s*\S+" 

183 r"|\[[^\]]+\]\((?:https?://|/library/)[^)]+\)" 

184 r")", 

185 re.IGNORECASE, 

186) 

187 

188# Single citation token for concatenated line check: 

189# Supports single brackets [1], double brackets [[1]], optional markdown link target, 

190# [cite1], or Chinese/Japanese lenticular brackets 【1】 

191_CONCATENATED_CITATION_ITEM = ( 

192 r"(?:(?:\[\[\d+\]\]|\[\d+\]|\[cite\d*\])(?:\([^)]*\))?|【\d+】)" 

193) 

194 

195# Line composed purely of citation markers, brackets, links, and commas. 

196# Structured without overlapping leading/trailing whitespace quantifiers to prevent ReDoS. 

197_CONCATENATED_CITATIONS_LINE_RE = re.compile( 

198 rf"^(?:[,\s]*{_CONCATENATED_CITATION_ITEM})+[,\s]*$" 

199) 

200 

201# Unheaded citation list item start (for trailing citation blocks lacking any heading). 

202# Excludes numbered/bullet lists, markdown link definitions, and bare URLs without citation markers. 

203# Requires double brackets [[...]], single brackets with link target/source, cite tag, or DOI. 

204_UNHEADED_CITATION_ITEM_RE = re.compile( 

205 r"^\s*(?:" 

206 r"\[\[\d+\]\](?:\([^)]*\))?" 

207 r"|\[\d+\](?!\:)(?:\((?:https?://|/library/)[^)]+\)|\s+(?:https?://|doi:))" 

208 r"|\[cite\d*\]" 

209 r"|doi:\s*\S+" 

210 r")", 

211 re.IGNORECASE, 

212) 

213 

214# Note line matching LLM boilerplate (e.g. "*(Note: ... bibliography ...)*" or 

215# "[Note: The framework will append...]") 

216_BIB_NOTE_RE = re.compile( 

217 r"^\s*(?:" 

218 r"[\*_]\s*\(?Note\b[^\n]*?\b(?:bibliography|sources?|references?|citations?)\b" 

219 r"|\[\s*Note\b" 

220 r"(?=[^\n]*\b(?:consolidat\w*|append\w*|omit\w*|duplicat\w*|placeholder\w*)\b)" 

221 r"[^\n]*?\b(?:bibliography|sources?|references?|citations?)\b)", 

222 re.IGNORECASE, 

223) 

224 

225 

226def _get_code_fence_spans(text: str) -> List[tuple[int, int]]: 

227 """Return character index ranges (start, end) that are inside code blocks. 

228 

229 Recognizes: 

230 - Backtick fenced blocks (``` or ```` etc.), paired by delimiter type and length. 

231 - Tilde fenced blocks (~~~ or ~~~~ etc.), paired by delimiter type and length. 

232 - Indented code blocks (lines indented by 4+ spaces or tabs, preceded by a blank line). 

233 """ 

234 spans: List[tuple[int, int]] = [] 

235 if not text: 

236 return spans 

237 

238 lines = text.splitlines(keepends=True) 

239 in_fence = False 

240 fence_char = "" 

241 fence_len = 0 

242 fence_start = 0 

243 

244 in_indented = False 

245 indented_start = 0 

246 last_indented_end = 0 

247 prev_blank = True 

248 

249 line_offset = 0 

250 fence_open_re = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})") 

251 

252 for line in lines: 

253 line_start = line_offset 

254 line_end = line_offset + len(line) 

255 line_offset = line_end 

256 

257 stripped = line.rstrip("\r\n") 

258 

259 if in_fence: 

260 close_re = ( 

261 rf"^[ \t]{{0,3}}{re.escape(fence_char)}{{{fence_len},}}[ \t]*$" 

262 ) 

263 if re.match(close_re, stripped): 

264 in_fence = False 

265 spans.append((fence_start, line_end)) 

266 continue 

267 

268 open_match = fence_open_re.match(stripped) 

269 if open_match: 

270 if in_indented: 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true

271 in_indented = False 

272 spans.append((indented_start, last_indented_end)) 

273 

274 in_fence = True 

275 delim = open_match.group(1) 

276 fence_char = delim[0] 

277 fence_len = len(delim) 

278 fence_start = line_start 

279 prev_blank = False 

280 continue 

281 

282 is_indented = stripped.startswith(" ") or stripped.startswith("\t") 

283 is_blank = not stripped.strip() 

284 

285 if in_indented: 

286 if is_indented: 

287 last_indented_end = line_end 

288 elif is_blank: 

289 pass 

290 else: 

291 in_indented = False 

292 spans.append((indented_start, last_indented_end)) 

293 else: 

294 if is_indented and prev_blank: 

295 in_indented = True 

296 indented_start = line_start 

297 last_indented_end = line_end 

298 

299 prev_blank = is_blank 

300 

301 if in_fence: 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true

302 spans.append((fence_start, len(text))) 

303 elif in_indented: 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true

304 spans.append((indented_start, last_indented_end)) 

305 

306 return spans 

307 

308 

309def _is_in_spans(pos: int, spans: List[tuple[int, int]]) -> bool: 

310 """Return True if pos falls within any range in spans.""" 

311 return any(start <= pos < end for start, end in spans) 

312 

313 

314def get_report_generator(search_system=None): 

315 """Return an instance of the report generator with default settings. 

316 

317 Args: 

318 search_system: Optional existing AdvancedSearchSystem to use 

319 """ 

320 return IntegratedReportGenerator(search_system=search_system) 

321 

322 

323class IntegratedReportGenerator: 

324 def __init__( 

325 self, 

326 searches_per_section: int = 2, 

327 search_system=None, 

328 llm: BaseChatModel | None = None, 

329 settings_snapshot: Optional[Dict] = None, 

330 ): 

331 """ 

332 Args: 

333 searches_per_section: Number of searches to perform for each 

334 section in the report. 

335 search_system: Custom search system to use, otherwise just uses 

336 the default. 

337 llm: Custom LLM to use. Required if search_system is not provided. 

338 settings_snapshot: Optional settings snapshot for configurable values. 

339 

340 """ 

341 # If search_system is provided, use its LLM; otherwise use the provided LLM 

342 self._owns_llm = False 

343 if search_system: 

344 self.search_system = search_system 

345 self.model = llm or search_system.model 

346 elif llm: 

347 self.model = llm 

348 self.search_system = AdvancedSearchSystem(llm=self.model) # type: ignore[call-arg] 

349 else: 

350 # Fallback for backwards compatibility - will only work with auth 

351 self._owns_llm = True 

352 self.model = get_llm() 

353 self.search_system = AdvancedSearchSystem(llm=self.model) # type: ignore[call-arg] 

354 

355 self.searches_per_section = ( 

356 searches_per_section # Control search depth per section 

357 ) 

358 

359 # Load context settings from snapshot or use defaults 

360 self.max_context_sections = get_setting_from_snapshot( 

361 "report.max_context_sections", 

362 default=DEFAULT_MAX_CONTEXT_SECTIONS, 

363 settings_snapshot=settings_snapshot, 

364 ) 

365 self.max_context_chars = get_setting_from_snapshot( 

366 "report.max_context_chars", 

367 default=DEFAULT_MAX_CONTEXT_CHARS, 

368 settings_snapshot=settings_snapshot, 

369 ) 

370 

371 def close(self) -> None: 

372 """Close the LLM client if this instance created it.""" 

373 from .utilities.resource_utils import safe_close 

374 

375 if self._owns_llm: 

376 safe_close(self.model, "report generator LLM") 

377 

378 def generate_report( 

379 self, 

380 initial_findings: Dict, 

381 query: str, 

382 progress_callback=None, 

383 ) -> Dict: 

384 """Generate a complete research report with section-specific research. 

385 

386 Args: 

387 initial_findings: Results from initial research phase. 

388 query: Original user query. 

389 progress_callback: Optional callable(message, progress_percent, metadata) 

390 for reporting progress (0-100%) and checking cancellation. 

391 """ 

392 

393 # Step 1: Determine structure 

394 if progress_callback: 

395 progress_callback( 

396 "Determining report structure", 

397 0, 

398 {"phase": "report_structure"}, 

399 ) 

400 structure = self._determine_report_structure(initial_findings, query) 

401 

402 # Step 2: Research and generate content for each section in one step 

403 sections = self._research_and_generate_sections( 

404 initial_findings, 

405 structure, 

406 query, 

407 progress_callback=progress_callback, 

408 ) 

409 

410 # Step 3: Format final report 

411 if progress_callback: 

412 progress_callback( 

413 "Formatting final report", 

414 90, 

415 {"phase": "report_formatting"}, 

416 ) 

417 report = self._format_final_report(sections, structure, query) 

418 

419 if progress_callback: 

420 progress_callback( 

421 "Report complete", 100, {"phase": "report_complete"} 

422 ) 

423 

424 return report 

425 

426 def _determine_report_structure( 

427 self, findings: Dict, query: str 

428 ) -> List[Dict]: 

429 """Analyze content and determine the optimal detailed report structure.""" 

430 combined_content = findings["current_knowledge"] 

431 prompt = f""" 

432 Analyze this research content about: {query} 

433 

434 Content Summary: 

435 {combined_content} 

436 

437 Determine the most appropriate detailed report structure by: 

438 1. Analyzing the type of content (technical, business, academic, etc.) 

439 2. Identifying main themes and logical groupings 

440 3. Considering the depth and breadth of the research 

441 

442 Return a table of contents structure in this exact format: 

443 STRUCTURE 

444 1. [Section Name] 

445 - [Subsection] | [purpose] 

446 2. [Section Name] 

447 - [Subsection] | [purpose] 

448 ... 

449 END_STRUCTURE 

450 

451 Make the structure specific to the content, not generic. 

452 Each subsection must include its purpose after the | symbol. 

453 DO NOT include sections about sources, citations, references, or methodology. 

454 """ 

455 

456 response = get_llm_response_text(invoke_llm_sync(self.model, prompt)) 

457 

458 # Parse the structure 

459 structure: List[Dict[str, Any]] = [] 

460 current_section: Optional[Dict[str, Any]] = None 

461 

462 for line in response.split("\n"): 

463 if line.strip() in ["STRUCTURE", "END_STRUCTURE"]: 

464 continue 

465 

466 if line.strip().startswith(tuple("123456789")): 

467 # Main section — require a dot-delimited name (e.g. "1. Intro"). 

468 parts = line.split(".", 1) 

469 if len(parts) < 2 or not parts[1].strip(): 

470 continue 

471 section_name = parts[1].strip() 

472 current_section = {"name": section_name, "subsections": []} 

473 structure.append(current_section) 

474 elif line.strip().startswith("-") and current_section: 

475 # Subsection with or without purpose 

476 parts = line.strip("- ").split( 

477 "|", 1 

478 ) # Only split on first pipe 

479 if len(parts) == 2: 

480 current_section["subsections"].append( 

481 {"name": parts[0].strip(), "purpose": parts[1].strip()} 

482 ) 

483 elif len(parts) == 1 and parts[0].strip(): 483 ↛ 462line 483 didn't jump to line 462 because the condition on line 483 was always true

484 # Subsection without purpose - add default 

485 current_section["subsections"].append( 

486 { 

487 "name": parts[0].strip(), 

488 "purpose": f"Provide detailed information about {parts[0].strip()}", 

489 } 

490 ) 

491 

492 # Check if the last section is source-related and remove it 

493 if structure: 

494 last_section = structure[-1] 

495 section_name_lower = last_section["name"].lower() 

496 source_keywords = [ 

497 "source", 

498 "citation", 

499 "reference", 

500 "bibliography", 

501 ] 

502 

503 # Only check the last section for source-related content 

504 if any( 

505 keyword in section_name_lower for keyword in source_keywords 

506 ): 

507 logger.info( 

508 f"Removed source-related last section: {last_section['name']}" 

509 ) 

510 structure = structure[:-1] 

511 

512 return structure 

513 

514 def _truncate_at_sentence_boundary(self, text: str, max_chars: int) -> str: 

515 """Truncate text at a sentence boundary to preserve readability. 

516 

517 Attempts to cut at the last sentence-ending punctuation (.!?) before 

518 the limit. If no suitable boundary is found within 80% of the limit, 

519 falls back to hard truncation. 

520 

521 Args: 

522 text: Text to truncate. 

523 max_chars: Maximum characters allowed. 

524 

525 Returns: 

526 Truncated text with ``[...truncated]`` marker if truncation 

527 occurred, otherwise the original text unchanged. 

528 """ 

529 if len(text) <= max_chars: 

530 return text 

531 

532 truncated = text[:max_chars] 

533 

534 # Look for sentence boundaries (. ! ?) followed by space or newline 

535 # Search backwards from the end for the last complete sentence 

536 last_sentence_end = -1 

537 for i in range(len(truncated) - 1, -1, -1): 

538 if truncated[i] in ".!?" and ( 

539 i + 1 >= len(truncated) or truncated[i + 1] in " \n" 

540 ): 

541 last_sentence_end = i + 1 

542 break 

543 

544 # Only use sentence boundary if it preserves at least 80% of content 

545 min_acceptable = int(max_chars * 0.8) 

546 if last_sentence_end > min_acceptable: 

547 return truncated[:last_sentence_end] + "\n[...truncated]" 

548 

549 # Fall back to hard truncation 

550 return truncated + "\n[...truncated]" 

551 

552 @staticmethod 

553 def _normalize_heading_text(text: str) -> str: 

554 """Normalize heading text for fuzzy comparison against subsection names. 

555 

556 Strips markdown bold markers, leading Roman-numeral / alphabetic / 

557 numeric enumeration prefixes (``II.``, ``A.``, ``1.``), collapses 

558 whitespace, and lowercases. Used only for matching — never written 

559 back into the report. 

560 """ 

561 cleaned = text.strip() 

562 # Drop surrounding bold/italic markers the LLM wraps titles in 

563 # (``**Title**``, ``*Title*``, ``__Title__``). Avoid regex here — 

564 # CodeQL flags ``^[*_]+|[*_]+$`` as polynomial on long ``*`` runs. 

565 cleaned = cleaned.strip("*_").strip() 

566 cleaned = re.sub( 

567 r"^(?:" 

568 r"[IVXLCDM]+\.|" # Roman numerals: II. III. IV. (uppercase only to avoid "Mix." false strip) 

569 r"[A-Z]\.|" # Single letters: A. B. 

570 r"\d+\." # Arabic numerals: 1. 2. 

571 r")\s+", 

572 "", 

573 cleaned, 

574 ) 

575 # Normalise curly/smart quotes and dashes so "Author's" (U+2019) 

576 # matches "Author's" (ASCII apostrophe) and en/em dashes collapse 

577 # to a plain hyphen for comparison. 

578 cleaned = cleaned.translate( 

579 str.maketrans( 

580 "\u2018\u2019\u201c\u201d\u2013\u2014\u2212", "''\"\"---" 

581 ) 

582 ) 

583 return re.sub(r"\s+", " ", cleaned).strip().lower() 

584 

585 @classmethod 

586 def _heading_restates_name(cls, heading_text: str, name: str) -> bool: 

587 """Return True if *heading_text* is a restatement of *name*. 

588 

589 Handles exact matches, prefix matches (heading extends the name 

590 with a colon/dash subtitle), and the reverse (name is a longer 

591 form of a short heading). Empty names never match. 

592 """ 

593 if not name or not heading_text: 

594 return False 

595 norm_heading = cls._normalize_heading_text(heading_text) 

596 norm_name = cls._normalize_heading_text(name) 

597 if not norm_heading or not norm_name: 597 ↛ 598line 597 didn't jump to line 598 because the condition on line 597 was never true

598 return False 

599 if norm_heading == norm_name: 

600 return True 

601 # Heading extends the subsection name with a subtitle (e.g. "Name: subtitle") 

602 # Delimiters only: bare space prefixes (e.g. "Introduction to …") 

603 # are intentional non-matches. En/em dashes are already folded to 

604 # ASCII "-" by _normalize_heading_text. 

605 if norm_heading.startswith(norm_name): 

606 rest = norm_heading[len(norm_name) :].lstrip() 

607 if rest and rest[0] in (":", "-", "("): 

608 return True 

609 # Name extends a short heading with a subtitle. 

610 if norm_name.startswith(norm_heading): 610 ↛ 611line 610 didn't jump to line 611 because the condition on line 610 was never true

611 rest = norm_name[len(norm_heading) :].lstrip() 

612 if rest and rest[0] in (":", "-", "("): 

613 return True 

614 return False 

615 

616 def _strip_leading_heading( 

617 self, 

618 content: str, 

619 subsection_name: str, 

620 sibling_subsection_names: Optional[List[str]] = None, 

621 ) -> str: 

622 """Drop a single leading heading line only when it restates subsection or sibling name.""" 

623 leading = _LEADING_HEADING_RE.match(content) 

624 if not leading: 

625 return content 

626 

627 fence_spans = _get_code_fence_spans(content) 

628 if _is_in_spans(leading.start(), fence_spans): 628 ↛ 629line 628 didn't jump to line 629 because the condition on line 628 was never true

629 return content 

630 

631 heading_text = re.sub(r"^\s*#{1,6}[ \t]+", "", leading.group(0)).strip() 

632 # Subsection name: allow subtitle extensions (Name: subtitle) 

633 if self._heading_restates_name(heading_text, subsection_name): 

634 end_pos = leading.end() 

635 while end_pos < len(content) and content[end_pos] in "\r\n": 

636 end_pos += 1 

637 return content[end_pos:] 

638 # Siblings: require exact normalized equality (avoid over-strip of 

639 # "### <Sibling>: Subtitle" organizers) 

640 norm_heading = self._normalize_heading_text(heading_text) 

641 for sibling in sibling_subsection_names or []: 

642 if sibling and norm_heading == self._normalize_heading_text( 642 ↛ 641line 642 didn't jump to line 641 because the condition on line 642 was always true

643 sibling 

644 ): 

645 end_pos = leading.end() 

646 while end_pos < len(content) and content[end_pos] in "\r\n": 

647 end_pos += 1 

648 return content[end_pos:] 

649 return content 

650 

651 def _strip_leading_italic_purpose( 

652 self, 

653 content: str, 

654 purpose: Optional[str] = None, 

655 ) -> str: 

656 """Drop a single leading italic purpose statement if it mirrors purpose or boilerplate.""" 

657 leading_italic = _LEADING_ITALIC_PURPOSE_RE.match(content) 

658 if not leading_italic: 

659 return content 

660 

661 fence_spans = _get_code_fence_spans(content) 

662 if _is_in_spans(leading_italic.start(), fence_spans): 662 ↛ 663line 662 didn't jump to line 663 because the condition on line 662 was never true

663 return content 

664 

665 italic_raw = leading_italic.group(0).strip() 

666 italic_text = italic_raw.strip("_*").strip() 

667 should_strip = False 

668 

669 if purpose: 

670 norm_italic = self._normalize_heading_text(italic_text) 

671 norm_purpose = self._normalize_heading_text(purpose) 

672 # Guard against degenerate purpose that normalizes to "" (e.g. "***") 

673 # — startswith("") is always True and would strip any italic line. 

674 if norm_italic and norm_purpose: 674 ↛ 683line 674 didn't jump to line 683 because the condition on line 674 was always true

675 if ( 

676 norm_italic == norm_purpose 

677 or norm_italic.startswith(norm_purpose) 

678 or norm_purpose.startswith(norm_italic) 

679 or self._heading_restates_name(italic_text, purpose) 

680 ): 

681 should_strip = True 

682 

683 if not should_strip: 

684 norm_italic = self._normalize_heading_text(italic_text) 

685 if norm_italic: 685 ↛ 777line 685 didn't jump to line 777 because the condition on line 685 was always true

686 # Narrow unconditional boilerplate markers — always safe to strip 

687 if norm_italic.startswith( 

688 ( 

689 "purpose:", 

690 "scope:", 

691 "this subsection ", 

692 "this section ", 

693 ) 

694 ): 

695 should_strip = True 

696 # Verb-prefix boilerplate: check for relevance to purpose to avoid 

697 # eating legitimate epigraphs like "_To review the evidence is to understand the past._" 

698 # when purpose is unrelated (S2). 

699 elif purpose is not None and norm_italic.startswith( 

700 ( 

701 "to summarize", 

702 "to summarise", 

703 "to clarify", 

704 "to examine", 

705 "to outline", 

706 "to provide", 

707 "to describe", 

708 "to detail", 

709 "to analyze", 

710 "to analyse", 

711 "to explore", 

712 "to present", 

713 "to discuss", 

714 "to evaluate", 

715 "to investigate", 

716 "to review", 

717 "to assess", 

718 "aims to", 

719 "designed to", 

720 ) 

721 ): 

722 norm_purpose = self._normalize_heading_text(purpose) 

723 if norm_purpose: 723 ↛ 777line 723 didn't jump to line 777 because the condition on line 723 was always true

724 _stop_words = { 

725 "to", 

726 "the", 

727 "and", 

728 "for", 

729 "that", 

730 "this", 

731 "with", 

732 "from", 

733 "summarize", 

734 "summarise", 

735 "clarify", 

736 "examine", 

737 "outline", 

738 "provide", 

739 "describe", 

740 "detail", 

741 "analyze", 

742 "analyse", 

743 "explore", 

744 "present", 

745 "discuss", 

746 "evaluate", 

747 "investigate", 

748 "review", 

749 "assess", 

750 "aims", 

751 "designed", 

752 "section", 

753 "subsection", 

754 } 

755 purpose_words = { 

756 w 

757 for w in re.findall( 

758 r"\b[a-z0-9]{3,}\b", norm_purpose.lower() 

759 ) 

760 if w not in _stop_words 

761 } 

762 italic_words = { 

763 w 

764 for w in re.findall( 

765 r"\b[a-z0-9]{3,}\b", norm_italic.lower() 

766 ) 

767 if w not in _stop_words 

768 } 

769 if ( 769 ↛ 775line 769 didn't jump to line 775 because the condition on line 769 was never true

770 self._heading_restates_name(italic_text, purpose) 

771 or norm_italic.startswith(norm_purpose) 

772 or norm_purpose.startswith(norm_italic) 

773 or bool(purpose_words & italic_words) 

774 ): 

775 should_strip = True 

776 

777 if should_strip: 

778 end_pos = leading_italic.end() 

779 while end_pos < len(content) and content[end_pos] in "\r\n": 

780 end_pos += 1 

781 return content[end_pos:] 

782 return content 

783 

784 def _strip_embedded_bibliographies(self, content: str) -> str: 

785 """Remove every embedded bibliography block outside code blocks. 

786 

787 Only removes a heading that looks like a bibliography *and* whose 

788 following block looks like a citation list — this prevents 

789 substantive headings such as "### Sources of Bias..." or 

790 "### Sources. Data Collection Methodology" from being deleted with 

791 their analysis body (R1). Consecutive bibliography headings no longer 

792 consume intervening prose (R8). 

793 """ 

794 fence_spans = _get_code_fence_spans(content) 

795 pieces: List[str] = [] 

796 cursor = 0 

797 bib_removed = False 

798 

799 for match in _BIBLIOGRAPHY_HEADING_RE.finditer(content): 

800 if _is_in_spans(match.start(), fence_spans): 

801 continue 

802 

803 matched_heading_text = match.group(0).lstrip() 

804 is_atx_heading = matched_heading_text.startswith("#") 

805 bib_line_re = ( 

806 _CITATION_LINE_RE 

807 if is_atx_heading 

808 else _PSEUDO_BIB_CITATION_LINE_RE 

809 ) 

810 

811 after_heading = match.end() 

812 next_heading_start = None 

813 next_heading_is_bib = False 

814 for nh in _NEXT_HEADING_RE.finditer(content, after_heading): 

815 if not _is_in_spans(nh.start(), fence_spans): 815 ↛ 814line 815 didn't jump to line 814 because the condition on line 815 was always true

816 next_heading_start = nh.start() 

817 # Peek if the next heading itself is a bibliography heading 

818 # — if so, we will trim the current block to its citation 

819 # list end rather than to the heading (R8). 

820 heading_line = content[nh.start() :].split("\n", 1)[0] 

821 if _BIBLIOGRAPHY_HEADING_RE.match(heading_line): 821 ↛ 822line 821 didn't jump to line 822 because the condition on line 821 was never true

822 next_heading_is_bib = True 

823 break 

824 

825 block_end = ( 

826 next_heading_start 

827 if next_heading_start is not None 

828 else len(content) 

829 ) 

830 block_text = content[after_heading:block_end] 

831 

832 # If the block immediately begins with a code fence, the heading is 

833 # introducing fenced example/code content, not a bibliography. 

834 offset = 0 

835 first_non_empty_start = None 

836 for ln in block_text.splitlines(keepends=True): 836 ↛ 842line 836 didn't jump to line 842 because the loop on line 836 didn't complete

837 if ln.strip(): 

838 first_non_empty_start = after_heading + offset 

839 break 

840 offset += len(ln) 

841 

842 if first_non_empty_start is not None and _is_in_spans( 

843 first_non_empty_start, fence_spans 

844 ): 

845 continue 

846 

847 # Body-shape safeguard: only treat as bibliography if the block 

848 # contains citation-like lines. Require at least one citation 

849 # marker, or >30% of non-empty lines look like citations, to 

850 # avoid deleting substantive analysis headings that happen to 

851 # match the label grammar (e.g. "### Sources for the Analysis" 

852 # with prose body). 

853 non_empty_lines = [] 

854 line_offset = 0 

855 for ln in block_text.splitlines(keepends=True): 

856 line_start = after_heading + line_offset 

857 if ln.strip() and not _is_in_spans(line_start, fence_spans): 

858 non_empty_lines.append(ln) 

859 line_offset += len(ln) 

860 if not non_empty_lines: 860 ↛ 862line 860 didn't jump to line 862 because the condition on line 860 was never true

861 # Empty block — treat as bibliography (heading with no body) 

862 is_bib_block = True 

863 else: 

864 # Intervening prose safeguard: a bibliography heading must introduce 

865 # citations directly (or via an immediate note / 1-line lead-in). 

866 # If substantive prose precedes citations, the heading is introducing 

867 # substantive analysis, not an embedded bibliography. 

868 first_line = non_empty_lines[0] 

869 first_is_bib = ( 

870 bib_line_re.match(first_line) 

871 or _BIB_NOTE_RE.match(first_line) 

872 or _HR_LINE_RE.match(first_line) 

873 ) 

874 if not first_is_bib: 

875 # Check if it's a short 1-line lead-in immediately followed by citations 

876 second_is_citation = len( 

877 non_empty_lines 

878 ) > 1 and bib_line_re.match(non_empty_lines[1]) 

879 is_lead_in = ( 

880 len(first_line.strip()) < 120 

881 and second_is_citation 

882 and bool( 

883 re.search( 

884 r"\b(?:sources?|references?|consulted|cited|following|basis)\b", 

885 first_line, 

886 re.IGNORECASE, 

887 ) 

888 ) 

889 ) 

890 has_early_note = any( 

891 _BIB_NOTE_RE.match(ln) for ln in non_empty_lines[:3] 

892 ) 

893 if not (is_lead_in or has_early_note): 

894 continue 

895 

896 citation_lines = sum( 

897 1 for ln in non_empty_lines if bib_line_re.match(ln) 

898 ) 

899 # Require explicit italic note boilerplate (e.g. "*(Note: ... bibliography ...)*") 

900 # rather than matching arbitrary prose containing "bibliography" (S1). 

901 has_bib_note = any( 

902 _BIB_NOTE_RE.match(ln) for ln in non_empty_lines[:3] 

903 ) 

904 if citation_lines == 0 and not has_bib_note: 904 ↛ 906line 904 didn't jump to line 906 because the condition on line 904 was never true

905 # No citation shape and no italic bib note — preserve heading + body 

906 continue 

907 # If citation lines present, require they are not a tiny minority 

908 # unless block is short (e.g. "- item" single line) 

909 if citation_lines == 0: 

910 is_bib_block = has_bib_note 

911 elif len(non_empty_lines) <= 3: 

912 is_bib_block = citation_lines >= 1 

913 else: 

914 is_bib_block = ( 

915 citation_lines / len(non_empty_lines) 

916 ) >= 0.3 or citation_lines >= 2 

917 

918 if not is_bib_block: 918 ↛ 919line 918 didn't jump to line 919 because the condition on line 918 was never true

919 continue 

920 

921 # Passed safeguard — this is a real bibliography block to remove 

922 bib_removed = True 

923 block_start = match.start() 

924 prefix = content[cursor:block_start] 

925 prefix_lines = prefix.splitlines(keepends=True) 

926 cut_idx = len(prefix) 

927 curr_offset = len(prefix) 

928 found_delimiter = False 

929 for pline in reversed(prefix_lines): 

930 line_start = curr_offset - len(pline) 

931 abs_pos = cursor + line_start 

932 if _is_in_spans(abs_pos, fence_spans): 

933 break 

934 pstripped = pline.strip() 

935 if not pstripped: 

936 curr_offset = line_start 

937 continue 

938 if _EMPTY_HEADING_OR_HR_RE.match(pstripped): 

939 found_delimiter = True 

940 cut_idx = line_start 

941 curr_offset = line_start 

942 else: 

943 break 

944 if found_delimiter and cut_idx < len(prefix): 

945 block_start = cursor + cut_idx 

946 

947 pieces.append(content[cursor:block_start]) 

948 

949 # R8: consecutive bib headings — end at citation list, not at next bib heading 

950 if next_heading_is_bib: 950 ↛ 952line 950 didn't jump to line 952 because the condition on line 950 was never true

951 # Find end of citation list within block_text 

952 lines = block_text.splitlines(keepends=True) 

953 offset = 0 

954 last_citation_end = 0 

955 for ln in lines: 

956 line_start = after_heading + offset 

957 if not _is_in_spans(line_start, fence_spans) and ( 

958 bib_line_re.match(ln) 

959 or (_BIB_NOTE_RE.match(ln) and offset < 300) 

960 ): 

961 last_citation_end = offset + len(ln) 

962 # Also keep consecutive citation lines; stop at first 

963 # non-citation prose that is not a blank/bib note? 

964 offset += len(ln) 

965 # Also include trailing blank lines after citations, but not 

966 # intervening prose before next bib heading 

967 if last_citation_end > 0: 

968 # Advance cursor to after the citation list (preserve 

969 # intervening prose between two bib headings) 

970 cursor = after_heading + last_citation_end 

971 # Skip following blank lines / HRs but not prose 

972 while cursor < block_end and content[cursor] in " \t\r\n-": 

973 # Only skip whitespace/HRs, stop at prose 

974 # Peek next non-whitespace chunk 

975 nxt = content[cursor:].lstrip(" \t\r\n") 

976 if nxt.startswith("-") and nxt[1:2] in " \t": 

977 # Another HR-like line, skip it 

978 cursor += len(content[cursor:]) - len(nxt) 

979 # consume the HR line 

980 eol = content.find("\n", cursor) 

981 cursor = eol + 1 if eol != -1 else len(content) 

982 else: 

983 break 

984 if cursor >= block_end: 

985 break 

986 # Ensure we don't overshoot into next bib heading's prefix; 

987 # if cursor is before next_heading_start, keep it, else 

988 # fall back to next_heading_start 

989 if cursor > block_end: 

990 cursor = block_end 

991 else: 

992 cursor = block_end 

993 else: 

994 # End the removal at the citation list rather than at the end 

995 # of the block, so a table, list or paragraph the model wrote 

996 # after its bibliography survives the strip. 

997 offset = 0 

998 citation_end = 0 

999 non_empty_seen = 0 

1000 trailing_content = False 

1001 for ln in block_text.splitlines(keepends=True): 

1002 line_start = after_heading + offset 

1003 substantive = bool(ln.strip()) 

1004 outside_fence = not _is_in_spans(line_start, fence_spans) 

1005 non_empty_seen += substantive and outside_fence 

1006 # The note window is the classifier's first-three-non-empty- 

1007 # lines rule, so a block accepted as a bibliography always 

1008 # has a qualifying line here and cannot fall through. 

1009 is_bib_line = bool( 

1010 outside_fence 

1011 and ( 

1012 bib_line_re.match(ln) 

1013 or (_BIB_NOTE_RE.match(ln) and non_empty_seen <= 3) 

1014 or _HR_LINE_RE.match(ln) 

1015 ) 

1016 ) 

1017 if citation_end and substantive and not is_bib_line: 

1018 trailing_content = True 

1019 break 

1020 offset += len(ln) 

1021 if substantive and is_bib_line: 

1022 citation_end = offset 

1023 if not trailing_content: 

1024 # Nothing substantive follows, so the whole block is the 

1025 # bibliography and is removed as before. 

1026 citation_end = len(block_text) 

1027 

1028 cursor = after_heading + citation_end 

1029 

1030 if bib_removed: 

1031 pieces.append(content[cursor:]) 

1032 content = "".join(pieces) 

1033 return self._strip_trailing_citation_blocks(content) 

1034 

1035 def _strip_trailing_citation_blocks(self, content: str) -> str: 

1036 """Remove unheaded trailing citation blocks or concatenated citations. 

1037 

1038 When the LLM is instructed not to include a sources section heading, 

1039 some models omit the heading entirely but append a list or block of 

1040 citations at the end of the response (e.g. consecutive lines of 

1041 `[[111]](/library/...)` or a concatenated line of citations). 

1042 

1043 This defensive helper scans backwards from the end of the content 

1044 outside code fences and strips trailing citation blocks while 

1045 preserving all substantive prose, tables, code fences, reference-style 

1046 link definitions, further-reading URLs, quiz answer keys, and CJK notes. 

1047 """ 

1048 if not content: 

1049 return content 

1050 

1051 fence_spans = _get_code_fence_spans(content) 

1052 lines = content.splitlines(keepends=True) 

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

1054 return content 

1055 

1056 line_offsets: List[int] = [] 

1057 curr = 0 

1058 for ln in lines: 

1059 line_offsets.append(curr) 

1060 curr += len(ln) 

1061 

1062 # Skip trailing empty / whitespace lines 

1063 cut_idx = len(lines) 

1064 while cut_idx > 0 and not lines[cut_idx - 1].strip(): 

1065 cut_idx -= 1 

1066 

1067 if cut_idx == 0: 1067 ↛ 1068line 1067 didn't jump to line 1068 because the condition on line 1067 was never true

1068 return content 

1069 

1070 # Never strip inside code fences 

1071 if _is_in_spans(line_offsets[cut_idx - 1], fence_spans): 

1072 return content 

1073 

1074 citation_lines_count = 0 

1075 concatenated_lines_count = 0 

1076 bib_heading_preceded = False 

1077 idx = cut_idx 

1078 

1079 while idx > 0: 1079 ↛ 1148line 1079 didn't jump to line 1148 because the condition on line 1079 was always true

1080 offset = line_offsets[idx - 1] 

1081 if _is_in_spans(offset, fence_spans): 1081 ↛ 1082line 1081 didn't jump to line 1082 because the condition on line 1081 was never true

1082 break 

1083 

1084 line_str = lines[idx - 1].strip() 

1085 if not line_str: 

1086 idx -= 1 

1087 continue 

1088 

1089 # Reference-style link definitions ([1]: https://...) must NEVER be stripped 

1090 if _MD_LINK_DEF_RE.match(line_str): 

1091 break 

1092 

1093 # Plain numbered or bulleted list items must NEVER be stripped as unheaded citations 

1094 if _ORDERED_OR_BULLET_LIST_RE.match(line_str): 

1095 break 

1096 

1097 # Concatenated citation clusters (e.g. [[217]][[218]] or [[1]](/a), [2], [[3]]) 

1098 if _CONCATENATED_CITATIONS_LINE_RE.match(line_str): 

1099 # Must contain double brackets [[...]], cite tags, or adjacent bracket tokens; 

1100 # exclude plain numbered markdown links like [1](url) [2](url) 

1101 if ( 

1102 "[[" in line_str 

1103 or "[cite" in line_str.lower() 

1104 or bool(re.search(r"\]\s*\[|】\s*[【\[]|\]\s*【", line_str)) 

1105 ): 

1106 concatenated_lines_count += 1 

1107 idx -= 1 

1108 continue 

1109 break 

1110 

1111 if _UNHEADED_CITATION_ITEM_RE.match(line_str): 

1112 citation_lines_count += 1 

1113 idx -= 1 

1114 continue 

1115 

1116 # Support indented continuation lines within a citation entry. 

1117 # When scanning backwards, find the preceding non-indented line. 

1118 if lines[idx - 1].startswith((" ", "\t")): 

1119 p = idx - 2 

1120 while p >= 0 and lines[p].startswith((" ", "\t")): 1120 ↛ 1121line 1120 didn't jump to line 1121 because the condition on line 1120 was never true

1121 p -= 1 

1122 if p >= 0 and _UNHEADED_CITATION_ITEM_RE.match( 1122 ↛ 1128line 1122 didn't jump to line 1128 because the condition on line 1122 was always true

1123 lines[p].strip() 

1124 ): 

1125 idx -= 1 

1126 continue 

1127 

1128 if _BIB_NOTE_RE.match(line_str): 1128 ↛ 1129line 1128 didn't jump to line 1129 because the condition on line 1128 was never true

1129 idx -= 1 

1130 continue 

1131 

1132 # Check if preceded by an explicit bibliography heading or label 

1133 if citation_lines_count > 0 or concatenated_lines_count > 0: 

1134 if _BIBLIOGRAPHY_HEADING_RE.match(line_str): 1134 ↛ 1135line 1134 didn't jump to line 1135 because the condition on line 1134 was never true

1135 bib_heading_preceded = True 

1136 idx -= 1 

1137 continue 

1138 if _EMPTY_HEADING_OR_HR_RE.match(line_str): 

1139 idx -= 1 

1140 continue 

1141 

1142 break 

1143 

1144 # Only strip if: 

1145 # 1. Found a concatenated citation cluster 

1146 # 2. Preceded by an explicit bibliography heading/label and found >= 1 citation items 

1147 # 3. Found >= 2 citation items in an unheaded block (bare --- does NOT arm single items) 

1148 should_strip = False 

1149 if concatenated_lines_count > 0: 

1150 should_strip = True 

1151 elif bib_heading_preceded and citation_lines_count >= 1: 1151 ↛ 1152line 1151 didn't jump to line 1152 because the condition on line 1151 was never true

1152 should_strip = True 

1153 elif citation_lines_count >= 2: 

1154 should_strip = True 

1155 

1156 if should_strip: 

1157 # Also consume any preceding empty headings, labels, or HRs 

1158 while idx > 0: 1158 ↛ 1172line 1158 didn't jump to line 1172 because the condition on line 1158 was always true

1159 offset = line_offsets[idx - 1] 

1160 if _is_in_spans(offset, fence_spans): 1160 ↛ 1161line 1160 didn't jump to line 1161 because the condition on line 1160 was never true

1161 break 

1162 pstripped = lines[idx - 1].strip() 

1163 if not pstripped: 1163 ↛ 1164line 1163 didn't jump to line 1164 because the condition on line 1163 was never true

1164 idx -= 1 

1165 continue 

1166 if _EMPTY_HEADING_OR_HR_RE.match( 1166 ↛ 1169line 1166 didn't jump to line 1169 because the condition on line 1166 was never true

1167 pstripped 

1168 ) or _BIBLIOGRAPHY_HEADING_RE.match(pstripped): 

1169 idx -= 1 

1170 else: 

1171 break 

1172 return "".join(lines[:idx]) 

1173 

1174 return content 

1175 

1176 def _strip_subsection_boilerplate( 

1177 self, 

1178 content: str, 

1179 subsection_name: str, 

1180 section_name: str, 

1181 sibling_subsection_names: Optional[List[str]] = None, 

1182 purpose: Optional[str] = None, 

1183 ) -> str: 

1184 """Strip boilerplate the LLM tends to emit around subsection content. 

1185 

1186 Small and/or quantized local models routinely produce three artefacts 

1187 that pollute the rendered report even when the OUTPUT FORMAT RULES 

1188 spelled out in the subsection prompt explicitly forbid them: 

1189 

1190 1. A redundant leading heading (stripped by :meth:`_strip_leading_heading`) 

1191 2. An italic purpose statement (stripped by :meth:`_strip_leading_italic_purpose`) 

1192 3. An embedded bibliography block (stripped by :meth:`_strip_embedded_bibliographies`) 

1193 

1194 This helper normalises the per-subsection content so the rendered 

1195 report stays clean even when the model ignored the prompt. It is a 

1196 defensive cleanup, not a substitute for the prompt rules. 

1197 

1198 Args: 

1199 content: Raw ``current_knowledge`` returned for one subsection. 

1200 subsection_name: The subsection's name (used for matching and 

1201 logging). 

1202 section_name: The parent section's name (used for logging). 

1203 sibling_subsection_names: Optional names of other subsections 

1204 in the same section. When provided, a leading heading that 

1205 restates a *sibling's* name (context-bleed) is also stripped. 

1206 purpose: Optional purpose string for the subsection. Used to 

1207 verify if a leading italic line mirrors the subsection purpose. 

1208 

1209 Returns: 

1210 ``content`` with a redundant leading heading removed when it 

1211 restates this or a sibling subsection name, a leading 

1212 italic-purpose statement removed, and any embedded bibliography 

1213 blocks removed. Returns content unchanged if no artefacts are 

1214 found, aside from collapsing 3+ blank lines to 2 and 

1215 normalising trailing whitespace (collapsed outside code fences 

1216 only, so fenced blocks are never rewritten). 

1217 """ 

1218 if not content: 

1219 return content 

1220 

1221 original_len = len(content) 

1222 new_content = self._strip_leading_heading( 

1223 content, subsection_name, sibling_subsection_names 

1224 ) 

1225 new_content = self._strip_leading_italic_purpose(new_content, purpose) 

1226 pre_bib_len = len(new_content) 

1227 new_content = self._strip_embedded_bibliographies(new_content) 

1228 pre_trailing_len = len(new_content) 

1229 new_content = self._strip_trailing_citation_blocks(new_content) 

1230 bib_chars_removed = (pre_bib_len - pre_trailing_len) + ( 

1231 pre_trailing_len - len(new_content) 

1232 ) 

1233 

1234 # R2: fence-aware whitespace collapse — never rewrite inside code fences 

1235 fence_spans = _get_code_fence_spans(new_content) 

1236 if not fence_spans: 

1237 collapsed = re.sub(r"\n{3,}", "\n\n", new_content).strip() 

1238 else: 

1239 parts: List[str] = [] 

1240 last = 0 

1241 for s, e in sorted(fence_spans): 

1242 # collapse outside fence 

1243 parts.append(re.sub(r"\n{3,}", "\n\n", new_content[last:s])) 

1244 # keep fence content verbatim 

1245 parts.append(new_content[s:e]) 

1246 last = e 

1247 parts.append(re.sub(r"\n{3,}", "\n\n", new_content[last:])) 

1248 collapsed = "".join(parts).strip() 

1249 new_content = collapsed 

1250 if new_content: 

1251 new_content += "\n" 

1252 

1253 if len(new_content) != original_len: 

1254 # R5: make silent deletions observable 

1255 if not new_content.strip() and content.strip(): 

1256 logger.warning( 

1257 "Stripped subsection boilerplate emptied non-trivial content for " 

1258 f"'{section_name} > {subsection_name}': " 

1259 f"{original_len} -> {len(new_content)} chars — " 

1260 "check for over-strip (R1/R3 regression)" 

1261 ) 

1262 elif len(new_content) < 0.5 * original_len: 

1263 # If the truncation below 50% was driven by stripping an 

1264 # embedded bibliography (where pre-bibliography content was 

1265 # >= 50% and bib removal did the rest), this is expected 

1266 # cleanup rather than an unexpected over-strip. 

1267 if ( 

1268 bib_chars_removed > 0 

1269 and pre_bib_len >= 0.5 * original_len 

1270 and (original_len - pre_bib_len) < 0.25 * original_len 

1271 ): 

1272 logger.info( 

1273 "Stripped embedded bibliography from subsection " 

1274 f"'{section_name} > {subsection_name}': " 

1275 f"{original_len} -> {len(new_content)} chars " 

1276 "(expected cleanup; warning demoted to INFO)" 

1277 ) 

1278 else: 

1279 logger.warning( 

1280 "Stripped subsection boilerplate heavily truncated content for " 

1281 f"'{section_name} > {subsection_name}': " 

1282 f"{original_len} -> {len(new_content)} chars" 

1283 ) 

1284 else: 

1285 logger.debug( 

1286 "Stripped subsection boilerplate for " 

1287 f"'{section_name} > {subsection_name}': " 

1288 f"{original_len} -> {len(new_content)} chars" 

1289 ) 

1290 return new_content 

1291 

1292 def _build_previous_context(self, accumulated_findings: List[str]) -> str: 

1293 """Build context block from previously generated sections. 

1294 

1295 Creates a formatted context block containing content from the last 

1296 N sections (defined by self.max_context_sections) with explicit instructions 

1297 not to repeat this content. Context is truncated if it exceeds 

1298 self.max_context_chars to stay safe for smaller local models. 

1299 

1300 Args: 

1301 accumulated_findings: List of previously generated section content, 

1302 each formatted as "[Section > Subsection]\\n{content}" 

1303 

1304 Returns: 

1305 Formatted context block with delimiters, or empty string if no 

1306 previous findings exist 

1307 """ 

1308 if not accumulated_findings: 

1309 return "" 

1310 

1311 recent_findings = accumulated_findings[-self.max_context_sections :] 

1312 previous_context = "\n\n---\n\n".join(recent_findings) 

1313 

1314 # Truncate at sentence boundary if too long 

1315 if len(previous_context) > self.max_context_chars: 

1316 previous_context = self._truncate_at_sentence_boundary( 

1317 previous_context, self.max_context_chars 

1318 ) 

1319 

1320 return ( 

1321 f"\n\n=== CONTENT ALREADY WRITTEN (DO NOT REPEAT) ===\n" 

1322 f"{previous_context}\n" 

1323 f"=== END OF PREVIOUS CONTENT ===\n\n" 

1324 f"CRITICAL: The above content has already been written. Do NOT repeat " 

1325 f"these points, examples, or explanations. Focus on NEW information " 

1326 f"not covered above.\n" 

1327 ) 

1328 

1329 def _research_and_generate_sections( 

1330 self, 

1331 initial_findings: Dict, 

1332 structure: List[Dict], 

1333 query: str, 

1334 progress_callback=None, 

1335 ) -> Dict[str, str]: 

1336 """Research and generate content for each section in one step. 

1337 

1338 This method processes sections sequentially, accumulating generated 

1339 content as it goes. For each new section/subsection, it passes context 

1340 from the last few previously generated sections to help the LLM avoid 

1341 repetition. 

1342 

1343 The context accumulation mechanism: 

1344 - Tracks all generated content in accumulated_findings list 

1345 - Before generating each section, builds context from recent findings 

1346 - Uses self.max_context_sections (configurable, default: 3) to limit context size 

1347 - Truncates context to self.max_context_chars (configurable, default: 4000) for safety 

1348 - Includes explicit "DO NOT REPEAT" instructions with actual content 

1349 

1350 Args: 

1351 initial_findings: Results from initial research phase, may contain 

1352 questions_by_iteration to preserve search continuity 

1353 structure: List of section definitions, each with name and subsections 

1354 query: Original user query for context 

1355 

1356 Returns: 

1357 Dict mapping section names to their generated markdown content 

1358 """ 

1359 sections = {} 

1360 

1361 # Accumulate content from previous sections to avoid repetition 

1362 accumulated_findings: List[str] = [] 

1363 

1364 # Count total subsections for progress tracking 

1365 total_subsections = sum( 

1366 max(len(section.get("subsections", [])), 1) for section in structure 

1367 ) 

1368 completed_subsections = 0 

1369 

1370 # Preserve questions from initial research to avoid repetition 

1371 # This follows the same pattern as citation tracking (all_links_of_system) 

1372 existing_questions = initial_findings.get("questions_by_iteration", {}) 

1373 if existing_questions: 

1374 # Set questions on both search system and its strategy 

1375 if hasattr(self.search_system, "questions_by_iteration"): 1375 ↛ 1381line 1375 didn't jump to line 1381 because the condition on line 1375 was always true

1376 self.search_system.questions_by_iteration = ( 

1377 existing_questions.copy() 

1378 ) 

1379 

1380 # More importantly, set it on the strategy which actually uses it 

1381 if hasattr(self.search_system, "strategy") and hasattr( 1381 ↛ 1391line 1381 didn't jump to line 1391 because the condition on line 1381 was always true

1382 self.search_system.strategy, "questions_by_iteration" 

1383 ): 

1384 self.search_system.strategy.questions_by_iteration = ( 

1385 existing_questions.copy() 

1386 ) 

1387 logger.info( 

1388 f"Initialized strategy with {len(existing_questions)} iterations of previous questions" 

1389 ) 

1390 

1391 for i, section in enumerate(structure, 1): 

1392 logger.info(f"Processing section: {section['name']}") 

1393 section_content = [] 

1394 

1395 section_content.append(f"# {i}. {section['name']}\n") 

1396 

1397 # If section has no subsections, create one from the section itself 

1398 if not section["subsections"]: 

1399 # Parse section name for purpose 

1400 if "|" in section["name"]: 

1401 parts = section["name"].split("|", 1) 

1402 section["subsections"] = [ 

1403 {"name": parts[0].strip(), "purpose": parts[1].strip()} 

1404 ] 

1405 else: 

1406 # No purpose provided - use section name as subsection 

1407 section["subsections"] = [ 

1408 { 

1409 "name": section["name"], 

1410 "purpose": f"Provide comprehensive content for {section['name']}", 

1411 } 

1412 ] 

1413 

1414 # Process each subsection by directly researching it 

1415 for j, subsection in enumerate(section["subsections"], 1): 

1416 # Only add subsection header if there are multiple subsections 

1417 if len(section["subsections"]) > 1: 

1418 section_content.append(f"## {i}.{j} {subsection['name']}\n") 

1419 section_content.append(f"_{subsection['purpose']}_\n\n") 

1420 

1421 # Get other subsections in this section for context 

1422 other_subsections = [ 

1423 f"- {s['name']}: {s['purpose']}" 

1424 for s in section["subsections"] 

1425 if s["name"] != subsection["name"] 

1426 ] 

1427 other_subsections_text = ( 

1428 "\n".join(other_subsections) 

1429 if other_subsections 

1430 else "None" 

1431 ) 

1432 

1433 # Get all other sections for broader context 

1434 other_sections = [ 

1435 f"- {s['name']}" 

1436 for s in structure 

1437 if s["name"] != section["name"] 

1438 ] 

1439 other_sections_text = ( 

1440 "\n".join(other_sections) if other_sections else "None" 

1441 ) 

1442 

1443 # Check if this is actually a section-level content (only one subsection, likely auto-created) 

1444 is_section_level = len(section["subsections"]) == 1 

1445 

1446 # Build context from previously generated sections to avoid repetition 

1447 previous_context_section = self._build_previous_context( 

1448 accumulated_findings 

1449 ) 

1450 

1451 # Generate appropriate search query 

1452 if is_section_level: 

1453 # Section-level prompt - more comprehensive 

1454 subsection_query = ( 

1455 f"Research task: Create comprehensive content for the '{subsection['name']}' section in a report about '{query}'. " 

1456 f"Section purpose: {subsection['purpose']} " 

1457 f"\n" 

1458 f"Other sections in the report:\n{other_sections_text}\n" 

1459 f"{previous_context_section}" 

1460 f"This is a standalone section requiring comprehensive coverage of its topic. " 

1461 f"Provide a thorough exploration that may include synthesis of information from previous sections where relevant. " 

1462 f"Include unique insights, specific examples, and concrete data. " 

1463 f"Use tables to organize information where applicable. " 

1464 f"For conclusion sections: synthesize key findings and provide forward-looking insights. " 

1465 f"Build upon the research findings from earlier sections to create a cohesive narrative." 

1466 f"{_SUBSECTION_OUTPUT_GUIDANCE}" 

1467 ) 

1468 else: 

1469 # Subsection-level prompt - more focused 

1470 subsection_query = ( 

1471 f"Research task: Create content for subsection '{subsection['name']}' in a report about '{query}'. " 

1472 f"This subsection's purpose: {subsection['purpose']} " 

1473 f"Part of section: '{section['name']}' " 

1474 f"\n" 

1475 f"Other sections in the report:\n{other_sections_text}\n" 

1476 f"\n" 

1477 f"Other subsections in this section will cover:\n{other_subsections_text}\n" 

1478 f"{previous_context_section}" 

1479 f"Focus ONLY on information specific to your subsection's purpose. " 

1480 f"Include unique details, specific examples, and concrete data. " 

1481 f"Use tables to organize information where applicable. " 

1482 f"IMPORTANT: Avoid repeating information that would logically be covered in other sections - focus on what makes this subsection unique. " 

1483 f"Previous research exists - find specific angles for this subsection." 

1484 f"{_SUBSECTION_OUTPUT_GUIDANCE}" 

1485 ) 

1486 

1487 logger.info( 

1488 f"Researching subsection: {subsection['name']} with query: {subsection_query}" 

1489 ) 

1490 

1491 # Report progress and check for cancellation 

1492 if progress_callback: 

1493 pct = int( 

1494 10 

1495 + (completed_subsections / max(total_subsections, 1)) 

1496 * 80 

1497 ) 

1498 progress_callback( 

1499 f"Researching: {section['name']} > {subsection['name']}", 

1500 pct, 

1501 { 

1502 "phase": "report_section_research", 

1503 "subsection": subsection["name"], 

1504 }, 

1505 ) 

1506 

1507 # Fix iteration override: modify strategy's settings_snapshot 

1508 # which is read dynamically via get_setting() 

1509 strategy = self.search_system.strategy 

1510 original_iterations = strategy.settings_snapshot.get( 

1511 "search.iterations" 

1512 ) 

1513 had_iterations_key = ( 

1514 "search.iterations" in strategy.settings_snapshot 

1515 ) 

1516 strategy.settings_snapshot["search.iterations"] = 1 

1517 # Belt-and-suspenders: also override max_iterations for 

1518 # strategies that cache it at __init__ time 

1519 original_max_iter = getattr(strategy, "max_iterations", None) 

1520 strategy.max_iterations = 1 

1521 # If the strategy wraps a delegate (e.g. 

1522 # EnhancedContextualFollowUpStrategy), the actual research 

1523 # happens inside ``delegate_strategy.analyze_topic`` and the 

1524 # delegate has its own cached ``max_iterations`` from 

1525 # construction — without this extra override the per-subsection 

1526 # cap never reaches the worker and each subsection runs the 

1527 # full configured loop instead of one iteration. 

1528 delegate = getattr(strategy, "delegate_strategy", None) 

1529 original_delegate_max_iter = ( 

1530 getattr(delegate, "max_iterations", None) 

1531 if delegate is not None 

1532 else None 

1533 ) 

1534 if delegate is not None and hasattr(delegate, "max_iterations"): 

1535 delegate.max_iterations = 1 

1536 

1537 try: 

1538 # Perform search for this subsection 

1539 subsection_results = self.search_system.analyze_topic( 

1540 subsection_query 

1541 ) 

1542 finally: 

1543 # Restore original iteration settings 

1544 if had_iterations_key: 

1545 strategy.settings_snapshot["search.iterations"] = ( 

1546 original_iterations 

1547 ) 

1548 else: 

1549 strategy.settings_snapshot.pop( 

1550 "search.iterations", None 

1551 ) 

1552 if original_max_iter is not None: 

1553 strategy.max_iterations = original_max_iter 

1554 if delegate is not None and hasattr( 

1555 delegate, "max_iterations" 

1556 ): 

1557 if original_delegate_max_iter is not None: 1557 ↛ 1560line 1557 didn't jump to line 1560 because the condition on line 1557 was always true

1558 delegate.max_iterations = original_delegate_max_iter 

1559 else: 

1560 delegate.max_iterations = None 

1561 

1562 completed_subsections += 1 

1563 

1564 # Add the researched content for this subsection 

1565 if subsection_results.get("current_knowledge"): 

1566 generated_content = subsection_results["current_knowledge"] 

1567 # Strip the boilerplate the LLM tends to emit around 

1568 # subsections: a redundant leading heading that mirrors 

1569 # the framework's own `## i.j Name` heading, an italic 

1570 # purpose statement that mirrors the framework's 

1571 # `_<purpose>_` subtitle, and an embedded 

1572 # '## Sources' / '## References' bibliography block 

1573 # (the framework appends one master '## Sources' to 

1574 # the whole report). Small/quantized local models 

1575 # routinely ignore the OUTPUT FORMAT RULES in the 

1576 # prompt above; this normalisation step guarantees a 

1577 # clean rendered output regardless. 

1578 sibling_names = [ 

1579 s["name"] 

1580 for s in section["subsections"] 

1581 if s["name"] != subsection["name"] 

1582 ] 

1583 try: 

1584 generated_content = self._strip_subsection_boilerplate( 

1585 generated_content, 

1586 subsection_name=subsection["name"], 

1587 section_name=section["name"], 

1588 sibling_subsection_names=sibling_names, 

1589 purpose=subsection.get("purpose"), 

1590 ) 

1591 except Exception: 

1592 logger.exception( 

1593 "Boilerplate strip failed for " 

1594 f"'{section['name']} > {subsection['name']}' — " 

1595 "using raw LLM content" 

1596 ) 

1597 # Fall back to raw content so a cosmetic step never 

1598 # aborts the entire multi-section report. 

1599 generated_content = ( 

1600 subsection_results.get("current_knowledge", "") 

1601 or "" 

1602 ) 

1603 if generated_content.strip(): 1603 ↛ 1610line 1603 didn't jump to line 1610 because the condition on line 1603 was always true

1604 section_content.append(generated_content) 

1605 # Accumulate for context in subsequent sections 

1606 accumulated_findings.append( 

1607 f"[{section['name']} > {subsection['name']}]\n{generated_content}" 

1608 ) 

1609 else: 

1610 section_content.append( 

1611 "*Limited information was found for this subsection.*\n" 

1612 ) 

1613 else: 

1614 section_content.append( 

1615 "*Limited information was found for this subsection.*\n" 

1616 ) 

1617 

1618 section_content.append("\n\n") 

1619 

1620 # Combine all content for this section 

1621 sections[section["name"]] = "\n".join(section_content) 

1622 

1623 return sections 

1624 

1625 def _generate_sections( 

1626 self, 

1627 initial_findings: Dict, 

1628 _section_research: Dict[str, List[Dict]], 

1629 structure: List[Dict], 

1630 query: str, 

1631 ) -> Dict[str, str]: 

1632 """ 

1633 This method is kept for compatibility but no longer used. 

1634 The functionality has been moved to _research_and_generate_sections. 

1635 """ 

1636 return {} 

1637 

1638 def _format_final_report( 

1639 self, 

1640 sections: Dict[str, str], 

1641 structure: List[Dict], 

1642 query: str, 

1643 ) -> Dict: 

1644 """Format the final report with table of contents and sections.""" 

1645 # Generate TOC 

1646 toc = ["# Table of Contents\n"] 

1647 for i, section in enumerate(structure, 1): 

1648 toc.append(f"{i}. **{section['name']}**") 

1649 if len(section["subsections"]) > 1: 

1650 for j, subsection in enumerate(section["subsections"], 1): 

1651 toc.append( 

1652 f" {i}.{j} {subsection['name']} | _{subsection['purpose']}_" 

1653 ) 

1654 

1655 # Combine TOC and sections 

1656 report_parts = ["\n".join(toc), ""] 

1657 

1658 # Add a summary of the research 

1659 report_parts.append("# Research Summary") 

1660 report_parts.append( 

1661 "This report was researched using an advanced search system." 

1662 ) 

1663 report_parts.append( 

1664 "Research included targeted searches for each section and subsection." 

1665 ) 

1666 report_parts.append("\n---\n") 

1667 

1668 # Add each section's content 

1669 for section in structure: 

1670 if section["name"] in sections: 

1671 report_parts.append(sections[section["name"]]) 

1672 report_parts.append("") 

1673 

1674 # Format links from search system 

1675 # Get utilities module dynamically to avoid circular imports 

1676 utilities = importlib.import_module("local_deep_research.utilities") 

1677 # Imported here for the same reason, and directly rather than off 

1678 # the module object above so the count is computed by the real 

1679 # grouping helper. 

1680 from .utilities.search_utilities import ( 

1681 count_distinct_sources, 

1682 filter_cited_links, 

1683 resolve_uncited_sources_mode, 

1684 ) 

1685 

1686 # Body prose BEFORE the Sources tail: citation indices/URLs are 

1687 # parsed from this, so uncited accumulated hits stay out of the 

1688 # bibliography. Honors report.uncited_sources_mode. 

1689 body_prose = "\n\n".join(report_parts) 

1690 mode = resolve_uncited_sources_mode() 

1691 visible_links = filter_cited_links( 

1692 self.search_system.all_links_of_system, 

1693 body_prose, 

1694 mode=mode, 

1695 ) 

1696 formatted_all_links = ( 

1697 utilities.search_utilities.format_links_to_markdown( 

1698 all_links=visible_links, 

1699 ) 

1700 ) 

1701 

1702 # Create final report with all parts. The Sources tail is 

1703 # kept here so in-memory consumers (MCP `generate_report`, 

1704 # programmatic API) get the full assembled blob unchanged. 

1705 # The DB save site (research_service.py) strips this Sources 

1706 # section via format_document_split before persisting, so the 

1707 # answer-only invariant on report_content still holds. 

1708 final_report_content = body_prose 

1709 # Explicit "\n\n" separator: downstream regex consumers 

1710 # (_SOURCES_SECTION_PATTERNS in text_optimization/citation_formatter.py 

1711 # and _LEGACY_SOURCES_RE in web/services/report_assembly_service.py) 

1712 # use line-anchored `re.MULTILINE` matching. Today the trailing 

1713 # newlines produced by `"\n\n".join` happen to keep `## Sources` 

1714 # at the start of a line, but that is incidental; an explicit 

1715 # separator preserves the invariant against future section 

1716 # template changes. 

1717 # 

1718 # The HTML-comment sentinel around the appended `## Sources` 

1719 # block lets ``format_document_split`` locate this section 

1720 # unambiguously, even when the LLM has emitted its own 

1721 # `## Sources` header earlier in the prose. The legacy regex 

1722 # patterns would otherwise match the first `## Sources` they 

1723 # find and could over-strip a multi-section report where the 

1724 # LLM happened to include an inline sources block. Without the 

1725 # sentinel, a 1380-source run legitimately pushes the 

1726 # answer/sources ratio below the 50% safety threshold and the 

1727 # splitter logs a misleading "over-stripped" warning. 

1728 # The sentinel is imported at module top; no circular-import 

1729 # concern at use time. 

1730 final_report_content += ( 

1731 f"\n\n{LDR_APPENDED_SOURCES_SENTINEL}\n\n" 

1732 f"## Sources\n\n{formatted_all_links}" 

1733 ) 

1734 

1735 # Create metadata dictionary 

1736 metadata = { 

1737 "generated_at": datetime.now(UTC).isoformat(), 

1738 # Count the same visible, canonically grouped source set that 

1739 # the Sources block above renders. Document-level: one document 

1740 # cited via several chunk anchors still counts once even though 

1741 # it renders several bibliography lines (distinct sources <= 

1742 # bibliography lines <= citation indices) — so a count below 

1743 # the rendered line total is expected, not a bug. 

1744 "initial_sources": count_distinct_sources(visible_links), 

1745 "sections_researched": len(structure), 

1746 "searches_per_section": self.searches_per_section, 

1747 "query": query, 

1748 } 

1749 

1750 # Return both content and metadata 

1751 return {"content": final_report_content, "metadata": metadata} 

1752 

1753 def _generate_error_report(self, query: str, error_msg: str) -> str: 

1754 return f"=== ERROR REPORT ===\nQuery: {query}\nError: {error_msg}"