Coverage for src/local_deep_research/web/services/report_assembly_service.py: 99%
101 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""Reconstruct the legacy "full report" view from structured storage.
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.
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.
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"""
25import re
26from typing import Any, Dict, List, Optional
28from loguru import logger
29from sqlalchemy.orm import Session
31from ...database.models.research import ResearchHistory, ResearchResource
32from ...utilities.search_utilities import format_links_to_markdown
33from ...utilities.url_utils import canonical_url_key
35# Line-anchored regexes for the legacy-row guard. See `assemble_full_report`
36# for why a substring `in body` check is too loose. A Sources heading alone is
37# not enough: LLMs sometimes emit one followed by placeholder prose. Require
38# the first non-blank line to be a bracket-shaped legacy bibliography entry,
39# including grouped indices and the historical empty-index shape, and require
40# its URL continuation line so bracket-led prose cannot trip the guard.
41_LEGACY_SOURCES_RE = re.compile(
42 r"^## Sources\b[^\r\n]*\r?\n"
43 r"(?:[ \t]*\r?\n)*[ \t]*\[(?:\d+(?:[ \t]*,[ \t]*\d+)*)?\]"
44 r"[^\r\n]*\r?\n[ \t]+URL:[ \t]*\S",
45 re.MULTILINE,
46)
47_LEGACY_METRICS_RE = re.compile(r"^## Research Metrics\b", re.MULTILINE)
50def assemble_full_report(
51 research: Optional[ResearchHistory], db_session: Session
52) -> Optional[str]:
53 """Reconstruct the legacy report shape from structured storage.
55 Args:
56 research: The ResearchHistory ORM row. Must be loaded inside
57 the supplied ``db_session`` to avoid DetachedInstanceError
58 when accessing ``research.research_meta`` lazily.
59 db_session: Active SQLAlchemy session bound to the user DB.
60 Used to query ``research_resources`` for the sources block.
62 Returns:
63 ``None`` when ``research`` is ``None`` (caller should map to
64 404). Otherwise assembled markdown: answer + optional
65 ``## Sources`` block + optional ``## Research Metrics`` block.
66 An existing row with no body / sources / metrics returns an
67 empty string ``""`` (a valid empty-but-found response).
68 """
69 if research is None:
70 return None
72 body = research.report_content or ""
74 # Legacy-row guard: older rows already contain inline
75 # `## Sources` / `## Research Metrics` blocks in report_content. If
76 # we appended freshly-assembled sections to those rows we'd render
77 # the blocks twice. Match only at line-start to avoid false positives
78 # from prose that happens to contain the substring `## Sources` inline
79 # (e.g. an answer that quotes another markdown document).
80 has_legacy_sources = bool(_LEGACY_SOURCES_RE.search(body))
81 has_legacy_metrics = bool(_LEGACY_METRICS_RE.search(body))
83 parts = [body]
85 if not has_legacy_sources:
86 # Let any failure propagate: the callers already wrap this in a
87 # try/except that returns HTTP 500. Swallowing it here would emit a
88 # report that looks complete but is silently missing all sources.
89 sources_md = _build_sources_markdown(research, db_session)
90 if sources_md:
91 parts.append("## Sources\n\n" + sources_md)
93 if not has_legacy_metrics:
94 metrics_md = _build_metrics_markdown(research)
95 if metrics_md:
96 parts.append("## Research Metrics\n" + metrics_md)
98 return "\n\n".join(parts)
101def _build_metrics_markdown(research: ResearchHistory) -> str:
102 """Render the Research Metrics block from persisted metadata.
104 Today the inline metrics block (research_service.py quick-summary
105 path) used ``results["iterations"]`` and a fresh save-time
106 timestamp. Both end up in ``research.research_meta`` (the save site
107 persists ``metadata["iterations"]`` and ``metadata["generated_at"]``)
108 so this read recovers the same values. Falls back to
109 ``research.completed_at`` for the timestamp when ``generated_at`` is
110 missing (legacy rows or scheduler-saved research).
112 Returns an empty string when nothing meaningful can be rendered.
113 """
114 meta = research.research_meta or {}
115 iterations = meta.get("iterations")
116 generated_at = meta.get("generated_at") or research.completed_at
117 lines = []
118 if iterations is not None:
119 lines.append(f"- Search Iterations: {iterations}")
120 if generated_at:
121 lines.append(f"- Generated at: {generated_at}")
122 return "\n".join(lines)
125def _build_sources_markdown(
126 research: ResearchHistory, db_session: Session
127) -> str:
128 """Render the Sources block from the ``research_resources`` table.
130 Maps each ResearchResource row back to the dict shape
131 ``format_links_to_markdown`` expects, preferring the original
132 citation index from ``resource_metadata['original_data']['index']``
133 (assigned by the search system at search time, and the number the
134 inline ``[N]`` references in the saved answer point to). Falls
135 back to row order when the original index was lost on save.
136 """
137 resources = (
138 db_session.query(ResearchResource)
139 .filter_by(research_id=research.id)
140 .order_by(ResearchResource.id.asc())
141 .all()
142 )
144 all_links: List[Dict[str, Any]] = []
145 missing_index_count = 0
146 for fallback_idx, r in enumerate(resources, start=1):
147 # Defensive: legacy rows may have stored metadata as a string.
148 meta = (
149 r.resource_metadata if isinstance(r.resource_metadata, dict) else {}
150 )
151 original = (
152 meta.get("original_data")
153 if isinstance(meta.get("original_data"), dict)
154 else {}
155 )
156 # ``is None`` (not ``not``) so 0 isn't treated as missing.
157 index = original.get("index")
158 if index is None or index == "":
159 missing_index_count += 1
160 index = str(fallback_idx)
161 all_links.append(
162 {
163 "url": str(r.url) if r.url else "",
164 "title": str(r.title) if r.title else "Untitled",
165 "index": index,
166 "journal_quality": original.get("journal_quality"),
167 }
168 )
170 if missing_index_count:
171 # DEBUG (not WARNING): expected for legacy rows / URL-less
172 # entries skipped at save time. Render correctness is preserved
173 # via row-order fallback. Bind research_id so the message
174 # routes through the per-research log table.
175 logger.bind(research_id=research.id).debug(
176 "_build_sources_markdown: {} of {} rows missing original "
177 "citation index; using row order. Common cause: URL-less "
178 "entries were skipped at save time.",
179 missing_index_count,
180 len(resources),
181 )
183 return format_links_to_markdown(all_links)
186def _format_source_link(row: ResearchResource) -> Optional[Dict[str, str]]:
187 """Map one ``research_resources`` row to the news feed's link shape.
189 Shared by :func:`get_research_source_links` and its batched variant so
190 the two cannot drift. Returns ``None`` for a row the feed cannot link
191 to: a non-``http`` URL, or a ``url`` that is not a string at all.
192 Titles fall back to the bare domain when missing and are truncated to
193 50 chars, matching the existing list-card rendering in the news UI.
195 A non-``str`` ``url`` is skipped, not coerced — the same rule
196 ``count_distinct_sources`` and ``format_links_to_markdown`` already
197 apply, and for the same reason: the caller passes this result to
198 :func:`_dedup_key`, and ``canonical_url_key`` raises on a non-str
199 (it unpacks ``str.partition``). Coercing with ``str()`` instead would
200 render a Python repr as a clickable link. ``title`` gets the same
201 treatment, so a malformed row degrades to the domain fallback rather
202 than printing a repr onto a news card.
203 """
204 if not isinstance(row.url, str):
205 return None
206 url = row.url.strip()
207 if not url.startswith("http"):
208 return None
209 title = (row.title if isinstance(row.title, str) else "").strip()
210 if not title:
211 domain = url.split("//")[-1].split("/")[0]
212 title = domain.replace("www.", "")
213 if len(title) > 50: 213 ↛ 214line 213 didn't jump to line 214 because the condition on line 213 was never true
214 title = title[:50] + "..."
215 return {"url": url, "title": title}
218def _dedup_key(url: str) -> str:
219 """Canonical grouping key for a source link.
221 The SAME key ``format_links_to_markdown`` groups the bibliography by
222 and ``count_distinct_sources`` counts with, so a "top 3 sources" card,
223 the rendered ``## Sources`` block and the reported source count cannot
224 disagree about what counts as one source. Falls back to the raw URL if
225 canonicalization yields nothing, so an unrecognizable URL stays its
226 own source rather than merging with every other unrecognizable one.
227 """
228 return canonical_url_key(url) or url
231def get_research_source_links(
232 research_id: str, db_session: Session, limit: int = 3
233) -> List[Dict[str, str]]:
234 """Top-N DISTINCT source links for a research, in row-insertion order.
236 Returns dicts shaped ``{"url": str, "title": str}`` matching the
237 news feed's ``links`` contract (``news/api.py`` consumers). Titles
238 are domain-fallback when missing, truncated to 50 chars to match
239 the existing list-card rendering in the news UI.
241 Deduplicated on :func:`_dedup_key`, first occurrence winning, so
242 ``limit=N`` yields N *distinct* sources rather than N rows. One row
243 per source is not something the caller can assume: a search strategy
244 stores one ``research_resources`` row per piece of evidence, so the
245 same URL legitimately appears several times with different snippets
246 (see #5894), and without this a "top 3 sources" card could render one
247 URL three times. Because dedup happens in Python the row cap cannot
248 be pushed into SQL: the query fetches every matching row, but
249 formatting stops as soon as ``limit`` distinct sources are in hand.
251 Args:
252 research_id: The ResearchHistory id.
253 db_session: Active SQLAlchemy session bound to the user DB.
254 limit: Maximum number of DISTINCT links to return.
255 """
256 rows = (
257 db_session.query(ResearchResource)
258 .filter_by(research_id=research_id)
259 .filter(ResearchResource.url.isnot(None))
260 .order_by(ResearchResource.id.asc())
261 )
262 out: List[Dict[str, str]] = []
263 seen: set[str] = set()
264 for r in rows:
265 if len(out) >= limit:
266 break
267 link = _format_source_link(r)
268 if link is None:
269 continue
270 key = _dedup_key(link["url"])
271 if key in seen:
272 continue
273 seen.add(key)
274 out.append(link)
275 return out
278def get_research_source_links_batch(
279 research_ids: List[str], db_session: Session, limit: Optional[int] = 3
280) -> Dict[str, List[Dict[str, str]]]:
281 """Batched variant of :func:`get_research_source_links`.
283 For news-feed list views that would otherwise fire one query per
284 research item (N+1). One ``WHERE research_id IN (...)`` query plus
285 Python-side grouping. Returned dict maps each research_id to its
286 top-N links (same shape as :func:`get_research_source_links`, and
287 deduplicated the same way — ``limit`` counts DISTINCT sources, not
288 rows). Research ids with zero rows map to ``[]``.
290 ``limit=None`` returns every link for each research (no cap) — used by
291 the report API, which exposes the full source list rather than a top-N.
292 """
293 result: Dict[str, List[Dict[str, str]]] = {rid: [] for rid in research_ids}
294 if not research_ids:
295 return result
297 rows = (
298 db_session.query(ResearchResource)
299 .filter(ResearchResource.research_id.in_(research_ids))
300 .filter(ResearchResource.url.isnot(None))
301 .order_by(ResearchResource.research_id, ResearchResource.id.asc())
302 .all()
303 )
304 seen_by_research: Dict[str, set[str]] = {}
305 for r in rows:
306 bucket = result.setdefault(r.research_id, [])
307 if limit is not None and len(bucket) >= limit:
308 continue
309 link = _format_source_link(r)
310 if link is None:
311 continue
312 seen = seen_by_research.setdefault(r.research_id, set())
313 key = _dedup_key(link["url"])
314 if key in seen:
315 continue
316 seen.add(key)
317 bucket.append(link)
318 return result