Coverage for src/local_deep_research/web/services/research_sources_service.py: 88%

296 statements  

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

1""" 

2Service for managing research sources/resources in the database. 

3 

4This service handles saving and retrieving sources from research 

5in a proper relational way using the ResearchResource table. 

6""" 

7 

8from typing import List, Dict, Any, Optional 

9from datetime import datetime, UTC 

10from loguru import logger 

11from sqlalchemy import or_ 

12import numbers 

13 

14from sqlalchemy.exc import IntegrityError 

15from sqlalchemy.orm import Session, load_only 

16 

17from ...database.models import ( 

18 Journal, 

19 Paper, 

20 PaperAppearance, 

21 ResearchResource, 

22 ResearchHistory, 

23) 

24from ...database.session_context import get_user_db_session 

25from ...utilities.citation_normalizer import normalize_citation 

26from ...utilities.url_utils import ( 

27 CHUNK_DISPLAY_KEY, 

28 canonical_url_key, 

29 library_display_url, 

30 preferred_chunk_display, 

31) 

32 

33 

34def _as_text(value: object) -> str: 

35 """Coerce a stored-dict value to ``str`` for slicing and ``len()``. 

36 

37 Every read in the per-source loop comes from a JSON blob that 

38 round-tripped through the database, so an ``int`` or ``list`` in a 

39 text field is possible. Slicing one raises inside the broad 

40 ``except`` below, which rolls back and drops the whole citation with 

41 no message — the same silent-loss failure the url guards prevent. 

42 """ 

43 if value is None: 

44 return "" 

45 if isinstance(value, str): 

46 return value 

47 try: 

48 return str(value) 

49 except Exception: 

50 # A coercion helper on a silent-drop path must not be able to 

51 # raise: an object whose ``__str__`` fails would otherwise take 

52 # out the exception handler that reports the failure, and with it 

53 # every sibling source in the batch. 

54 return "" 

55 

56 

57# Every field in this loop that is read as text — by normalize_citation, 

58# by a slice, as a dict key, or as a Text column. Coercing them once at 

59# the top of the loop is what stops the "guard the read a review named, 

60# miss its sibling" cycle: nine commits guarded url, then snippet, then 

61# ct_matched, then title, while pmid, arxiv_id and normalize_citation's 

62# own re-read of url stayed raw and kept dropping the citation. 

63_TEXT_FIELDS = ( 

64 "url", 

65 "link", 

66 "title", 

67 "name", 

68 "snippet", 

69 "content_preview", 

70 "description", 

71 "source_type", 

72 "source_engine", 

73 "source", 

74 "container_title", 

75 "container-title", 

76 "journal", 

77 "venue", 

78 "journal_ref", 

79 "journal_name_matched", 

80 "volume", 

81 "issue", 

82 "pages", 

83 "publisher", 

84) 

85 

86 

87# Identifier fields. These are NOT text-coerced: they feed ``unique`` 

88# columns on ``Paper`` and the dedup SELECT that decides whether two 

89# engines found the same paper. ``_extract_doi`` documents and tests a 

90# LIST doi (it takes ``doi[0]``), so stringifying one writes 

91# ``"['10.1/x', '10.2/y']"`` into a unique key — worse than the drop it 

92# replaced, because it is permanent and defeats dedup. Unwrap instead. 

93_IDENTIFIER_FIELDS = ("doi", "pmid", "pmcid", "arxiv_id") 

94 

95 

96# The two fields whose contents ``_parse_authors_list`` turns into author 

97# records. Only inside these does an empty name key mean "junk author". 

98_AUTHOR_LIST_KEYS = ("authors", "authors_csl") 

99 

100# Author fields whose emptiness produces a junk author record. 

101# ``name``/``display_name`` are the two ``citation_normalizer._parse_name`` 

102# strips — ``display_name`` is the OpenAlex shape, missing from an earlier 

103# spelling of this tuple. ``family``/``given``/``suffix`` are copied 

104# verbatim by ``_parse_authors_list``'s CSL branch, which tests only 

105# ``"family" in author``, so an empty one of those is embedded in the 

106# stored csl_json rather than ignored. ``literal`` is deliberately absent: 

107# ``_parse_name`` only ever EMITS it, never reads it from input, and an 

108# earlier spelling carried it (plus two more) as keys that did nothing. 

109_AUTHOR_NAME_KEYS = ("name", "display_name", "family", "given", "suffix") 

110 

111 

112# Where the walk currently is, relative to an author record. A bool was 

113# not enough: it could say "inside authors" but not "inside an author 

114# record's OWN keys", so once set it stayed set for every descendant and 

115# deleted an empty ``given`` from ``authors[0].affiliation`` or an 

116# OpenAlex ``institutions[].display_name`` — neither of which 

117# ``_parse_authors_list`` ever reads. 

118_AUTHORS_OUTSIDE = 0 

119_AUTHORS_LIST = 1 # this value IS an authors/authors_csl list 

120_AUTHORS_RECORD = 2 # this value IS one author record 

121 

122 

123def _json_text_safe( 

124 value: object, _depth: int = 0, _authors: int = _AUTHORS_OUTSIDE 

125) -> object: 

126 """Recursively make *value* safe to serialize and to read as text. 

127 

128 The ingest boundary is the TREE, not the top-level dict. ``metadata`` 

129 and ``authors`` come from the same untrusted producer, are read by the 

130 same ``normalize_citation`` call, and are serialized into the same JSON 

131 column — so a ``set`` under ``metadata["journal"]`` or an ``int`` under 

132 ``authors[0]["name"]`` raises exactly where a top-level one used to. 

133 

134 ``_authors`` scopes the empty-name drop to an author record's OWN 

135 keys, which are the only ones ``_parse_authors_list`` reads. Anything 

136 nested below them is inert data the producer may want preserved. 

137 """ 

138 if _depth > 6: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true

139 return str(value) 

140 if value is None or isinstance(value, (str, int, float, bool)): 

141 return value 

142 if isinstance(value, dict): 

143 in_record = _authors == _AUTHORS_RECORD 

144 # Author name fields are coerced ONCE and reused. Calling 

145 # ``_as_text`` separately in the filter and the value expression 

146 # meant a non-deterministic ``__str__`` could pass the filter on 

147 # one call and write "" from the next. 

148 coerced = ( 

149 { 

150 str(k): _as_text(v) 

151 for k, v in value.items() 

152 if k in _AUTHOR_NAME_KEYS 

153 } 

154 if in_record 

155 else {} 

156 ) 

157 return { 

158 str(k): ( 

159 coerced[str(k)] 

160 if in_record and k in _AUTHOR_NAME_KEYS 

161 # Descending out of the record: re-arm only on another 

162 # authors key, never inherit. 

163 else _json_text_safe( 

164 v, 

165 _depth + 1, 

166 _AUTHORS_LIST 

167 if k in _AUTHOR_LIST_KEYS 

168 else _AUTHORS_OUTSIDE, 

169 ) 

170 ) 

171 for k, v in value.items() 

172 # An author name that coerces to nothing is DROPPED, not kept. 

173 # ``_parse_name(None)`` raises and ``_parse_name("")`` 

174 # synthesises ``{"literal": ""}``; the CSL branch of 

175 # ``_parse_authors_list`` copies ``family``/``given``/``suffix`` 

176 # verbatim, so an empty one of those lands as a junk author in 

177 # the stored csl_json just the same. ``.strip()`` because 

178 # ``_parse_name`` strips before its own emptiness test, so a 

179 # whitespace-only name produces the same junk. 

180 if not ( 

181 in_record 

182 and k in _AUTHOR_NAME_KEYS 

183 and not (coerced.get(str(k)) or "").strip() 

184 ) 

185 } 

186 if isinstance(value, (list, tuple)): 

187 # The ELEMENTS of an authors list are the author records. 

188 child = ( 

189 _AUTHORS_RECORD if _authors == _AUTHORS_LIST else _AUTHORS_OUTSIDE 

190 ) 

191 return [_json_text_safe(v, _depth + 1, child) for v in value] 

192 return str(value) 

193 

194 

195# SQLite's INTEGER column is 64-bit. Python's int is not, so a value can 

196# pass ``int()`` inside citation_normalizer and still raise OverflowError 

197# at flush() — and because that is not an IntegrityError, the per-source 

198# SAVEPOINT retry does not apply and the Session is left rolled back, so 

199# every LATER source in the batch is lost too and nothing commits. 

200_SQLITE_INT_MIN = -(2**63) 

201_SQLITE_INT_MAX = 2**63 - 1 

202 

203 

204def _coerce_year(value: object) -> object: 

205 """Drop a year that cannot be stored, rather than losing the batch.""" 

206 if value is None or isinstance(value, bool): 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true

207 return value 

208 # Duck-typed on the operation, not on a type list. ``_parse_date`` does 

209 # ``int(raw_year)``, which accepts anything with ``__int__`` — numpy 

210 # scalars, Decimal, Fraction, even UUID — and this file's own comments 

211 # name numpy as an expected producer. Gating on ``(int, float, str)`` 

212 # let every one of those past the bound and into flush(). 

213 try: 

214 as_int = int(float(value) if isinstance(value, float) else value) 

215 except (ValueError, OverflowError, TypeError, ArithmeticError): 

216 # Not int()-able, so ``_parse_date`` cannot overflow on it either: 

217 # leave strings for its regex, drop anything else. 

218 return value if isinstance(value, str) else None 

219 return value if _SQLITE_INT_MIN <= as_int <= _SQLITE_INT_MAX else None 

220 

221 

222def _coerce_identifier(value: object) -> object: 

223 """Return *value* fit for a ``unique`` identifier column, else ``None``. 

224 

225 Unwrap-or-DROP, never unwrap-or-stringify. These feed ``Paper.doi``, 

226 ``.pmid`` and ``.arxiv_id`` and the dedup SELECT, where a repr is worse 

227 than absence twice over: it is permanent, and two DIFFERENT papers 

228 carrying the same unparseable value match each other and collapse into 

229 one row. Absence just means "this engine gave us no identifier", which 

230 ``_extract_doi`` already handles by falling through to its next 

231 channel. 

232 

233 A list is unwrapped once and its element re-checked, so ``[["10.1/x"]]`` 

234 drops rather than stringifying the inner list. 

235 """ 

236 if isinstance(value, (list, tuple)): 

237 value = value[0] if value else None 

238 if value is None or isinstance(value, str): 

239 return value or None 

240 # Integral-ness, not ``isinstance(int)``: a PMID legitimately arrives 

241 # as an int, and numpy.int64 / Decimal("12345") are integers that are 

242 # NOT int subclasses. Rejecting them dropped real identifiers and 

243 # broke dedup — the same harm as stringifying, in the other direction. 

244 # ``str()`` on a large int is exact, so there is nothing to protect 

245 # against here. 

246 # A numpy boolean is neither a Python ``bool`` nor a 

247 # ``numbers.Real``/``Integral``, so it slips both gates while 

248 # ``int()``-ing to 1 and comparing equal to it — a True in a pmid 

249 # field was stored as the identifier "1". 

250 # 

251 # Matched on the dtype kind rather than the type name: numpy 1.x calls 

252 # the scalar ``bool_`` and numpy 2.x calls it ``bool``, so a name check 

253 # written against either version silently misses the other. 

254 if isinstance(value, bool): 

255 return None 

256 try: 

257 # ``getattr`` only swallows a MISSING attribute; a ``.kind`` 

258 # property that raises would propagate out of a coercion helper 

259 # that every other step in this function keeps total. 

260 if getattr(getattr(value, "dtype", None), "kind", None) == "b": 

261 return None 

262 except Exception: 

263 return None 

264 # ``numbers.Real`` rather than ``isinstance(value, float)``: numpy's 

265 # float32 is not a float subclass, so an exactly-integral one slipped 

266 # through as an identifier. Engines send ints and strings, never reals. 

267 if isinstance(value, numbers.Real) and not isinstance( 

268 value, numbers.Integral 

269 ): 

270 return None 

271 try: 

272 as_int = int(value) 

273 except (ValueError, OverflowError, TypeError, ArithmeticError): 

274 return None 

275 # Exactness. ``numbers.Integral`` first, because ``as_int != value`` 

276 # relies on a symmetric ``__eq__``: numpy ints and Decimal have one, 

277 # so both are accepted. An object that defines ``__int__`` but neither 

278 # registers as Integral nor compares equal to its own integer is 

279 # DROPPED — deliberately. This feeds a unique column, and a value that 

280 # cannot be shown equal to the integer it claims to be is not one to 

281 # guess at. So this is int()-ability plus provable exactness, not 

282 # int()-ability alone. 

283 if not isinstance(value, numbers.Integral) and as_int != value: 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true

284 return None 

285 return str(as_int) if as_int else None 

286 

287 

288def normalize_source_fields(source: dict) -> dict: 

289 """Return a copy of *source* with its text fields coerced to ``str``. 

290 

291 Engine dicts reach this service from arbitrary producers — a LangChain 

292 retriever's ``Document.metadata`` is whatever the user put there — and 

293 a non-str reaching a slice, a dict key, a regex or a Text column raises 

294 inside the per-source ``except``, which rolls back and drops the whole 

295 citation with only a counter to show for it. 

296 

297 A falsy non-str becomes ``""``, not its repr: ``{"link": []}`` should 

298 keep skipping the source, as it did before any coercion existed, rather 

299 than persisting the string ``"[]"`` and rendering it as a link. 

300 

301 Typed fields are left alone — ``authors`` stays a list, ``year`` an 

302 int, ``score`` a float — and only a non-dict ``metadata`` is replaced, 

303 since callers index into it. 

304 """ 

305 normalized = dict(source) 

306 for field in _TEXT_FIELDS: 

307 value = normalized.get(field) 

308 if value is None or isinstance(value, str): 

309 continue 

310 normalized[field] = _as_text(value) if value else "" 

311 for field in ("year", "publication_year"): 

312 if field in normalized: 

313 normalized[field] = _coerce_year(normalized[field]) 

314 

315 for field in _IDENTIFIER_FIELDS: 

316 # ``in``, not ``.get``: materialising ``doi: None`` on every plain 

317 # web result changes what ``original_data`` stores for rows that 

318 # never had an academic identity. 

319 if field not in normalized: 

320 continue 

321 normalized[field] = _coerce_identifier(normalized[field]) 

322 

323 if "metadata" in normalized: 

324 metadata = normalized.get("metadata") 

325 normalized["metadata"] = ( 

326 _json_text_safe(metadata) if isinstance(metadata, dict) else {} 

327 ) 

328 

329 # D2: ``authors_csl`` is read BEFORE ``authors`` by 

330 # ``normalize_citation``, in the same expression, and is NASA ADS's 

331 # primary author channel — normalizing only one of the two left the 

332 # other reaching the same json.dumps. 

333 for field in _AUTHOR_LIST_KEYS: 

334 value = normalized.get(field) 

335 if isinstance(value, (list, tuple)): 

336 normalized[field] = _json_text_safe( 

337 list(value), _authors=_AUTHORS_LIST 

338 ) 

339 

340 # D1: ``external_ids`` is the second DOI source ``_extract_doi`` 

341 # documents. Leaving it raw put a list DOI into the unique Paper.doi 

342 # by the very channel the identifier unwrap above exists to protect. 

343 for field in ("external_ids", "externalIds"): 

344 value = normalized.get(field) 

345 if value is not None: 

346 normalized[field] = ( 

347 _json_text_safe(value) if isinstance(value, dict) else {} 

348 ) 

349 for key in ("DOI", "doi"): 

350 if key in normalized[field]: 

351 normalized[field][key] = _coerce_identifier( 

352 normalized[field][key] 

353 ) 

354 return normalized 

355 

356 

357def select_source_url(source: dict) -> object: 

358 """Choose the URL to persist for *source*. 

359 

360 Extracted so the test suite can exercise THIS function. It previously 

361 lived inline and was pinned by a test that reimplemented it; the mirror 

362 stayed faithful right up until it faithfully mirrored a bug, and went 

363 green while the hole was open. 

364 

365 Fails CLOSED on a non-str url: an earlier version made the isinstance 

366 check one conjunct of the rejection condition, so a non-str url made 

367 the whole ``and``-chain false, the ownership check never ran, and a 

368 recorded anchor naming a DIFFERENT document was persisted unchecked. 

369 """ 

370 own_url = source.get("url", "") or source.get("link", "") 

371 own_key = canonical_url_key(own_url) if isinstance(own_url, str) else None 

372 

373 raw_key = source.get(CHUNK_DISPLAY_KEY) 

374 recorded = ( 

375 preferred_chunk_display(raw_key) if isinstance(raw_key, str) else None 

376 ) 

377 # No usable key for this entry means no way to prove ownership, so the 

378 # recorded anchor is refused rather than trusted. 

379 if recorded and (own_key is None or canonical_url_key(recorded) != own_key): 

380 recorded = None 

381 

382 own_anchor = ( 

383 preferred_chunk_display(own_url) if isinstance(own_url, str) else None 

384 ) 

385 # Normalise before falling back to the raw string. Without this the 

386 # absolute alias is persisted as typed, and the ``:443``/userinfo 

387 # spellings — which the alias parser accepts as the same document but 

388 # ``library_resolver`` refuses, since it compares netloc exactly — 

389 # become dead links in ``research_resources.url``. Returns None for a 

390 # non-library URL, so external sources keep their own spelling. 

391 normalised = ( 

392 library_display_url(own_url) if isinstance(own_url, str) else None 

393 ) 

394 return own_anchor or recorded or normalised or own_url 

395 

396 

397class ResearchSourcesService: 

398 """Service for managing research sources in the database.""" 

399 

400 @staticmethod 

401 def save_research_sources( 

402 research_id: str, 

403 sources: List[Dict[str, Any]], 

404 username: Optional[str] = None, 

405 ) -> int: 

406 """ 

407 Save sources from research to the ResearchResource table. 

408 

409 Args: 

410 research_id: The UUID of the research 

411 sources: List of source dictionaries with url, title, snippet, etc. 

412 username: Username for database access 

413 

414 Returns: 

415 Number of sources saved 

416 """ 

417 if not sources: 

418 logger.info(f"No sources to save for research {research_id}") 

419 return 0 

420 

421 saved_count = 0 

422 # Failed-source counter. The per-source try/except below catches 

423 # broad exceptions to keep one bad source from killing the batch, 

424 # but without this counter the caller had no way to distinguish 

425 # "all N saved" from "some silently dropped". Emitted in the 

426 # final log line so admins can spot save-failure trends. 

427 failed_count = 0 

428 

429 try: 

430 with get_user_db_session(username) as db_session: 

431 # First check if resources already exist for this research 

432 existing = ( 

433 db_session.query(ResearchResource) 

434 .filter_by(research_id=research_id) 

435 .count() 

436 ) 

437 

438 if existing > 0: 

439 logger.info( 

440 f"Research {research_id} already has {existing} resources, skipping save" 

441 ) 

442 return int(existing) 

443 

444 # Save each source as a ResearchResource. 

445 # Each source runs inside a SAVEPOINT so a per-source 

446 # failure can be rolled back cleanly without losing 

447 # any previously saved sources in this batch. 

448 # Per-batch memoization: container_title → journal_id 

449 # avoids redundant Journal lookups when multiple sources 

450 # share the same venue (common in topic-focused searches). 

451 journal_id_cache: Dict[Optional[str], Optional[int]] = {} 

452 for source in sources: 

453 sp = None 

454 try: 

455 # Once, before anything reads it — see 

456 # normalize_source_fields. 

457 source = normalize_source_fields(source) 

458 # Extract fields from various possible formats 

459 # A chunk-anchored spelling recorded by the 

460 # collector wins. After canonical-key dedup only 

461 # one entry per library document survives, and it 

462 # may be the anchor-less view — persisting that 

463 # loses the #chunk-<n> permanently, since this row 

464 # is what later renders the source. 

465 # Validated, not trusted: this value is written 

466 # to the database and later rendered as a link, so 

467 # an unchecked read would let whatever set the key 

468 # choose the stored URL. 

469 # ``_as_text`` here, not just at the slices: these 

470 # three reach ``Text`` columns, and a list or dict 

471 # raises at ``flush()`` rather than at the read — 

472 # so guarding the read alone only moved the 

473 # exception, and the citation was dropped just the 

474 # same. 

475 url = _as_text(select_source_url(source)) 

476 title = _as_text( 

477 source.get("title", "") or source.get("name", "") 

478 ) 

479 # Coerced, not assumed: these come from the same 

480 # stored dict as the url, and a non-str raises 

481 # inside the per-source ``except`` below — which 

482 # drops the whole citation silently. 

483 snippet = _as_text( 

484 source.get("snippet", "") 

485 or source.get("content_preview", "") 

486 or source.get("description", "") 

487 ) 

488 source_type = _as_text(source.get("source_type", "web")) 

489 

490 # Skip if no URL 

491 if not url: 

492 continue 

493 

494 # Start savepoint for this source — any rollback 

495 # inside this block (including the IntegrityError 

496 # retry path below) only affects this source. 

497 sp = db_session.begin_nested() 

498 

499 # Create resource record. 

500 # Sanitize the source dict before embedding it in 

501 # resource_metadata — raw engine dicts can contain 

502 # non-JSON-serializable values (nested objects, 

503 # numpy types, affiliation sub-dicts, etc.) which 

504 # would crash json.dumps() at flush time. 

505 safe_source = _json_safe(source) 

506 resource = ResearchResource( 

507 research_id=research_id, 

508 title=title or "Untitled", 

509 url=url, 

510 content_preview=snippet[:1000] 

511 if snippet 

512 else None, # Limit preview length 

513 source_type=source_type, 

514 resource_metadata={ 

515 "added_at": datetime.now(UTC).isoformat(), 

516 "original_data": safe_source, 

517 }, 

518 created_at=datetime.now(UTC).isoformat(), 

519 ) 

520 

521 db_session.add(resource) 

522 db_session.flush() # Get resource.id for FK 

523 

524 # Create or reuse Paper for academic sources 

525 citation_fields = normalize_citation(source) 

526 if citation_fields: 

527 source_engine = citation_fields.pop( 

528 "source_engine", None 

529 ) 

530 # Try to link to existing Journal record 

531 # (container_title stays in citation_fields so 

532 # it ends up in the metadata blob for citation 

533 # export). Memoized per batch to avoid repeat 

534 # lookups for the same venue. 

535 ct = citation_fields.get("container_title") 

536 # Coerced before use as a dict key: CSL 

537 # ``container-title`` is legitimately an array, 

538 # and an unhashable value raises here — 34 

539 # lines before the guard on the same value. 

540 ct = _as_text(ct) or None 

541 if ct in journal_id_cache: 

542 journal_id = journal_id_cache[ct] 

543 else: 

544 journal_id = _resolve_journal_id(db_session, ct) 

545 journal_id_cache[ct] = journal_id 

546 

547 # Separate indexed columns from metadata blob. 

548 # Only doi/arxiv_id/pmid/journal_id/ 

549 # container_title/year are real columns on 

550 # Paper; everything else is bundled into 

551 # the metadata JSON blob. Quality is NOT 

552 # stored per-Paper — the dashboard resolves 

553 # it live (Tier 4: journals.quality via 

554 # container_title lookup; Tier 1-3: bundled 

555 # reference DB) so a re-scored journal 

556 # propagates automatically. 

557 # container_title: prefer the filter's 

558 # cleaned matched name (what actually keyed 

559 # the successful score); fall back to the raw 

560 # CSL container_title if the filter didn't 

561 # run (e.g. journal_reputation disabled). 

562 # 

563 # .pop() removes it from citation_fields so it 

564 # doesn't end up duplicated in paper_metadata 

565 # JSON. The Paper column is the sole source 

566 # of truth; CSL-JSON export already captured 

567 # the raw value inside citation_fields[ 

568 # "csl_json"] during normalize_citation. 

569 ct_raw = citation_fields.pop( 

570 "container_title", None 

571 ) 

572 ct_matched = ( 

573 source.get("journal_name_matched") or ct_raw 

574 ) 

575 # ``or None`` like its sibling above: an empty 

576 # container_title must stay NULL, or every 

577 # venue-less paper forms an empty-string group 

578 # in the journal metrics instead of being 

579 # excluded by ``isnot(None)``. 

580 ct_matched = _as_text(ct_matched) or None 

581 if ct_matched and len(ct_matched) > 500: 581 ↛ 582line 581 didn't jump to line 582 because the condition on line 581 was never true

582 logger.debug( 

583 f"Truncating container_title to 500 " 

584 f"chars: {ct_matched[:80]}..." 

585 ) 

586 ct_matched = ct_matched[:500] 

587 # `year` intentionally stays in citation_fields 

588 # (JSON blob) AND is copied to the indexed column. 

589 # The JSON blob remains the CSL-JSON source of 

590 # truth; the column is a denormalized index 

591 # surface for dashboard year queries. 

592 indexed = { 

593 "doi": citation_fields.pop("doi", None), 

594 "arxiv_id": citation_fields.pop( 

595 "arxiv_id", None 

596 ), 

597 "pmid": citation_fields.pop("pmid", None), 

598 "journal_id": journal_id, 

599 "container_title": ct_matched, 

600 "year": citation_fields.get("year"), 

601 } 

602 

603 # Dedup: find existing paper by DOI/arxiv/pmid. 

604 # The UNIQUE constraints on doi/arxiv_id/pmid 

605 # prevent duplicates from concurrent writers, 

606 # but we still need to handle the race where 

607 # our SELECT missed and another writer's 

608 # INSERT succeeds first — catch IntegrityError 

609 # and re-query. 

610 paper = _find_existing_paper(db_session, indexed) 

611 if paper is not None: 

612 _merge_identifiers( 

613 paper, indexed, citation_fields 

614 ) 

615 else: 

616 paper = Paper( 

617 **indexed, 

618 paper_metadata=citation_fields or None, 

619 ) 

620 db_session.add(paper) 

621 try: 

622 db_session.flush() 

623 except IntegrityError: 

624 # Concurrent writer inserted same 

625 # paper. Roll back this SAVEPOINT 

626 # only (not the whole batch), then 

627 # restart a nested one and re-fetch 

628 # the existing row for merging. 

629 sp.rollback() 

630 sp = db_session.begin_nested() 

631 # After savepoint rollback we also 

632 # need to re-create the resource 

633 # since its flush was undone. 

634 resource = ResearchResource( 

635 research_id=research_id, 

636 title=title or "Untitled", 

637 url=url, 

638 content_preview=snippet[:1000] 

639 if snippet 

640 else None, 

641 source_type=source_type, 

642 resource_metadata={ 

643 "added_at": datetime.now( 

644 UTC 

645 ).isoformat(), 

646 "original_data": safe_source, 

647 }, 

648 created_at=datetime.now( 

649 UTC 

650 ).isoformat(), 

651 ) 

652 db_session.add(resource) 

653 db_session.flush() 

654 paper = _find_existing_paper( 

655 db_session, indexed 

656 ) 

657 if paper is None: 

658 # Truly unexpected — concurrent 

659 # writer's row is gone. 

660 raise 

661 _merge_identifiers( 

662 paper, indexed, citation_fields 

663 ) 

664 

665 # Link paper to this resource 

666 appearance = PaperAppearance( 

667 paper_id=paper.id, 

668 resource_id=resource.id, 

669 source_engine=source_engine, 

670 ) 

671 db_session.add(appearance) 

672 

673 # Commit the savepoint so this source's writes 

674 # persist even if a later source fails. 

675 sp.commit() 

676 saved_count += 1 

677 

678 except Exception: 

679 # Roll back just this source's savepoint; earlier 

680 # sources in the batch stay committed at the 

681 # outer transaction level. 

682 # 

683 # Unconditional, NOT gated on ``sp.is_active``: a 

684 # failure inside flush() deactivates the savepoint, 

685 # so the guard skipped the rollback in exactly the 

686 # case that needs it. The Session then stayed in 

687 # its failed state, every later source raised 

688 # PendingRollbackError, and the final commit() 

689 # raised out of the function — one bad source lost 

690 # the whole batch and the caller got an exception 

691 # instead of a reduced count. 

692 if sp is not None: 692 ↛ 701line 692 didn't jump to line 701 because the condition on line 692 was always true

693 try: 

694 sp.rollback() 

695 except Exception: 

696 # Already unwound; nothing further to undo 

697 # for this source. 

698 logger.debug( 

699 "savepoint rollback was already done" 

700 ) 

701 failed_count += 1 

702 # ``_as_text``: the only unguarded read of url 

703 # left, and it runs when something has ALREADY 

704 # failed — an exception raised here escapes the 

705 # handler and loses the sibling sources too. 

706 logger.exception( 

707 "Failed to save source " 

708 + _as_text(source.get("url", "unknown")) 

709 ) 

710 continue 

711 

712 # Commit all resources 

713 if saved_count > 0: 

714 db_session.commit() 

715 if failed_count > 0: 

716 logger.warning( 

717 f"Saved {saved_count} sources for research " 

718 f"{research_id}{failed_count} source(s) " 

719 f"failed and were skipped (see earlier " 

720 f"ERROR logs for per-source stack traces)" 

721 ) 

722 else: 

723 logger.info( 

724 f"Saved {saved_count} sources for research {research_id}" 

725 ) 

726 elif failed_count > 0: 726 ↛ 727line 726 didn't jump to line 727 because the condition on line 726 was never true

727 logger.warning( 

728 f"No sources saved for research {research_id}" 

729 f"all {failed_count} sources in the batch failed " 

730 f"(see earlier ERROR logs for per-source stack " 

731 f"traces)" 

732 ) 

733 

734 except Exception: 

735 logger.exception("Error saving research sources") 

736 raise 

737 

738 return saved_count 

739 

740 @staticmethod 

741 def get_research_sources( 

742 research_id: str, username: Optional[str] = None 

743 ) -> List[Dict[str, Any]]: 

744 """ 

745 Get all sources for a research from the database. 

746 

747 Args: 

748 research_id: The UUID of the research 

749 username: Username for database access 

750 

751 Returns: 

752 List of source dictionaries 

753 """ 

754 sources = [] 

755 

756 try: 

757 with get_user_db_session(username) as db_session: 

758 resources = ( 

759 db_session.query(ResearchResource) 

760 .filter_by(research_id=research_id) 

761 .order_by(ResearchResource.id.asc()) 

762 .all() 

763 ) 

764 

765 for resource in resources: 

766 sources.append( 

767 { 

768 "id": resource.id, 

769 "url": resource.url, 

770 "title": resource.title, 

771 "snippet": resource.content_preview, 

772 "content_preview": resource.content_preview, 

773 "source_type": resource.source_type, 

774 "metadata": resource.resource_metadata or {}, 

775 "created_at": resource.created_at, 

776 } 

777 ) 

778 

779 logger.info( 

780 f"Retrieved {len(sources)} sources for research {research_id}" 

781 ) 

782 

783 except Exception: 

784 logger.exception("Error retrieving research sources") 

785 raise 

786 

787 return sources 

788 

789 @staticmethod 

790 def update_research_with_sources( 

791 research_id: str, 

792 all_links_of_system: List[Dict[str, Any]], 

793 username: Optional[str] = None, 

794 ) -> bool: 

795 """ 

796 Update a completed research with its sources. 

797 This should be called when research completes. 

798 

799 Args: 

800 research_id: The UUID of the research 

801 all_links_of_system: List of all sources found during research 

802 username: Username for database access 

803 

804 Returns: 

805 True if successful 

806 """ 

807 try: 

808 # Save sources to ResearchResource table 

809 saved_count = ResearchSourcesService.save_research_sources( 

810 research_id, all_links_of_system, username 

811 ) 

812 

813 # Also update the research metadata to include source count 

814 with get_user_db_session(username) as db_session: 

815 research = ( 

816 db_session.query(ResearchHistory) 

817 .filter_by(id=research_id) 

818 .first() 

819 ) 

820 

821 if research: 

822 if not research.research_meta: 822 ↛ 826line 822 didn't jump to line 826 because the condition on line 822 was always true

823 research.research_meta = {} 

824 

825 # Update metadata with source information 

826 research.research_meta["sources_count"] = saved_count 

827 research.research_meta["has_sources"] = saved_count > 0 

828 

829 db_session.commit() 

830 logger.info( 

831 f"Updated research {research_id} with {saved_count} sources" 

832 ) 

833 return True 

834 logger.warning( 

835 f"Research {research_id} not found for source update" 

836 ) 

837 return False 

838 

839 except Exception: 

840 logger.exception("Error updating research with sources") 

841 return False 

842 

843 

844def _json_safe(value: Any, _depth: int = 0, _seen: Optional[set] = None) -> Any: 

845 """Recursively coerce a value into a JSON-serializable form. 

846 

847 Used before embedding arbitrary engine result dicts into JSON 

848 columns. Non-primitive values (datetime, date, set, tuple, 

849 custom objects) are converted to strings or dropped. This is a 

850 last-resort sanitizer — callers should still prefer structured 

851 whitelisting (e.g., Paper.paper_metadata only stores known CSL 

852 fields). 

853 

854 Depth limit and cycle detection prevent RecursionError on 

855 pathological input (circular dict/list references). 

856 """ 

857 # Depth limit as a belt-and-braces guard 

858 if _depth > 32: 858 ↛ 859line 858 didn't jump to line 859 because the condition on line 858 was never true

859 return str(value) 

860 

861 # JSON primitives pass through unchanged 

862 if value is None or isinstance(value, (bool, int, float, str)): 

863 return value 

864 

865 # Container cycle detection via id() tracking 

866 if isinstance(value, (dict, list, tuple, set, frozenset)): 

867 if _seen is None: 

868 _seen = set() 

869 if id(value) in _seen: 869 ↛ 870line 869 didn't jump to line 870 because the condition on line 869 was never true

870 return "<circular>" 

871 _seen = _seen | {id(value)} 

872 

873 if isinstance(value, dict): 

874 return { 

875 str(k): _json_safe(v, _depth + 1, _seen) 

876 for k, v in value.items() 

877 if isinstance(k, (str, int, float, bool)) 

878 } 

879 if isinstance(value, (list, tuple, set, frozenset)): 

880 return [_json_safe(v, _depth + 1, _seen) for v in value] 

881 # datetime/date and anything else: coerce to string 

882 return str(value) 

883 

884 

885def _resolve_journal_id( 

886 db_session: Session, container_title: Optional[str] 

887) -> Optional[int]: 

888 """Look up a Journal record by name. Returns journal.id or None. 

889 

890 The journal reputation filter writes Journal rows using the 

891 cleaned journal name as returned by its regex cleanup (NFKC- 

892 normalized, whitespace-stripped, but NOT lowercased). We match 

893 against that by applying the same NFKC+strip normalization here 

894 and using a case-insensitive comparison so mismatched capitalization 

895 in the container_title doesn't break the lookup. 

896 """ 

897 if not container_title: 

898 return None 

899 import unicodedata 

900 

901 name_norm = unicodedata.normalize("NFKC", container_title).strip() 

902 # Query name_lower, not func.lower(name): expression-wrapping the 

903 # indexed column forces a full scan. 

904 row = ( 

905 db_session.query(Journal.id) 

906 .filter(Journal.name_lower == name_norm.lower()) 

907 .first() 

908 ) 

909 return row[0] if row else None 

910 

911 

912def _find_existing_paper( 

913 db_session: Session, fields: dict 

914) -> Optional["Paper"]: 

915 """Find an existing Paper by any of DOI, arXiv ID, or PMID. 

916 

917 Issues a single OR-query across all provided identifiers so that a 

918 caller with multiple IDs doesn't miss dedup because the first one 

919 (e.g. DOI) is absent from the stored row but a later one (e.g. 

920 arXiv) would have matched. The previous waterfall short-circuited 

921 on the first non-null input and never tried the remaining IDs. 

922 

923 Uses load_only to skip the ``paper_metadata`` JSON blob on the 

924 dedup lookup — we only need the identifier columns. The blob is 

925 lazy-loaded if the caller later touches ``paper.paper_metadata``. 

926 """ 

927 id_only = load_only( 

928 Paper.id, 

929 Paper.doi, 

930 Paper.arxiv_id, 

931 Paper.pmid, 

932 Paper.journal_id, 

933 ) 

934 

935 conditions = [] 

936 doi = fields.get("doi") 

937 arxiv_id = fields.get("arxiv_id") 

938 pmid = fields.get("pmid") 

939 if doi: 

940 conditions.append(Paper.doi == doi) 

941 if arxiv_id: 

942 conditions.append(Paper.arxiv_id == arxiv_id) 

943 if pmid: 

944 conditions.append(Paper.pmid == pmid) 

945 

946 if not conditions: 

947 return None 

948 

949 matches = ( 

950 db_session.query(Paper).options(id_only).filter(or_(*conditions)).all() 

951 ) 

952 

953 if not matches: 

954 return None 

955 if len(matches) == 1: 955 ↛ 961line 955 didn't jump to line 961 because the condition on line 955 was always true

956 return matches[0] 

957 

958 # Multiple distinct rows matched different identifiers of the same 

959 # incoming record. This indicates a prior mismerge; deterministic 

960 # tie-break on oldest (lowest) id so repeat runs don't oscillate. 

961 winner = min(matches, key=lambda p: p.id) 

962 logger.warning( 

963 f"Paper dedup conflict on {sorted(k for k in ('doi', 'arxiv_id', 'pmid') if fields.get(k))}: " 

964 f"{len(matches)} rows (ids {sorted(m.id for m in matches)}); " 

965 f"using id {winner.id}. Manual review recommended." 

966 ) 

967 return winner 

968 

969 

970def _merge_identifiers(paper: "Paper", indexed: dict, metadata: dict) -> None: 

971 """Enrich an existing Paper with identifiers from a new encounter. 

972 

973 E.g., an ArXiv paper later found via OpenAlex gains a DOI. 

974 

975 Args: 

976 paper: The existing Paper row to enrich. 

977 indexed: New values for the real columns (doi, arxiv_id, 

978 pmid, journal_id). Only applied if the column is 

979 currently empty. 

980 metadata: Additional bibliographic fields (pmcid, authors, 

981 csl_json, etc.) to merge into paper.paper_metadata. Only 

982 keys that aren't already present in the existing blob 

983 are added — first write wins, to preserve the original 

984 enrichment. 

985 """ 

986 # Indexed columns — first write wins. Avoids churning rows when 

987 # the same paper turns up across many research sessions with 

988 # slightly different scoring / cleaned names. 

989 if indexed.get("doi") and not paper.doi: 

990 paper.doi = indexed["doi"] 

991 if indexed.get("arxiv_id") and not paper.arxiv_id: 991 ↛ 992line 991 didn't jump to line 992 because the condition on line 991 was never true

992 paper.arxiv_id = indexed["arxiv_id"] 

993 if indexed.get("pmid") and not paper.pmid: 993 ↛ 994line 993 didn't jump to line 994 because the condition on line 993 was never true

994 paper.pmid = indexed["pmid"] 

995 if indexed.get("journal_id") and not paper.journal_id: 995 ↛ 996line 995 didn't jump to line 996 because the condition on line 995 was never true

996 paper.journal_id = indexed["journal_id"] 

997 if indexed.get("container_title") and not paper.container_title: 

998 paper.container_title = indexed["container_title"] 

999 if indexed.get("year") is not None and paper.year is None: 999 ↛ 1000line 999 didn't jump to line 1000 because the condition on line 999 was never true

1000 paper.year = indexed["year"] 

1001 

1002 # Metadata blob — merge any missing keys. 

1003 # IMPORTANT: we must build a NEW dict and reassign the attribute so 

1004 # that SQLAlchemy's plain JSON column marks it dirty. In-place 

1005 # mutation of the existing dict is not detected without 

1006 # MutableDict.as_mutable() — which this column does not use, to 

1007 # stay consistent with other JSON columns in the project. 

1008 if metadata: 

1009 existing = dict(paper.paper_metadata) if paper.paper_metadata else {} 

1010 changed = False 

1011 for key, value in metadata.items(): 

1012 if value is not None and key not in existing: 1012 ↛ 1013line 1012 didn't jump to line 1013 because the condition on line 1012 was never true

1013 existing[key] = value 

1014 changed = True 

1015 if changed: 1015 ↛ 1016line 1015 didn't jump to line 1016 because the condition on line 1015 was never true

1016 paper.paper_metadata = existing or None