Coverage for src/local_deep_research/web/services/report_assembly_service.py: 95%

142 statements  

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

1"""Reconstruct the legacy "full report" view from structured storage. 

2 

3`research.report_content` stores only the synthesized answer (with inline 

4`[N](url)` hyperlinks). Sources live in the `research_resources` table; 

5metrics live in `research.research_meta`. This module rebuilds the 

6combined `answer + ## Sources + ## Research Metrics` view on demand for 

7display and export — chat reads `research.report_content` directly and 

8never goes through this module. 

9 

10The legacy Sources guard expects the first non-blank entry to use the 

11bracketed bibliography shape (`[N]`, grouped `[N, M]`, or historical `[]`) 

12and to carry an indented `URL:` continuation line. Indexed entries mirror 

13`text_optimization.citation_formatter._BIB_SOURCES_PATTERN`; the empty-index 

14variant covers rows emitted before every source had an index. The currently 

15dormant `advanced_search_system.findings.repository.format_links` instead 

16emits `1. Title` entries, so it must not become a persisted Sources emitter 

17without extending this guard and its round-trip tests. 

18 

19DO NOT write the assembled output back to `research.report_content` — 

20that column must stay answer-only. Writing assembled output back would 

21silently re-introduce the regex over-strip class of bugs that 

22answer-only storage avoids. 

23""" 

24 

25import json 

26import re 

27from typing import Any, Dict, List, Optional 

28 

29from loguru import logger 

30from sqlalchemy.orm import Session 

31 

32from ...database.models.research import ResearchHistory, ResearchResource 

33from ...text_optimization.citation_formatter import ( 

34 strip_appended_sources_sentinel, 

35) 

36from ...utilities.search_utilities import ( 

37 UNCITED_SOURCES_MODE_KEY, 

38 UNCITED_SOURCES_MODES, 

39 format_links_to_markdown, 

40 resolve_uncited_sources_mode, 

41) 

42from ...utilities.url_utils import FETCHED_TITLE_KEY, canonical_url_key 

43 

44# Line-anchored regexes for the legacy-row guard. See `assemble_full_report` 

45# for why a substring `in body` check is too loose. A Sources heading alone is 

46# not enough: LLMs sometimes emit one followed by placeholder prose. Require 

47# the first non-blank line to be a bracket-shaped legacy bibliography entry, 

48# including grouped indices and the historical empty-index shape, and require 

49# its URL continuation line so bracket-led prose cannot trip the guard. 

50_LEGACY_SOURCES_RE = re.compile( 

51 r"^## Sources\b[^\r\n]*\r?\n" 

52 r"(?:[ \t]*\r?\n)*[ \t]*\[(?:\d+(?:[ \t]*,[ \t]*\d+)*)?\]" 

53 r"[^\r\n]*\r?\n[ \t]+URL:[ \t]*\S", 

54 re.MULTILINE, 

55) 

56_LEGACY_METRICS_RE = re.compile(r"^## Research Metrics\b", re.MULTILINE) 

57 

58 

59def _current_user_uncited_mode(db_session: Session) -> Optional[str]: 

60 """Read the requesting user's CURRENT saved uncited-sources mode. 

61 

62 Covers legacy rows that completed before snapshots were persisted 

63 (chat/follow-up research created and finished before #6747): those 

64 rows carry only ``{"submission": ...}`` forever, so the row snapshot 

65 cannot decide the render. The requesting user's current preference is 

66 the best available signal — it is what a re-run would generate with. 

67 

68 Returns the validated mode, or ``None`` when the setting is missing, 

69 malformed, or unreadable (caller falls through to the thread context 

70 and then ``"fallback"``). Never raises: rendering a report must not 

71 crash on a preference read. 

72 """ 

73 try: 

74 from ...settings.manager import SettingsManager 

75 

76 raw = SettingsManager(db_session).get_setting( 

77 UNCITED_SOURCES_MODE_KEY, None 

78 ) 

79 except Exception: 

80 logger.debug( 

81 "report assembly: current uncited-sources mode unreadable; " 

82 "falling back", 

83 exc_info=True, 

84 ) 

85 return None 

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

87 raw = raw.get("value") 

88 if isinstance(raw, str) and raw.strip() in UNCITED_SOURCES_MODES: 

89 return raw.strip() 

90 return None 

91 

92 

93def _research_metadata(research) -> Dict[str, Any]: 

94 """Read metadata from current rows and legacy JSON-string rows.""" 

95 try: 

96 meta = getattr(research, "research_meta", None) 

97 if isinstance(meta, str): 

98 meta = json.loads(meta) 

99 return meta if isinstance(meta, dict) else {} 

100 except (json.JSONDecodeError, TypeError): 

101 return {} 

102 

103 

104def _research_settings_snapshot(research) -> Optional[Dict[str, Any]]: 

105 """Return the saved ``settings_snapshot`` for a research row, if any. 

106 

107 Report/history routes run without a research-worker settings context, 

108 so resolving ``report.uncited_sources_mode`` from the thread context 

109 alone always yields ``fallback`` — even when the user saved 

110 ``disabled`` or ``strict``. The snapshot captured at research start 

111 (``research_meta["settings_snapshot"]``) is the applicable saved 

112 preference and must be passed explicitly. 

113 """ 

114 try: 

115 snap = _research_metadata(research).get("settings_snapshot") 

116 return snap if isinstance(snap, dict) else None 

117 except Exception: 

118 return None 

119 

120 

121def assemble_full_report( 

122 research: Optional[ResearchHistory], 

123 db_session: Session, 

124 uncited_mode: Optional[str] = None, 

125 settings_snapshot: Optional[Dict[str, Any]] = None, 

126) -> Optional[str]: 

127 """Reconstruct the legacy report shape from structured storage. 

128 

129 Args: 

130 research: The ResearchHistory ORM row. Must be loaded inside 

131 the supplied ``db_session`` to avoid DetachedInstanceError 

132 when accessing ``research.research_meta`` lazily. 

133 db_session: Active SQLAlchemy session bound to the user DB. 

134 Used to query ``research_resources`` for the sources block. 

135 uncited_mode: Explicit ``report.uncited_sources_mode`` override. 

136 When ``None`` (default), a saved value in the research row's 

137 snapshot is honored. Legacy snapshots without this setting use 

138 the requesting user's current preference. 

139 settings_snapshot: Explicit settings snapshot override. It takes 

140 precedence over the row only when it contains this setting. 

141 

142 Returns: 

143 ``None`` when ``research`` is ``None`` (caller should map to 

144 404). Otherwise assembled markdown: answer + optional 

145 ``## Sources`` block + optional ``## Research Metrics`` block. 

146 An existing row with no body / sources / metrics returns an 

147 empty string ``""`` (a valid empty-but-found response). 

148 """ 

149 if research is None: 

150 return None 

151 

152 # Detailed reports saved in NO_HYPERLINKS mode, or after the citation 

153 # formatter raised, before the save site removed it, still carry the 

154 # report generator's appended-sources sentinel. It is an HTML comment, 

155 # which renderMarkdown() shows as literal text, so it is removed before 

156 # the body is displayed or exported. 

157 body = strip_appended_sources_sentinel(research.report_content or "") 

158 

159 # Legacy-row guard: older rows already contain inline 

160 # `## Sources` / `## Research Metrics` blocks in report_content. If 

161 # we appended freshly-assembled sections to those rows we'd render 

162 # the blocks twice. Match only at line-start to avoid false positives 

163 # from prose that happens to contain the substring `## Sources` inline 

164 # (e.g. an answer that quotes another markdown document). 

165 has_legacy_sources = bool(_LEGACY_SOURCES_RE.search(body)) 

166 has_legacy_metrics = bool(_LEGACY_METRICS_RE.search(body)) 

167 

168 parts = [body] 

169 

170 if not has_legacy_sources: 

171 # Let any failure propagate: the callers already wrap this in a 

172 # try/except that returns HTTP 500. Swallowing it here would emit a 

173 # report that looks complete but is silently missing all sources. 

174 sources_md = _build_sources_markdown( 

175 research, 

176 db_session, 

177 body=body, 

178 uncited_mode=uncited_mode, 

179 settings_snapshot=settings_snapshot, 

180 ) 

181 if sources_md: 

182 parts.append("## Sources\n\n" + sources_md) 

183 

184 if not has_legacy_metrics: 

185 metrics_md = _build_metrics_markdown(research) 

186 if metrics_md: 

187 parts.append("## Research Metrics\n" + metrics_md) 

188 

189 return "\n\n".join(parts) 

190 

191 

192def _build_metrics_markdown(research: ResearchHistory) -> str: 

193 """Render the Research Metrics block from persisted metadata. 

194 

195 Today the inline metrics block (research_service.py quick-summary 

196 path) used ``results["iterations"]`` and a fresh save-time 

197 timestamp. Both end up in ``research.research_meta`` (the save site 

198 persists ``metadata["iterations"]`` and ``metadata["generated_at"]``) 

199 so this read recovers the same values. Falls back to 

200 ``research.completed_at`` for the timestamp when ``generated_at`` is 

201 missing (legacy rows or scheduler-saved research). 

202 

203 Returns an empty string when nothing meaningful can be rendered. 

204 """ 

205 meta = _research_metadata(research) 

206 iterations = meta.get("iterations") 

207 generated_at = meta.get("generated_at") or research.completed_at 

208 lines = [] 

209 if iterations is not None: 

210 lines.append(f"- Search Iterations: {iterations}") 

211 if generated_at: 

212 lines.append(f"- Generated at: {generated_at}") 

213 return "\n".join(lines) 

214 

215 

216def _build_sources_markdown( 

217 research: ResearchHistory, 

218 db_session: Session, 

219 body: str = "", 

220 uncited_mode: Optional[str] = None, 

221 settings_snapshot: Optional[Dict[str, Any]] = None, 

222) -> str: 

223 """Render the Sources block from the ``research_resources`` table. 

224 

225 Maps each ResearchResource row back to the dict shape 

226 ``format_links_to_markdown`` expects, preferring the original 

227 citation index from ``resource_metadata['original_data']['index']`` 

228 (assigned by the search system at search time, and the number the 

229 inline ``[N]`` references in the saved answer point to). Falls 

230 back to row order when the original index was lost on save. 

231 

232 Only resources cited in ``body`` (the saved answer) are listed, 

233 honoring ``report.uncited_sources_mode``. The saved snapshot on 

234 the research row wins over the thread-local worker context (which 

235 report/history routes never install): an explicit ``uncited_mode`` 

236 wins first, then an explicit snapshot containing this setting, then 

237 the row's own snapshot containing it, then — for older rows that 

238 lack this setting — the requesting user's current saved setting, 

239 then the thread context. 

240 """ 

241 resources = ( 

242 db_session.query(ResearchResource) 

243 .filter_by(research_id=research.id) 

244 .order_by(ResearchResource.id.asc()) 

245 .all() 

246 ) 

247 

248 all_links: List[Dict[str, Any]] = [] 

249 missing_index_count = 0 

250 for fallback_idx, r in enumerate(resources, start=1): 

251 # Defensive: legacy rows may have stored metadata as a string. 

252 meta = ( 

253 r.resource_metadata if isinstance(r.resource_metadata, dict) else {} 

254 ) 

255 original = ( 

256 meta.get("original_data") 

257 if isinstance(meta.get("original_data"), dict) 

258 else {} 

259 ) 

260 # ``is None`` (not ``not``) so 0 isn't treated as missing. 

261 index = original.get("index") 

262 if index is None or index == "": 

263 missing_index_count += 1 

264 index = str(fallback_idx) 

265 all_links.append( 

266 { 

267 "url": str(r.url) if r.url else "", 

268 "title": str(r.title) if r.title else "Untitled", 

269 "index": index, 

270 "journal_quality": original.get("journal_quality"), 

271 # A fetched title can predate an archive upgrade. Retain 

272 # the capture metadata so the renderer still adds its date. 

273 "source_type": original.get("source_type", r.source_type), 

274 "original_url": original.get("original_url"), 

275 "requested_url": original.get("requested_url"), 

276 "timestamp": original.get("timestamp"), 

277 "formatted_date": original.get("formatted_date"), 

278 # ``title`` above is the column, which the search hit wrote. 

279 # Rows saved before this key existed return None, which the 

280 # renderer ignores. 

281 FETCHED_TITLE_KEY: original.get(FETCHED_TITLE_KEY), 

282 } 

283 ) 

284 

285 if missing_index_count: 

286 # DEBUG (not WARNING): expected for legacy rows / URL-less 

287 # entries skipped at save time. Render correctness is preserved 

288 # via row-order fallback. Bind research_id so the message 

289 # routes through the per-research log table. 

290 logger.bind(research_id=research.id).debug( 

291 "_build_sources_markdown: {} of {} rows missing original " 

292 "citation index; using row order. Common cause: URL-less " 

293 "entries were skipped at save time.", 

294 missing_index_count, 

295 len(resources), 

296 ) 

297 

298 return format_links_to_markdown( 

299 all_links, 

300 prose=body, 

301 uncited_mode=_resolve_saved_uncited_mode( 

302 research, 

303 uncited_mode=uncited_mode, 

304 settings_snapshot=settings_snapshot, 

305 db_session=db_session, 

306 ), 

307 ) 

308 

309 

310def _resolve_saved_uncited_mode( 

311 research, 

312 uncited_mode: Optional[str] = None, 

313 settings_snapshot: Optional[Dict[str, Any]] = None, 

314 db_session: Optional[Session] = None, 

315) -> str: 

316 """Resolve the effective uncited-sources mode for a saved research row. 

317 

318 Precedence: explicit ``uncited_mode`` > explicit snapshot value > 

319 the row's own saved snapshot value > the requesting user's current 

320 saved setting > thread context (which report routes never install) > 

321 ``"fallback"``. A snapshot without this key is not an override. 

322 """ 

323 if uncited_mode is not None: 

324 return resolve_uncited_sources_mode(uncited_mode) 

325 if ( 

326 isinstance(settings_snapshot, dict) 

327 and UNCITED_SOURCES_MODE_KEY in settings_snapshot 

328 ): 

329 return resolve_uncited_sources_mode( 

330 None, settings_snapshot=settings_snapshot 

331 ) 

332 row_snapshot = _research_settings_snapshot(research) 

333 if row_snapshot is not None and UNCITED_SOURCES_MODE_KEY in row_snapshot: 

334 return resolve_uncited_sources_mode( 

335 None, settings_snapshot=row_snapshot 

336 ) 

337 if db_session is not None: 337 ↛ 341line 337 didn't jump to line 341 because the condition on line 337 was always true

338 current = _current_user_uncited_mode(db_session) 

339 if current is not None: 

340 return current 

341 return resolve_uncited_sources_mode(None, settings_snapshot=None) 

342 

343 

344def _format_source_link(row: ResearchResource) -> Optional[Dict[str, str]]: 

345 """Map one ``research_resources`` row to the news feed's link shape. 

346 

347 Shared by :func:`get_research_source_links` and its batched variant so 

348 the two cannot drift. Returns ``None`` for a row the feed cannot link 

349 to: a non-``http`` URL, or a ``url`` that is not a string at all. 

350 Titles fall back to the bare domain when missing and are truncated to 

351 50 chars, matching the existing list-card rendering in the news UI. 

352 

353 A non-``str`` ``url`` is skipped, not coerced — the same rule 

354 ``count_distinct_sources`` and ``format_links_to_markdown`` already 

355 apply, and for the same reason: the caller passes this result to 

356 :func:`_dedup_key`, and ``canonical_url_key`` raises on a non-str 

357 (it unpacks ``str.partition``). Coercing with ``str()`` instead would 

358 render a Python repr as a clickable link. ``title`` gets the same 

359 treatment, so a malformed row degrades to the domain fallback rather 

360 than printing a repr onto a news card. 

361 """ 

362 if not isinstance(row.url, str): 

363 return None 

364 url = row.url.strip() 

365 if not url.startswith("http"): 

366 return None 

367 title = (row.title if isinstance(row.title, str) else "").strip() 

368 if not title: 

369 domain = url.split("//")[-1].split("/")[0] 

370 title = domain.replace("www.", "") 

371 if len(title) > 50: 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true

372 title = title[:50] + "..." 

373 return {"url": url, "title": title} 

374 

375 

376def _dedup_key(url: str) -> str: 

377 """Canonical grouping key for a source link. 

378 

379 The SAME key ``format_links_to_markdown`` groups the bibliography by 

380 and ``count_distinct_sources`` counts with, so a "top 3 sources" card, 

381 the rendered ``## Sources`` block and the reported source count cannot 

382 disagree about what counts as one source. Falls back to the raw URL if 

383 canonicalization yields nothing, so an unrecognizable URL stays its 

384 own source rather than merging with every other unrecognizable one. 

385 """ 

386 return canonical_url_key(url) or url 

387 

388 

389def get_research_source_links( 

390 research_id: str, db_session: Session, limit: int = 3 

391) -> List[Dict[str, str]]: 

392 """Top-N DISTINCT source links for a research, in row-insertion order. 

393 

394 Returns dicts shaped ``{"url": str, "title": str}`` matching the 

395 news feed's ``links`` contract (``news/api.py`` consumers). Titles 

396 are domain-fallback when missing, truncated to 50 chars to match 

397 the existing list-card rendering in the news UI. 

398 

399 Deduplicated on :func:`_dedup_key`, first occurrence winning, so 

400 ``limit=N`` yields N *distinct* sources rather than N rows. One row 

401 per source is not something the caller can assume: a search strategy 

402 stores one ``research_resources`` row per piece of evidence, so the 

403 same URL legitimately appears several times with different snippets 

404 (see #5894), and without this a "top 3 sources" card could render one 

405 URL three times. Because dedup happens in Python the row cap cannot 

406 be pushed into SQL: the query fetches every matching row, but 

407 formatting stops as soon as ``limit`` distinct sources are in hand. 

408 

409 Args: 

410 research_id: The ResearchHistory id. 

411 db_session: Active SQLAlchemy session bound to the user DB. 

412 limit: Maximum number of DISTINCT links to return. 

413 """ 

414 rows = ( 

415 db_session.query(ResearchResource) 

416 .filter_by(research_id=research_id) 

417 .filter(ResearchResource.url.isnot(None)) 

418 .order_by(ResearchResource.id.asc()) 

419 ) 

420 out: List[Dict[str, str]] = [] 

421 seen: set[str] = set() 

422 for r in rows: 

423 if len(out) >= limit: 

424 break 

425 link = _format_source_link(r) 

426 if link is None: 

427 continue 

428 key = _dedup_key(link["url"]) 

429 if key in seen: 

430 continue 

431 seen.add(key) 

432 out.append(link) 

433 return out 

434 

435 

436def get_research_source_links_batch( 

437 research_ids: List[str], db_session: Session, limit: Optional[int] = 3 

438) -> Dict[str, List[Dict[str, str]]]: 

439 """Batched variant of :func:`get_research_source_links`. 

440 

441 For news-feed list views that would otherwise fire one query per 

442 research item (N+1). One ``WHERE research_id IN (...)`` query plus 

443 Python-side grouping. Returned dict maps each research_id to its 

444 top-N links (same shape as :func:`get_research_source_links`, and 

445 deduplicated the same way — ``limit`` counts DISTINCT sources, not 

446 rows). Research ids with zero rows map to ``[]``. 

447 

448 ``limit=None`` returns every link for each research (no cap) — used by 

449 the report API, which exposes the full source list rather than a top-N. 

450 """ 

451 result: Dict[str, List[Dict[str, str]]] = {rid: [] for rid in research_ids} 

452 if not research_ids: 

453 return result 

454 

455 rows = ( 

456 db_session.query(ResearchResource) 

457 .filter(ResearchResource.research_id.in_(research_ids)) 

458 .filter(ResearchResource.url.isnot(None)) 

459 .order_by(ResearchResource.research_id, ResearchResource.id.asc()) 

460 .all() 

461 ) 

462 seen_by_research: Dict[str, set[str]] = {} 

463 for r in rows: 

464 bucket = result.setdefault(r.research_id, []) 

465 if limit is not None and len(bucket) >= limit: 

466 continue 

467 link = _format_source_link(r) 

468 if link is None: 

469 continue 

470 seen = seen_by_research.setdefault(r.research_id, set()) 

471 key = _dedup_key(link["url"]) 

472 if key in seen: 

473 continue 

474 seen.add(key) 

475 bucket.append(link) 

476 return result