Coverage for src/local_deep_research/journal_quality/db.py: 55%

669 statements  

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

1"""Read-only SQLAlchemy accessor for the compiled journal-quality DB. 

2 

3INVARIANT: this module's `JournalQualityDB` class **never writes**. 

4The runtime engine is opened with SQLite URI flags `mode=ro` and 

5`immutable=1`, the file is `chmod 0o444` after every build, and a 

6pre-commit hook bans cross-module opens of the file without `mode=ro`. 

7 

8The only writer is `build_db()` in this same module, which opens its 

9own short-lived writable engine, populates the schema, runs ANALYZE 

10+ VACUUM, closes the engine, and chmods the file back to 0o444. 

11 

12The DB compiles five gzipped JSON snapshots (downloaded by 

13`journal_quality.downloader`) into one queryable file: 

14 

15- OpenAlex sources → `sources` table (with predatory + DOAJ flags) 

16- Stop Predatory Journals → `predatory_journals/_publishers/_hijacked` 

17- DOAJ → cross-referenced into `sources` 

18- JabRef abbreviations → `abbreviations` table 

19- OpenAlex Institutions → `institutions` table 

20 

21Built fresh on every download, no migrations. 

22""" 

23 

24from __future__ import annotations 

25 

26import gzip 

27import json 

28import os 

29import secrets 

30import sqlite3 

31import sys 

32import threading 

33import time 

34from contextlib import closing, contextmanager 

35from pathlib import Path 

36from typing import Iterable, Iterator, Optional 

37 

38from loguru import logger 

39from sqlalchemy import create_engine, func, inspect, or_, select 

40from sqlalchemy.engine import Engine 

41from sqlalchemy.exc import DatabaseError, OperationalError 

42from sqlalchemy.orm import Session, sessionmaker 

43 

44from .models import ( 

45 Abbreviation, 

46 Institution, 

47 JournalQualityBase, 

48 PredatoryHijacked, 

49 PredatoryJournal, 

50 PredatoryPublisher, 

51 Source, 

52) 

53from ..constants import PREDATORY_WHITELIST_HINDEX 

54from ..utilities.citation_normalizer import normalize_issn 

55from ..utilities.sql_utils import escape_like 

56from .scoring import ( 

57 derive_quality_score, 

58 institution_score_from_h_index, 

59 normalize_name, 

60) 

61 

62DB_FILENAME = "journal_quality.db" 

63_BATCH_SIZE = 5000 

64 

65# Bump when the reference DB schema (models.py) changes in a way that 

66# requires a rebuild even if the upstream data version hasn't changed. 

67# Stamped as SQLite `PRAGMA user_version` during build_db; checked on 

68# _ensure_engine. Separate from JOURNAL_DATA_VERSION (downloader.py) 

69# which tracks upstream source-data freshness. 

70# v4: dropped Source.has_doaj_seal — DOAJ retired the Seal in April 2025. 

71JOURNAL_QUALITY_SCHEMA_VERSION = 4 

72 

73# Quality tier → score range, used by the dashboard tier filter. 

74_TIER_RANGES = { 

75 "elite": (9, 10), 

76 "strong": (7, 8), 

77 "moderate": (5, 6), 

78 "low": (3, 4), 

79 "predatory": (1, 2), 

80} 

81 

82# Columns safe to use in ORDER BY (prevents injection via the dashboard 

83# `sort` query parameter). 

84_SORT_COLUMNS = frozenset( 

85 { 

86 "name", 

87 "quality", 

88 "quartile", 

89 "h_index", 

90 "impact_factor", 

91 "score_source", 

92 "source_type", 

93 "publisher", 

94 "is_predatory", 

95 } 

96) 

97 

98# Max length for user-supplied search strings. Even with LIKE 

99# wildcards escaped, a 10 KB pattern against 217K rows is slow enough 

100# to matter for CPU budget under concurrent requests. 

101_MAX_SEARCH_LEN = 100 

102 

103 

104# --------------------------------------------------------------------------- 

105# Read-only accessor 

106# --------------------------------------------------------------------------- 

107 

108 

109class JournalQualityDB: 

110 """Read-only SQLAlchemy 2.0 accessor for `journal_quality.db`. 

111 

112 All filter hot-path methods return plain dicts (not mapped Source 

113 objects) so call sites in `journal_reputation_filter.py` keep the 

114 same call shape they had against the dict-based predecessor. The 

115 dashboard methods can return either dicts or Source instances — 

116 they're called once per page view so the ORM overhead is fine. 

117 """ 

118 

119 def __init__(self) -> None: 

120 self._engine: Optional[Engine] = None 

121 self._SessionLocal: Optional[sessionmaker[Session]] = None 

122 # RLock (not Lock): _ensure_engine holds the lock while calling 

123 # _build_or_raise → build_db → reset_db → self.reset(), which 

124 # re-acquires the same lock. A non-reentrant Lock would deadlock 

125 # the very first request on a fresh install. 

126 self._lock = threading.RLock() 

127 # Whether we've already logged a stale-data-version warning for 

128 # this engine lifetime. Prevents log spam — one WARNING per 

129 # server start is enough to surface the problem to admins. 

130 self._stale_version_warned = False 

131 

132 # --- engine + session lifecycle --- 

133 

134 def _resolve_db_path(self) -> Path: 

135 from ..config.paths import get_journal_data_directory 

136 

137 return get_journal_data_directory() / DB_FILENAME 

138 

139 def _ensure_engine(self) -> None: 

140 # Acquire lock BEFORE first read to avoid DCLP publication hazard. 

141 # With the GIL this is safe on CPython but explicit locking makes 

142 # the happens-before relationship clear and portable. 

143 with self._lock: 

144 if self._engine is not None: 

145 return 

146 path = self._resolve_db_path() 

147 if not path.exists(): 

148 self._build_or_raise(path) 

149 else: 

150 # Validate existing file before wiring up the read-only 

151 # engine. Catches two failure modes at open time instead 

152 # of letting them propagate to first query: 

153 # 1. Schema drift — ORM changed since this file was 

154 # built (PRAGMA user_version mismatch) → rebuild. 

155 # 2. Corruption — file exists but isn't a valid DB 

156 # (truncated build, disk error) → rebuild. 

157 if not self._validate_existing_db(path): 

158 self._build_or_raise(path) 

159 

160 # mode=ro + immutable=1: SQLite physically refuses writes, 

161 # skips locking entirely, and reads via mmap. The OS page 

162 # cache holds one shared resident copy of the hot pages. 

163 # 

164 # Use a creator callback because SQLAlchemy's URL parser 

165 # eats the ?mode=ro&immutable=1 query string before it can 

166 # reach sqlite3. The creator builds the connection directly 

167 # with the SQLite URI flags intact. 

168 def _make_ro_conn() -> sqlite3.Connection: 

169 return sqlite3.connect( 

170 f"file:{path}?mode=ro&immutable=1", 

171 uri=True, 

172 ) 

173 

174 # NullPool: each Session checks out an isolated read-only 

175 # connection that is closed when the session context exits. 

176 # With mode=ro&immutable=1, SQLite connection opens are 

177 # trivial (no lock overhead) and the OS page cache 

178 # shares data across connections. NullPool ensures that 

179 # concurrent filter_results threads never share a single 

180 # sqlite3.Connection handle (eliminating the cursor race condition 

181 # that caused sqlite3.InterfaceError / IndexError under StaticPool). 

182 from sqlalchemy.pool import NullPool 

183 

184 engine = create_engine( 

185 "sqlite://", 

186 creator=_make_ro_conn, 

187 poolclass=NullPool, 

188 echo=False, 

189 ) 

190 session_local = sessionmaker(bind=engine, expire_on_commit=False) 

191 # Publish both together so readers never see engine-without-session. 

192 self._engine = engine 

193 self._SessionLocal = session_local 

194 logger.info(f"Opened journal_quality.db (read-only): {path}") 

195 # One-shot check: is the DATA version (the one the sources 

196 # JSON + build logic produce) behind the bundled latest? 

197 # Schema drift is already handled by `_validate_existing_db` 

198 # via ``PRAGMA user_version``. A data-version mismatch is a 

199 # different concern: the DB schema is fine, but the scoring 

200 # logic (e.g. the repository cap) or source snapshots have 

201 # been updated since this file was built. The hot path 

202 # (filter scoring) would silently serve stale scores if we 

203 # didn't surface the mismatch anywhere but the admin 

204 # dashboard. Log once, don't auto-rebuild — user consent 

205 # via the dashboard "Download Data" button remains the 

206 # explicit refresh trigger. 

207 self._warn_on_stale_data_version(path.parent) 

208 

209 def _warn_on_stale_data_version(self, data_dir: Path) -> None: 

210 """Log WARNING once if ``version.json`` is behind ``JOURNAL_DATA_VERSION``.""" 

211 if self._stale_version_warned: 

212 return 

213 # Lazy import to avoid any downloader → db cycle even though 

214 # today's module graph doesn't have one. 

215 from .downloader import JOURNAL_DATA_VERSION 

216 

217 version_file = data_dir / "version.json" 

218 if not version_file.exists(): 

219 return # Brand-new install — the dashboard's banner handles this. 

220 try: 

221 with open(version_file, encoding="utf-8") as f: 

222 info = json.load(f) 

223 installed = info.get("version") 

224 except (json.JSONDecodeError, OSError): 

225 return # Malformed — dashboard's banner surfaces; don't double-log. 

226 if installed and installed != JOURNAL_DATA_VERSION: 

227 logger.warning( 

228 f"journal_quality data version is stale: on-disk={installed!r} " 

229 f"bundled-latest={JOURNAL_DATA_VERSION!r}. " 

230 f"Scoring is continuing with the older data. Visit " 

231 f"/metrics/journals and click 'Download Data' to refresh." 

232 ) 

233 self._stale_version_warned = True 

234 

235 def _validate_existing_db(self, path: Path) -> bool: 

236 """Return True if the existing DB file is usable as-is. 

237 

238 A version of 0 means the file was built before schema stamping 

239 existed and is grandfathered in — we don't force a rebuild just 

240 because the stamp is missing. A non-zero version that doesn't 

241 match the current schema is a real drift signal and triggers a 

242 rebuild. File-open errors also trigger a rebuild. 

243 """ 

244 from ..utilities.resource_utils import safe_close 

245 

246 conn: Optional[sqlite3.Connection] = None 

247 is_valid = False 

248 try: 

249 conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True) 

250 version = conn.execute("PRAGMA user_version").fetchone()[0] 

251 if version != 0 and version != JOURNAL_QUALITY_SCHEMA_VERSION: 

252 logger.warning( 

253 f"journal_quality.db schema_version={version}, " 

254 f"expected {JOURNAL_QUALITY_SCHEMA_VERSION}" 

255 f"rebuilding" 

256 ) 

257 else: 

258 # Cheap sanity check — confirms the file is a valid DB. 

259 conn.execute("SELECT 1 FROM sqlite_master LIMIT 1").fetchone() 

260 is_valid = True 

261 except (sqlite3.DatabaseError, OSError): 

262 logger.exception( 

263 f"journal_quality.db at {path} is unusable; rebuilding" 

264 ) 

265 finally: 

266 if conn is not None: 

267 safe_close(conn, "journal_quality validate") 

268 

269 if not is_valid: 

270 self._unlink_unusable_db(path) 

271 return is_valid 

272 

273 @staticmethod 

274 def _unlink_unusable_db(path: Path) -> None: 

275 """Best-effort cleanup of a corrupted / schema-drifted DB file. 

276 

277 Corruption was already logged by the caller (``_validate_existing_db``). 

278 Both operations below are best-effort — on failure we *log and 

279 continue* rather than raise, because the build path will rebuild 

280 the file regardless. But we don't silence: if chmod / unlink 

281 fails (permissions, read-only mount, file held open on Windows) 

282 the next build will likely also fail and the user needs the 

283 warning to diagnose the real problem. 

284 """ 

285 try: 

286 # bearer:disable python_lang_file_permissions 

287 os.chmod(path, 0o644) 

288 except OSError: 

289 logger.warning( 

290 f"Could not chmod 0644 on unusable DB {path} before " 

291 f"unlink (continuing to unlink attempt)" 

292 ) 

293 try: 

294 path.unlink() 

295 except OSError: 

296 logger.warning( 

297 f"Could not unlink unusable DB {path} (will be " 

298 f"overwritten on next build)" 

299 ) 

300 

301 def _build_or_raise(self, path: Path) -> None: 

302 """Lazy-build the DB on first access if it's missing. 

303 

304 Under the test suite's mock-only mode (``LDR_TESTING_WITH_MOCKS``, 

305 defaulted to true by ``tests/conftest.py``) this refuses to 

306 *download*. The method is reached implicitly — any read through 

307 ``_ensure_engine`` when the file is missing or fails validation — so 

308 a test that never intended to exercise the download path could 

309 otherwise trigger a live, multi-hundred-MB fetch from OpenAlex/DOAJ 

310 inside a request. ``timeout_method="thread"`` cannot interrupt a 

311 blocked socket, so the xdist worker dies instead of the test failing. 

312 

313 It gates ``auto_download`` rather than refusing outright, and the 

314 difference matters: ``ensure_journal_data`` returns immediately when 

315 the gz snapshots are already on disk, which is a purely local, 

316 network-free path. Refusing before that call would also block a 

317 developer or CI job that has the snapshots, turning a working offline 

318 build into a failure. With ``auto_download=False`` the local build 

319 still happens and only the network is off the table. 

320 

321 Tests that DO want the real download orchestration call 

322 ``download_journal_data()`` directly with the per-source fetchers 

323 mocked (see ``tests/journal_quality/test_downloader.py``), which this 

324 does not touch. 

325 """ 

326 from ..settings.env_definitions.testing import testing_with_mocks 

327 from .downloader import ensure_journal_data 

328 

329 mock_mode = testing_with_mocks() 

330 data_dir, available = ensure_journal_data(auto_download=not mock_mode) 

331 if not available: 331 ↛ 346line 331 didn't jump to line 346 because the condition on line 331 was always true

332 if mock_mode: 332 ↛ 341line 332 didn't jump to line 341 because the condition on line 332 was always true

333 raise FileNotFoundError( 

334 "Journal data files not available and " 

335 "LDR_TESTING_WITH_MOCKS=true — refusing to download " 

336 "from OpenAlex/DOAJ during a test run. Seed " 

337 "journal_quality.db directly, or place the gz " 

338 "snapshots in the data directory, if this test needs " 

339 "real data." 

340 ) 

341 raise FileNotFoundError( 

342 "Journal data files not available. " 

343 "Check your network connection or download manually " 

344 "from the dashboard." 

345 ) 

346 logger.info(f"Building {DB_FILENAME} from data files...") 

347 build_db(data_dir=data_dir, output_path=path) 

348 

349 @contextmanager 

350 def session(self) -> Iterator[Session]: 

351 """Yield a read-only SQLAlchemy session. 

352 

353 If the underlying file becomes corrupt mid-session (e.g. a 

354 rebuild ran and this engine is pointed at a now-unlinked inode), 

355 DatabaseError propagates but we drop the cached engine so the 

356 next call rebuilds cleanly instead of failing forever. 

357 """ 

358 self._ensure_engine() 

359 if self._SessionLocal is None: 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true

360 raise RuntimeError("JournalQualityDB engine failed to initialize") 

361 from ..utilities.resource_utils import safe_close 

362 

363 s = self._SessionLocal() 

364 try: 

365 yield s 

366 except (OperationalError, DatabaseError): 

367 logger.exception("journal_quality.db error — resetting engine") 

368 self.reset() 

369 raise 

370 finally: 

371 safe_close(s, "journal_quality session") 

372 

373 @property 

374 def available(self) -> bool: 

375 try: 

376 self._ensure_engine() 

377 return True 

378 except FileNotFoundError: 

379 return False 

380 

381 def reset(self) -> None: 

382 """Drop the cached engine — call after `build_db` rebuilds the file.""" 

383 with self._lock: 

384 if self._engine is not None: 

385 self._engine.dispose() 

386 self._engine = None 

387 self._SessionLocal = None 

388 logger.info("Reset journal_quality.db engine") 

389 

390 # --- filter hot path: return plain dicts --- 

391 

392 def lookup_openalex( 

393 self, 

394 *, 

395 source_id: Optional[str] = None, 

396 issn: Optional[str] = None, 

397 name: Optional[str] = None, 

398 ) -> Optional[dict]: 

399 """Look up a source by OpenAlex ID, ISSN, or name. 

400 

401 Returns a dict with the same shape the dict-based predecessor 

402 produced (`name`, `type`, `h_index`, `impact_factor`, 

403 `is_in_doaj`, `publisher`, `issn_l`) so the filter code at 

404 `journal_reputation_filter.py` doesn't need to change. 

405 """ 

406 issn = normalize_issn(issn) 

407 try: 

408 self._ensure_engine() 

409 except FileNotFoundError: 

410 return None 

411 with self.session() as s: 

412 row = self._lookup_source_row(s, source_id, issn, name) 

413 return _source_to_lookup_dict(row) if row else None 

414 

415 # Alias used by the dashboard / future call sites 

416 lookup_source = lookup_openalex 

417 

418 def count_predatory_by_names(self, names: Iterable[str]) -> int: 

419 """Count how many of the given journal names are flagged predatory. 

420 

421 One SQL round-trip using ``WHERE name_lower IN (…) AND is_predatory = TRUE``. 

422 Names are normalized (NFKC + lower + strip) so the caller can pass raw 

423 display names; matches the normalization used at build time. 

424 

425 Used by the per-user metrics dashboard to report a global "N predatory 

426 journals across all your research" stat without making N round trips 

427 to the reference DB. Returns 0 if the reference DB is missing or if 

428 ``names`` is empty. 

429 

430 .. note:: 

431 Deliberately unchunked. SQLite's ``SQLITE_MAX_VARIABLE_NUMBER`` 

432 has been 250,000 since SQLite 3.32 (2020); Python 3.11 ships 

433 with 3.45.1. A heavy user with 100k distinct container_titles 

434 is still well under the limit. Re-confirmed in the PR #3081 

435 audit — no chunking needed. 

436 """ 

437 normed = {normalize_name(n) for n in names if n} 

438 normed.discard("") 

439 if not normed: 

440 return 0 

441 try: 

442 self._ensure_engine() 

443 except FileNotFoundError: 

444 return 0 

445 with self.session() as s: 

446 stmt = select(func.count(Source.id)).where( 

447 Source.name_lower.in_(normed), 

448 Source.is_predatory.is_(True), 

449 ) 

450 result = s.execute(stmt).scalar() 

451 return int(result or 0) 

452 

453 def lookup_sources_batch(self, names: Iterable[str]) -> dict: 

454 """Batch-look-up multiple journal names in one query. 

455 

456 Takes an iterable of raw display names and returns a 

457 ``{normalized_name: dashboard_dict}`` map for every name that 

458 matched a Source. Names that didn't match are simply absent 

459 from the result (caller decides how to handle misses). 

460 

461 Dashboard hot path: the ``/api/journals/user-research`` endpoint 

462 collects up to 200 unique ``container_title`` values from the 

463 user's Papers and hands them in here — one SQL round-trip vs. 

464 200 per-row lookups. 

465 

466 Normalization matches ``normalize_name`` (NFKC + lower + strip) 

467 so the reference DB's ``name_lower`` column hits directly. No 

468 "the " / "proceedings of" fallback tiers — those live in 

469 ``_lookup_source_row`` for precision per-call; the batch path 

470 is for dashboard display where a miss is acceptable. 

471 

472 Chunked at 900 params per chunk. Defensive: SQLite's actual 

473 limit (``SQLITE_MAX_VARIABLE_NUMBER``) has been 250,000 since 

474 3.32 (2020) — we could easily put the whole batch in one IN — 

475 but 900 keeps us well under any older embedded-SQLite ceiling 

476 a deployment might pin to. 

477 """ 

478 normed = [normalize_name(n) for n in names if n] 

479 normed = [n for n in normed if n] 

480 if not normed: 480 ↛ 481line 480 didn't jump to line 481 because the condition on line 480 was never true

481 return {} 

482 try: 

483 self._ensure_engine() 

484 except FileNotFoundError: 

485 return {} 

486 # De-duplicate while preserving insertion order for stable 

487 # iteration in tests. 

488 seen: set = set() 

489 uniq: list = [] 

490 for n in normed: 

491 if n not in seen: 491 ↛ 490line 491 didn't jump to line 490 because the condition on line 491 was always true

492 seen.add(n) 

493 uniq.append(n) 

494 

495 out: dict = {} 

496 CHUNK = 900 

497 with self.session() as s: 

498 for i in range(0, len(uniq), CHUNK): 

499 batch = uniq[i : i + CHUNK] 

500 stmt = select(Source).where(Source.name_lower.in_(batch)) 

501 for row in s.scalars(stmt): 

502 out[row.name_lower] = _source_to_dashboard_dict(row) 

503 return out 

504 

505 def lookup_doaj(self, *, issn: Optional[str] = None) -> Optional[dict]: 

506 issn = normalize_issn(issn) 

507 if not issn: 

508 return None 

509 try: 

510 self._ensure_engine() 

511 except FileNotFoundError: 

512 return None 

513 with self.session() as s: 

514 stmt = ( 

515 select(Source) 

516 .where(Source.issn == issn, Source.is_in_doaj.is_(True)) 

517 .limit(1) 

518 ) 

519 row = s.scalars(stmt).first() 

520 if row is None: 

521 return None 

522 return { 

523 "name": row.name, 

524 "publisher": row.publisher, 

525 } 

526 

527 def is_in_doaj(self, issn: Optional[str]) -> bool: 

528 return self.lookup_doaj(issn=issn) is not None 

529 

530 def is_predatory( 

531 self, 

532 *, 

533 journal_name: Optional[str] = None, 

534 publisher_name: Optional[str] = None, 

535 ) -> tuple[bool, Optional[str]]: 

536 """Check if a journal/publisher is on the predatory list. 

537 

538 Looks up the dedicated predatory tables (NOT just the 

539 `is_predatory` flag on `Source`), so checks work for arbitrary 

540 input names that aren't in OpenAlex. 

541 """ 

542 try: 

543 self._ensure_engine() 

544 except FileNotFoundError: 

545 return False, None 

546 with self.session() as s: 

547 if journal_name: 547 ↛ 554line 547 didn't jump to line 554 because the condition on line 547 was always true

548 norm = normalize_name(journal_name) 

549 if s.get(PredatoryJournal, norm) is not None: 549 ↛ 550line 549 didn't jump to line 550 because the condition on line 549 was never true

550 return True, "stop-predatory-journals" 

551 if s.get(PredatoryHijacked, norm) is not None: 551 ↛ 552line 551 didn't jump to line 552 because the condition on line 551 was never true

552 return True, "stop-predatory-hijacked" 

553 

554 if publisher_name: 554 ↛ 555line 554 didn't jump to line 555 because the condition on line 554 was never true

555 pub_norm = normalize_name(publisher_name) 

556 if s.get(PredatoryPublisher, pub_norm) is not None: 

557 return True, "stop-predatory-publishers" 

558 # Substring scan over long entries (~1162 rows) 

559 stmt = select(PredatoryPublisher.name_lower).where( 

560 PredatoryPublisher.is_long.is_(True) 

561 ) 

562 for (entry,) in s.execute(stmt).all(): 

563 if pub_norm in entry or entry in pub_norm: 

564 return True, "stop-predatory-publishers" 

565 

566 return False, None 

567 

568 def is_whitelisted( 

569 self, 

570 *, 

571 issn: Optional[str] = None, 

572 name: Optional[str] = None, 

573 ) -> bool: 

574 if self.is_in_doaj(issn): 

575 return True 

576 oa = self.lookup_openalex(issn=issn, name=name) 

577 if oa and (oa.get("h_index") or 0) > PREDATORY_WHITELIST_HINDEX: 

578 return True 

579 return False 

580 

581 def lookup_institution( 

582 self, 

583 *, 

584 ror_id: Optional[str] = None, 

585 openalex_id: Optional[str] = None, 

586 name: Optional[str] = None, 

587 ) -> Optional[dict]: 

588 """Look up an institution. 

589 

590 Order: openalex_id → ror → name. Returns a dict with full-name 

591 keys (``name``, ``country``, ``type``, ``h_index``, 

592 ``impact_factor``, ``works_count``, ``cited_by_count``, ``ror_id``) 

593 or ``None`` if no match. The on-disk snapshot uses one-character 

594 keys (``n``, ``c``, etc.) for space efficiency; the accessor 

595 returns full names instead for legibility and schema robustness. 

596 """ 

597 try: 

598 self._ensure_engine() 

599 except FileNotFoundError: 

600 return None 

601 with self.session() as s: 

602 row: Optional[Institution] = None 

603 

604 if openalex_id: 

605 sid = openalex_id.split("/")[-1] 

606 row = s.get(Institution, sid) 

607 

608 if row is None and ror_id: 

609 ror = ror_id.rstrip("/").split("/")[-1] 

610 stmt = ( 

611 select(Institution) 

612 .where(Institution.ror_id == ror) 

613 .limit(1) 

614 ) 

615 row = s.scalars(stmt).first() 

616 

617 if row is None and name: 

618 norm = normalize_name(name) 

619 stmt = ( 

620 select(Institution) 

621 .where(Institution.name_lower == norm) 

622 .limit(1) 

623 ) 

624 row = s.scalars(stmt).first() 

625 

626 return _institution_to_dict(row) if row else None 

627 

628 def score_from_affiliations(self, affiliations: list) -> Optional[int]: 

629 """Derive a score from author affiliations in ONE SQL query.""" 

630 if not affiliations: 

631 return None 

632 

633 openalex_ids: list[str] = [] 

634 ror_ids: list[str] = [] 

635 names: list[str] = [] 

636 

637 for aff in affiliations: 

638 if isinstance(aff, str): 

639 names.append(normalize_name(aff)) 

640 elif isinstance(aff, dict): 

641 if oid := (aff.get("openalex_id") or aff.get("id")): 

642 openalex_ids.append(oid.split("/")[-1]) 

643 if rid := aff.get("ror"): 

644 ror_ids.append(rid.rstrip("/").split("/")[-1]) 

645 if nm := aff.get("name"): 

646 names.append(normalize_name(nm)) 

647 

648 if not (openalex_ids or ror_ids or names): 

649 return None 

650 

651 try: 

652 self._ensure_engine() 

653 except FileNotFoundError: 

654 return None 

655 

656 clauses = [] 

657 if openalex_ids: 

658 clauses.append(Institution.openalex_id.in_(openalex_ids)) 

659 if ror_ids: 

660 clauses.append(Institution.ror_id.in_(ror_ids)) 

661 if names: 

662 clauses.append(Institution.name_lower.in_(names)) 

663 

664 with self.session() as s: 

665 stmt = select(func.max(Institution.h_index)).where( 

666 or_(*clauses), Institution.h_index.is_not(None) 

667 ) 

668 best_h = s.scalar(stmt) 

669 

670 # Single source of truth for institution scoring lives in 

671 # scoring.py — delegate so the build phase, the runtime filter, 

672 # and this affiliation-salvage path can never disagree. 

673 return institution_score_from_h_index(best_h) 

674 

675 # Static passthrough so the filter can call dm.derive_quality_score(...) 

676 # without importing from .scoring directly. Single home for the 

677 # scoring rules in scoring.py. 

678 derive_quality_score = staticmethod(derive_quality_score) 

679 

680 def expand_abbreviation(self, name: str) -> Optional[str]: 

681 if not name: 

682 return None 

683 try: 

684 self._ensure_engine() 

685 except FileNotFoundError: 

686 return None 

687 normalized = normalize_name(name) 

688 with self.session() as s: 

689 row = s.get(Abbreviation, normalized) 

690 if row is not None: 690 ↛ 691line 690 didn't jump to line 691 because the condition on line 690 was never true

691 return row.full_name 

692 no_dots = normalized.replace(".", "").strip() 

693 if no_dots != normalized: 693 ↛ 694line 693 didn't jump to line 694 because the condition on line 693 was never true

694 row = s.get(Abbreviation, no_dots) 

695 if row is not None: 

696 return row.full_name 

697 return None 

698 

699 # --- internal source lookup with name fallbacks --- 

700 

701 def _lookup_source_row( 

702 self, 

703 s: Session, 

704 source_id: Optional[str], 

705 issn: Optional[str], 

706 name: Optional[str], 

707 ) -> Optional[Source]: 

708 if source_id: 708 ↛ 709line 708 didn't jump to line 709 because the condition on line 708 was never true

709 sid = source_id.split("/")[-1] if "/" in source_id else source_id 

710 stmt = ( 

711 select(Source).where(Source.openalex_source_id == sid).limit(1) 

712 ) 

713 row = s.scalars(stmt).first() 

714 if row is not None: 

715 return row 

716 

717 if issn: 

718 stmt = select(Source).where(Source.issn == issn).limit(1) 

719 row = s.scalars(stmt).first() 

720 if row is not None: 720 ↛ 723line 720 didn't jump to line 723 because the condition on line 720 was always true

721 return row 

722 

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

724 norm = normalize_name(name) 

725 row = self._fetch_by_name_lower(s, norm) 

726 if row is not None: 726 ↛ 727line 726 didn't jump to line 727 because the condition on line 726 was never true

727 return row 

728 # Try with/without "the " prefix (~5K journals have it) 

729 if norm.startswith("the "): 729 ↛ 730line 729 didn't jump to line 730 because the condition on line 729 was never true

730 row = self._fetch_by_name_lower(s, norm[4:]) 

731 else: 

732 row = self._fetch_by_name_lower(s, "the " + norm) 

733 if row is not None: 733 ↛ 734line 733 didn't jump to line 734 because the condition on line 733 was never true

734 return row 

735 # Strip "proceedings of (the) (conference on) " prefix 

736 stripped = norm 

737 for prefix in ( 

738 "proceedings of the conference on ", 

739 "proceedings of the ", 

740 "proceedings of ", 

741 ): 

742 if stripped.startswith(prefix): 742 ↛ 743line 742 didn't jump to line 743 because the condition on line 742 was never true

743 stripped = stripped[len(prefix) :] 

744 break 

745 if stripped != norm: 745 ↛ 746line 745 didn't jump to line 746 because the condition on line 745 was never true

746 row = self._fetch_by_name_lower(s, stripped) 

747 if row is not None: 

748 return row 

749 

750 # MEDLINE-style "Title : long subtitle" — try the segment 

751 # before the colon. Catches PubMed names like 

752 # "Molecular therapy : the journal of the American Society..." 

753 # → "Molecular therapy" 

754 if " : " in norm: 754 ↛ 755line 754 didn't jump to line 755 because the condition on line 754 was never true

755 head = norm.split(" : ", 1)[0].strip() 

756 if head and head != norm: 

757 row = self._fetch_by_name_lower(s, head) 

758 if row is not None: 

759 return row 

760 

761 # MEDLINE-style "Title. Section name" — try the segment 

762 # before the first period. Catches PubMed names like 

763 # "Molecular therapy. Methods and clinical development" 

764 # but only when the head is meaningfully shorter (we don't 

765 # want to match "Nat" from "Nat. Commun."). 

766 if "." in norm: 766 ↛ 767line 766 didn't jump to line 767 because the condition on line 766 was never true

767 head = norm.split(".", 1)[0].strip() 

768 if head and len(head) >= 6 and head != norm: 

769 row = self._fetch_by_name_lower(s, head) 

770 if row is not None: 

771 return row 

772 

773 return None 

774 

775 @staticmethod 

776 def _fetch_by_name_lower(s: Session, name_lower: str) -> Optional[Source]: 

777 stmt = select(Source).where(Source.name_lower == name_lower).limit(1) 

778 return s.scalars(stmt).first() 

779 

780 # --- dashboard queries --- 

781 

782 def get_summary(self) -> dict: 

783 if not self.available: 

784 return { 

785 "total": 0, 

786 "avg_quality": 0, 

787 "avg_h_index": None, 

788 "predatory_count": 0, 

789 "doaj_count": 0, 

790 "llm_count": 0, 

791 } 

792 

793 with self.session() as s: 

794 row = s.execute( 

795 select( 

796 func.count().label("total"), 

797 func.round(func.avg(Source.quality), 1).label( 

798 "avg_quality" 

799 ), 

800 func.round(func.avg(Source.h_index)).label("avg_h_index"), 

801 func.sum(func.iif(Source.is_predatory, 1, 0)).label( 

802 "predatory_count" 

803 ), 

804 func.sum(func.iif(Source.is_in_doaj, 1, 0)).label( 

805 "doaj_count" 

806 ), 

807 func.sum( 

808 func.iif(Source.score_source == "llm", 1, 0) 

809 ).label("llm_count"), 

810 ) 

811 ).first() 

812 return dict(row._mapping) if row else {} 

813 

814 def get_quality_distribution(self) -> dict[str, int]: 

815 if not self.available: 

816 return {} 

817 with self.session() as s: 

818 rows = s.execute( 

819 select(Source.quality, func.count().label("cnt")) 

820 .where(Source.quality.is_not(None)) 

821 .group_by(Source.quality) 

822 .order_by(Source.quality) 

823 ).all() 

824 return {str(q): c for q, c in rows} 

825 

826 def get_source_distribution(self) -> dict[str, int]: 

827 if not self.available: 

828 return {} 

829 with self.session() as s: 

830 rows = s.execute( 

831 select( 

832 func.coalesce(Source.score_source, "unknown").label("src"), 

833 func.count().label("cnt"), 

834 ).group_by(Source.score_source) 

835 ).all() 

836 return {row.src: row.cnt for row in rows} 

837 

838 def get_journals_page( 

839 self, 

840 *, 

841 page: int = 1, 

842 per_page: int = 50, 

843 search: str = "", 

844 tier: str = "", 

845 score_source: str = "", 

846 sort: str = "quality", 

847 order: str = "desc", 

848 ) -> tuple[list[dict], int]: 

849 if not self.available: 849 ↛ 850line 849 didn't jump to line 850 because the condition on line 849 was never true

850 return [], 0 

851 

852 if sort not in _SORT_COLUMNS: 

853 sort = "quality" 

854 if order not in ("asc", "desc"): 

855 order = "desc" 

856 

857 wheres: list = [] 

858 if search: 

859 needle = escape_like(normalize_name(search)[:_MAX_SEARCH_LEN]) 

860 wheres.append(Source.name_lower.like(f"%{needle}%", escape="\\")) 

861 if tier and tier in _TIER_RANGES: 861 ↛ 862line 861 didn't jump to line 862 because the condition on line 861 was never true

862 lo, hi = _TIER_RANGES[tier] 

863 wheres.append(Source.quality.between(lo, hi)) 

864 if score_source: 

865 wheres.append(Source.score_source == score_source) 

866 

867 sort_col = getattr(Source, sort) 

868 order_clause = ( 

869 sort_col.desc().nulls_last() 

870 if order == "desc" 

871 else sort_col.asc().nulls_last() 

872 ) 

873 

874 offset = (max(1, page) - 1) * per_page 

875 

876 with self.session() as s: 

877 total = ( 

878 s.scalar( 

879 select(func.count()).select_from(Source).where(*wheres) 

880 ) 

881 or 0 

882 ) 

883 rows = s.scalars( 

884 select(Source) 

885 .where(*wheres) 

886 .order_by(order_clause) 

887 .limit(per_page) 

888 .offset(offset) 

889 ).all() 

890 

891 return [_source_to_dashboard_dict(r) for r in rows], total 

892 

893 def get_institutions_page( 

894 self, 

895 *, 

896 page: int = 1, 

897 per_page: int = 50, 

898 search: str = "", 

899 sort: str = "h_index", 

900 order: str = "desc", 

901 ) -> tuple[list[dict], int]: 

902 if not self.available: 902 ↛ 903line 902 didn't jump to line 903 because the condition on line 902 was never true

903 return [], 0 

904 

905 # Defensive allowlist — matches the pattern in get_journals_page. 

906 # The ternary below is already safe (non-"desc" falls through to 

907 # .asc()), but the explicit check prevents future refactors from 

908 # accidentally interpolating a tainted value into SQL. 

909 if order not in ("asc", "desc"): 909 ↛ 910line 909 didn't jump to line 910 because the condition on line 909 was never true

910 order = "desc" 

911 

912 wheres = [] 

913 if search: 913 ↛ 919line 913 didn't jump to line 919 because the condition on line 913 was always true

914 needle = escape_like(normalize_name(search)[:_MAX_SEARCH_LEN]) 

915 wheres.append( 

916 Institution.name_lower.like(f"%{needle}%", escape="\\") 

917 ) 

918 

919 sort_col = ( 

920 Institution.h_index if sort == "h_index" else Institution.name 

921 ) 

922 order_clause = ( 

923 sort_col.desc().nulls_last() 

924 if order == "desc" 

925 else sort_col.asc().nulls_last() 

926 ) 

927 

928 offset = (max(1, page) - 1) * per_page 

929 

930 with self.session() as s: 

931 total = ( 

932 s.scalar( 

933 select(func.count()).select_from(Institution).where(*wheres) 

934 ) 

935 or 0 

936 ) 

937 rows = s.scalars( 

938 select(Institution) 

939 .where(*wheres) 

940 .order_by(order_clause) 

941 .limit(per_page) 

942 .offset(offset) 

943 ).all() 

944 

945 return [_institution_to_dashboard_dict(r) for r in rows], total 

946 

947 

948# --------------------------------------------------------------------------- 

949# Dict adapters — keep filter/dashboard call sites unchanged 

950# --------------------------------------------------------------------------- 

951 

952 

953def _source_to_lookup_dict(row: Source) -> dict: 

954 """Convert a Source row to the dict shape `lookup_openalex` produces. 

955 

956 Includes `openalex_source_id` so dashboard / test code can chain 

957 a follow-up `lookup_source(source_id=...)` call. Also exposes 

958 ``quartile`` so the filter can store it on the per-user Journal row 

959 and feed it into score derivation. 

960 """ 

961 return { 

962 "name": row.name, 

963 "type": row.source_type, 

964 "h_index": row.h_index, 

965 "impact_factor": row.impact_factor, 

966 "is_in_doaj": row.is_in_doaj, 

967 "publisher": row.publisher, 

968 "issn_l": row.issn, 

969 "openalex_source_id": row.openalex_source_id, 

970 "quartile": row.quartile, 

971 } 

972 

973 

974def _source_to_dashboard_dict(row: Source) -> dict: 

975 return { 

976 "name": row.name, 

977 "quality": row.quality, 

978 "quartile": row.quartile, 

979 "cited_by_count": row.cited_by_count, 

980 "h_index": row.h_index, 

981 "impact_factor": ( 

982 round(row.impact_factor, 2) if row.impact_factor else None 

983 ), 

984 "is_in_doaj": bool(row.is_in_doaj), 

985 "is_predatory": bool(row.is_predatory), 

986 "predatory_source": row.predatory_source, 

987 "score_source": row.score_source, 

988 "source_type": row.source_type, 

989 "publisher": row.publisher, 

990 "issn": row.issn, 

991 "openalex_source_id": row.openalex_source_id, 

992 } 

993 

994 

995def _institution_to_dict(row: Institution) -> dict: 

996 """Public accessor shape for `lookup_institution`. 

997 

998 The on-disk JSON snapshot uses one-character keys (``n``, ``c``, 

999 ``t``, …) purely for space efficiency — 200k institutions × seven 

1000 long field names adds real bytes. Callers of the accessor don't 

1001 care about on-disk layout, so here we return the full names to 

1002 keep the public API legible and robust to future schema tweaks. 

1003 """ 

1004 return { 

1005 "name": row.name, 

1006 "country": row.country, 

1007 "type": row.type, 

1008 "h_index": row.h_index, 

1009 "impact_factor": row.impact_factor, 

1010 "works_count": row.works_count, 

1011 "cited_by_count": row.cited_by_count, 

1012 "ror_id": row.ror_id, 

1013 } 

1014 

1015 

1016def _institution_to_dashboard_dict(row: Institution) -> dict: 

1017 return { 

1018 "openalex_id": row.openalex_id, 

1019 "name": row.name, 

1020 "ror_id": row.ror_id, 

1021 "country": row.country, 

1022 "type": row.type, 

1023 "h_index": row.h_index, 

1024 "impact_factor": row.impact_factor, 

1025 "works_count": row.works_count, 

1026 "cited_by_count": row.cited_by_count, 

1027 } 

1028 

1029 

1030# --------------------------------------------------------------------------- 

1031# Module singleton 

1032# --------------------------------------------------------------------------- 

1033 

1034 

1035_db: Optional[JournalQualityDB] = None 

1036_db_lock = threading.Lock() 

1037 

1038 

1039def get_db() -> JournalQualityDB: 

1040 """Get or create the singleton `JournalQualityDB`.""" 

1041 global _db 

1042 if _db is None: 

1043 with _db_lock: 

1044 if _db is None: 1044 ↛ 1046line 1044 didn't jump to line 1046

1045 _db = JournalQualityDB() 

1046 return _db 

1047 

1048 

1049# Backwards-compat aliases used by web/routers/metrics.py and a couple of tests 

1050get_journal_reference_db = get_db 

1051JournalReferenceDB = JournalQualityDB 

1052 

1053 

1054def reset_db() -> None: 

1055 """Reset the cached engine after a build_db rebuild. 

1056 

1057 Held under `_db_lock` so a concurrent `get_db()` call can't see a 

1058 half-disposed singleton — without the lock, Thread B could pass 

1059 the `if _db is None` check in `get_db()` while Thread A is still 

1060 inside `_db.reset()`, then call `_ensure_engine()` which short- 

1061 circuits on the still-set `_engine` and hands back a disposed 

1062 pool. The lock makes the read-then-reset pair atomic with respect 

1063 to `get_db()`'s lazy-init path. 

1064 """ 

1065 global _db 

1066 with _db_lock: 

1067 if _db is not None: 

1068 _db.reset() 

1069 

1070 

1071# --------------------------------------------------------------------------- 

1072# Build phase — the ONLY writer 

1073# --------------------------------------------------------------------------- 

1074 

1075 

1076def build_db( 

1077 data_dir: Optional[Path] = None, 

1078 output_path: Optional[Path] = None, 

1079) -> None: 

1080 """Compile `journal_quality.db` from the gzipped JSON sources. 

1081 

1082 Opens a SHORT-LIVED writable engine, creates the schema, populates 

1083 every table from the gz files, runs ANALYZE + VACUUM, closes the 

1084 engine, then `chmod 0o444` the file. 

1085 """ 

1086 start = time.time() 

1087 

1088 if data_dir is None: 

1089 from ..config.paths import get_journal_data_directory 

1090 

1091 data_dir = get_journal_data_directory() 

1092 if output_path is None: 

1093 output_path = data_dir / DB_FILENAME 

1094 

1095 logger.info( 

1096 "Building journal quality reference DB (one-time, " 

1097 "~30s, decompresses ~25 MB of bundled data)…" 

1098 ) 

1099 

1100 # Sweep stale temp files from prior crashed builds so they don't 

1101 # accumulate. Any .tmp-* older than 1h is assumed dead. 

1102 _sweep_stale_tmp_files(output_path.parent, output_path.name) 

1103 

1104 # Build into a unique temp path, then os.replace() atomically at 

1105 # the end. A random suffix (not a fixed .tmp) lets concurrent 

1106 # builders write to separate files instead of racing on the same 

1107 # path — os.replace picks a winner atomically and neither corrupts 

1108 # the live file. 

1109 tmp_path = output_path.with_name( 

1110 f"{output_path.name}.tmp-{os.getpid()}-{secrets.token_hex(4)}" 

1111 ) 

1112 

1113 write_url = f"sqlite:///{tmp_path}" 

1114 engine = create_engine(write_url, connect_args={"check_same_thread": False}) 

1115 

1116 try: 

1117 # Pragmas for fast bulk insert. `journal_mode = OFF` plus 

1118 # `synchronous = OFF` is deliberately unsafe for general use but 

1119 # correct here because durability is guaranteed by the temp-file 

1120 # + os.replace() pattern around this block: we write to a unique 

1121 # `.tmp-PID-RAND` path, and on any crash mid-build the incomplete 

1122 # temp file is orphaned (and swept by `_sweep_stale_tmp_files()` 

1123 # on the next build). The live file is only ever moved into place 

1124 # by the atomic `os.replace()` at the bottom of this function — 

1125 # it never sees a partial write. Do NOT copy this pragma set 

1126 # elsewhere without the same atomic rename discipline. 

1127 with engine.connect() as conn: 

1128 conn.exec_driver_sql("PRAGMA journal_mode = OFF") 

1129 conn.exec_driver_sql("PRAGMA synchronous = OFF") 

1130 conn.exec_driver_sql("PRAGMA cache_size = -64000") 

1131 conn.exec_driver_sql("PRAGMA page_size = 4096") 

1132 

1133 JournalQualityBase.metadata.create_all(engine) 

1134 

1135 SessionWrite = sessionmaker(bind=engine) 

1136 with SessionWrite() as session: 

1137 sources = _load_openalex(data_dir) 

1138 doaj_data = _load_doaj(data_dir) 

1139 pred_data = _load_predatory(data_dir) 

1140 institutions = _load_institutions(data_dir) 

1141 abbreviations = _load_abbreviations(data_dir) 

1142 

1143 _populate_predatory(session, pred_data) 

1144 _populate_sources(session, sources, doaj_data, pred_data) 

1145 _populate_institutions(session, institutions) 

1146 _populate_abbreviations(session, abbreviations) 

1147 session.commit() 

1148 

1149 with engine.connect() as conn: 

1150 conn.exec_driver_sql("ANALYZE") 

1151 conn.exec_driver_sql("VACUUM") 

1152 # Stamp schema version so _ensure_engine can detect drift 

1153 # without depending on the external version.json. 

1154 conn.exec_driver_sql( 

1155 f"PRAGMA user_version = {JOURNAL_QUALITY_SCHEMA_VERSION}" 

1156 ) 

1157 except Exception: 

1158 engine.dispose() 

1159 if tmp_path.exists(): 

1160 try: 

1161 # bearer:disable python_lang_file_permissions 

1162 os.chmod(tmp_path, 0o644) 

1163 tmp_path.unlink() 

1164 except OSError: 

1165 logger.exception(f"Failed to clean up tmp DB at {tmp_path}") 

1166 raise 

1167 

1168 engine.dispose() 

1169 

1170 # Atomically swap tmp into place. os.replace is atomic on POSIX and 

1171 # overwrites an existing output_path if present. 

1172 if output_path.exists(): 

1173 # Prior file is chmod 0444 from the previous build — relax it 

1174 # so os.replace can overwrite. Best-effort: if chmod fails 

1175 # (e.g. read-only mount), os.replace will raise and surface 

1176 # the real problem. Log so the cause is visible. 

1177 try: 

1178 # bearer:disable python_lang_file_permissions 

1179 os.chmod(output_path, 0o644) 

1180 except OSError: 

1181 logger.warning( 

1182 f"Could not chmod 0644 on existing {output_path} before " 

1183 f"os.replace; if the replace fails this is likely why" 

1184 ) 

1185 os.replace(tmp_path, output_path) 

1186 

1187 # OS-level read-only flag — third layer of write protection. 

1188 # POSIX chmod is a no-op on Windows, so we also set the Windows 

1189 # read-only file attribute via SetFileAttributesW. The pre-commit 

1190 # hook check-journal-quality-readonly.py remains the primary 

1191 # defense against accidental writable opens. 

1192 # bearer:disable python_lang_file_permissions 

1193 os.chmod(output_path, 0o444) 

1194 if sys.platform == "win32": 

1195 try: 

1196 import ctypes 

1197 

1198 # FILE_ATTRIBUTE_READONLY = 0x1 

1199 ok = ctypes.windll.kernel32.SetFileAttributesW( 

1200 str(output_path), 0x1 

1201 ) 

1202 if not ok: 

1203 logger.warning( 

1204 f"SetFileAttributesW failed on {output_path.name}; " 

1205 "readonly pre-commit hook is the sole defense." 

1206 ) 

1207 except Exception: 

1208 logger.warning( 

1209 f"Could not set Windows readonly attribute on " 

1210 f"{output_path.name}" 

1211 ) 

1212 

1213 elapsed = time.time() - start 

1214 size_mb = output_path.stat().st_size / (1024 * 1024) 

1215 with closing( 

1216 sqlite3.connect(f"file:{output_path}?mode=ro&immutable=1", uri=True) 

1217 ) as _count_conn: 

1218 source_count = _count_conn.execute( 

1219 "SELECT COUNT(*) FROM sources" 

1220 ).fetchone()[0] 

1221 logger.info( 

1222 f"Journal quality DB ready: {source_count} sources, " 

1223 f"{size_mb:.1f} MB in {elapsed:.1f}s ({output_path.name}, chmod 0o444)" 

1224 ) 

1225 

1226 reset_db() 

1227 

1228 

1229def _sweep_stale_tmp_files(directory: Path, base_name: str) -> None: 

1230 """Remove journal_quality.db.tmp-* files older than 1h. 

1231 

1232 Per-file OSError (vanished between glob+stat, no permission, etc.) 

1233 is logged at debug — the sweep is best-effort and shouldn't stop 

1234 the build, but silent-pass on filesystem errors hides the cause of 

1235 accumulating stale tmp files that would otherwise eat disk over 

1236 time. 

1237 """ 

1238 if not directory.exists(): 

1239 return 

1240 cutoff = time.time() - 3600 

1241 for tmp in directory.glob(f"{base_name}.tmp-*"): 

1242 try: 

1243 if tmp.stat().st_mtime < cutoff: 

1244 tmp.unlink() 

1245 logger.info(f"Swept stale temp build file: {tmp.name}") 

1246 except OSError: 

1247 logger.debug(f"Could not sweep stale tmp file {tmp.name}") 

1248 

1249 

1250# --------------------------------------------------------------------------- 

1251# Source-data loaders (used by build_db only) 

1252# --------------------------------------------------------------------------- 

1253 

1254 

1255def _load_openalex(data_dir: Path) -> dict: 

1256 path = data_dir / "openalex_sources.json.gz" 

1257 if not path.exists(): 

1258 raise FileNotFoundError(f"OpenAlex source file not found: {path}") 

1259 with gzip.open(path, "rt", encoding="utf-8") as f: 

1260 data = json.load(f) 

1261 sources = data.get("s", data.get("sources", {})) 

1262 logger.info(f"Loaded {len(sources)} OpenAlex sources") 

1263 return dict(sources) 

1264 

1265 

1266def _load_doaj(data_dir: Path) -> dict: 

1267 path = data_dir / "doaj_journals.json" 

1268 if not path.exists(): 

1269 logger.warning(f"{path} not found — DOAJ cross-ref will be skipped") 

1270 return {} 

1271 with open(path, encoding="utf-8") as f: 

1272 data = json.load(f) 

1273 journals = data.get("journals", {}) 

1274 logger.info(f"Loaded {len(journals)} DOAJ entries") 

1275 return dict(journals) 

1276 

1277 

1278def _load_predatory(data_dir: Path) -> dict: 

1279 """Returns {journals: set, publishers: set, hijacked: set, long_pubs: list}.""" 

1280 path = data_dir / "predatory.json" 

1281 if not path.exists(): 

1282 logger.warning(f"{path} not found — predatory check will be skipped") 

1283 return { 

1284 "journals": set(), 

1285 "publishers": set(), 

1286 "hijacked": set(), 

1287 "long_pubs": [], 

1288 } 

1289 

1290 with open(path, encoding="utf-8") as f: 

1291 data = json.load(f) 

1292 

1293 journal_names = { 

1294 normalize_name(e.get("name", "")) 

1295 for e in data.get("journals", []) 

1296 if e.get("name", "").strip() 

1297 } 

1298 publisher_names = { 

1299 normalize_name(e.get("name", "")) 

1300 for e in data.get("publishers", []) 

1301 if e.get("name", "").strip() 

1302 } 

1303 hijacked_names = { 

1304 normalize_name(e.get("hijacked_name", "")) 

1305 for e in data.get("hijacked", []) 

1306 if e.get("hijacked_name", "").strip() 

1307 } 

1308 long_pubs = [ 

1309 normalize_name(e.get("name", "")) 

1310 for e in data.get("publishers", []) 

1311 if len(e.get("name", "").strip()) >= 10 

1312 ] 

1313 logger.info( 

1314 f"Loaded predatory: {len(journal_names)} journals, " 

1315 f"{len(publisher_names)} publishers, " 

1316 f"{len(hijacked_names)} hijacked" 

1317 ) 

1318 return { 

1319 "journals": journal_names, 

1320 "publishers": publisher_names, 

1321 "hijacked": hijacked_names, 

1322 "long_pubs": long_pubs, 

1323 } 

1324 

1325 

1326def _load_institutions(data_dir: Path) -> dict: 

1327 path = data_dir / "openalex_institutions.json.gz" 

1328 if not path.exists(): 

1329 logger.warning(f"{path} not found — institution tier will be empty") 

1330 return {} 

1331 with gzip.open(path, "rt", encoding="utf-8") as f: 

1332 data = json.load(f) 

1333 institutions = data.get("i", {}) 

1334 logger.info(f"Loaded {len(institutions)} institutions") 

1335 return dict(institutions) 

1336 

1337 

1338def _load_abbreviations(data_dir: Path) -> dict: 

1339 path = data_dir / "jabref_abbreviations.json.gz" 

1340 if not path.exists(): 

1341 logger.warning( 

1342 f"{path} not found — abbreviation expansion will be empty" 

1343 ) 

1344 return {} 

1345 with gzip.open(path, "rt", encoding="utf-8") as f: 

1346 data = json.load(f) 

1347 mappings = data.get("abbrev_to_full", {}) 

1348 logger.info(f"Loaded {len(mappings)} abbreviation mappings") 

1349 return dict(mappings) 

1350 

1351 

1352# --------------------------------------------------------------------------- 

1353# Table populators 

1354# --------------------------------------------------------------------------- 

1355 

1356 

1357def _populate_predatory(session: Session, pred: dict) -> None: 

1358 long_set = set(pred.get("long_pubs", [])) 

1359 

1360 journals = [{"name_lower": n} for n in pred.get("journals", set()) if n] 

1361 if journals: 

1362 session.bulk_insert_mappings(inspect(PredatoryJournal), journals) 

1363 

1364 hijacked = [{"name_lower": n} for n in pred.get("hijacked", set()) if n] 

1365 if hijacked: 

1366 session.bulk_insert_mappings(inspect(PredatoryHijacked), hijacked) 

1367 

1368 publishers = [ 

1369 {"name_lower": n, "is_long": n in long_set} 

1370 for n in pred.get("publishers", set()) 

1371 if n 

1372 ] 

1373 if publishers: 

1374 session.bulk_insert_mappings(inspect(PredatoryPublisher), publishers) 

1375 

1376 logger.info( 

1377 f"Inserted predatory tables: " 

1378 f"{len(journals)} journals, " 

1379 f"{len(publishers)} publishers, " 

1380 f"{len(hijacked)} hijacked" 

1381 ) 

1382 

1383 

1384def _populate_sources( 

1385 session: Session, 

1386 sources: dict, 

1387 doaj_data: dict, 

1388 pred: dict, 

1389) -> None: 

1390 """Build Source rows with cross-referenced DOAJ + predatory flags.""" 

1391 type_map = {"j": "journal", "c": "conference"} 

1392 pred_journals = pred.get("journals", set()) 

1393 pred_publishers = pred.get("publishers", set()) 

1394 pred_hijacked = pred.get("hijacked", set()) 

1395 

1396 # Dedup key is (name_lower, issn or "") so journals with separate 

1397 # print and electronic ISSNs in OpenAlex both survive instead of 

1398 # collapsing onto one row. 

1399 seen: dict[tuple[str, str], dict] = {} 

1400 

1401 for source_id, compact in sources.items(): 

1402 name = (compact.get("n") or "").strip() 

1403 if not name: 1403 ↛ 1404line 1403 didn't jump to line 1404 because the condition on line 1403 was never true

1404 continue 

1405 

1406 name_lower = normalize_name(name) 

1407 issn = normalize_issn(compact.get("i")) 

1408 publisher = compact.get("p") or None 

1409 h_index = compact.get("h") 

1410 impact_factor = compact.get("if") 

1411 cited_by_count = compact.get("cb") 

1412 source_type = type_map.get(compact.get("t", ""), compact.get("t", "")) 

1413 

1414 doaj_entry = doaj_data.get(issn) if issn else None 

1415 is_in_doaj = doaj_entry is not None 

1416 

1417 is_pred = name_lower in pred_journals 

1418 pred_source = "stop-predatory-journals" if is_pred else None 

1419 if not is_pred and publisher: 

1420 pub_norm = normalize_name(publisher) 

1421 if pub_norm in pred_publishers: 1421 ↛ 1422line 1421 didn't jump to line 1422 because the condition on line 1421 was never true

1422 is_pred = True 

1423 pred_source = "stop-predatory-publishers" 

1424 if not is_pred and name_lower in pred_hijacked: 

1425 is_pred = True 

1426 pred_source = "stop-predatory-hijacked" 

1427 

1428 # Whitelist override 

1429 if is_pred and ( 1429 ↛ 1432line 1429 didn't jump to line 1432 because the condition on line 1429 was never true

1430 is_in_doaj or (h_index or 0) > PREDATORY_WHITELIST_HINDEX 

1431 ): 

1432 is_pred = False 

1433 pred_source = None 

1434 

1435 quality = derive_quality_score( 

1436 h_index=h_index, 

1437 is_in_doaj=is_in_doaj, 

1438 is_predatory=is_pred, 

1439 source_type=source_type, 

1440 ) 

1441 

1442 rec = { 

1443 "name": name, 

1444 "name_lower": name_lower, 

1445 "issn": issn, 

1446 "openalex_source_id": source_id, 

1447 "source_type": source_type, 

1448 "publisher": publisher, 

1449 "h_index": h_index, 

1450 "impact_factor": impact_factor, 

1451 "cited_by_count": cited_by_count, 

1452 "quartile": None, # filled in by the post-pass below 

1453 "quality": quality, 

1454 "is_in_doaj": is_in_doaj, 

1455 "is_predatory": is_pred, 

1456 "predatory_source": pred_source, 

1457 "score_source": "openalex", 

1458 } 

1459 

1460 key = (name_lower, issn or "") 

1461 prev = seen.get(key) 

1462 if prev is None or (h_index or 0) > (prev.get("h_index") or 0): 1462 ↛ 1401line 1462 didn't jump to line 1401 because the condition on line 1462 was always true

1463 seen[key] = rec 

1464 

1465 # Second pass: DOAJ-only journals (not in OpenAlex). Without this 

1466 # we lose ~4-7K small open-access venues. Keyed by name_lower so 

1467 # we don't double-insert anything OpenAlex already covered. 

1468 openalex_names = {k[0] for k in seen.keys()} 

1469 doaj_added = 0 

1470 for issn, doaj_entry in doaj_data.items(): 

1471 name = (doaj_entry.get("name") or "").strip() 

1472 if not name: 1472 ↛ 1473line 1472 didn't jump to line 1473 because the condition on line 1472 was never true

1473 continue 

1474 name_lower = normalize_name(name) 

1475 if name_lower in openalex_names: 

1476 continue 

1477 publisher = doaj_entry.get("publisher") or None 

1478 quality = derive_quality_score( 

1479 h_index=None, 

1480 is_in_doaj=True, 

1481 is_predatory=False, 

1482 source_type="journal", 

1483 ) 

1484 seen[(name_lower, issn or "")] = { 

1485 "name": name, 

1486 "name_lower": name_lower, 

1487 "issn": issn, 

1488 "openalex_source_id": None, 

1489 "source_type": "journal", 

1490 "publisher": publisher, 

1491 "h_index": None, 

1492 "impact_factor": None, 

1493 "cited_by_count": None, 

1494 "quartile": None, 

1495 "quality": quality, 

1496 "is_in_doaj": True, 

1497 "is_predatory": False, 

1498 "predatory_source": None, 

1499 "score_source": "doaj", 

1500 } 

1501 openalex_names.add(name_lower) 

1502 doaj_added += 1 

1503 

1504 records = list(seen.values()) 

1505 

1506 # Derive quartile (Q1–Q4) from cited_by_count percentile within each 

1507 # source_type. Field-specific quartiles would be more accurate but 

1508 # require per-source topic data that 4–7×s the snapshot size, so we 

1509 # use global per-type percentiles as a defensible approximation 

1510 # given the license constraint that ruled out SJR. 

1511 by_type: dict[str, list[dict]] = {} 

1512 for r in records: 

1513 if r.get("cited_by_count") is None: 

1514 continue # NULL quartile for entries without citation data 

1515 by_type.setdefault(r.get("source_type") or "", []).append(r) 

1516 for type_records in by_type.values(): 

1517 type_records.sort(key=lambda r: r["cited_by_count"]) 

1518 n = len(type_records) 

1519 if n == 0: 1519 ↛ 1520line 1519 didn't jump to line 1520 because the condition on line 1519 was never true

1520 continue 

1521 for rank, r in enumerate(type_records): 

1522 pct = rank / n # 0.0 = lowest, ~1.0 = highest 

1523 if pct >= 0.75: 

1524 r["quartile"] = "Q1" 

1525 elif pct >= 0.50: 

1526 r["quartile"] = "Q2" 

1527 elif pct >= 0.25: 

1528 r["quartile"] = "Q3" 

1529 else: 

1530 r["quartile"] = "Q4" 

1531 

1532 # Re-derive quality now that quartile is available. The first-pass 

1533 # `quality` values above were computed without quartile and are 

1534 # therefore suboptimal — a Q1 journal without h-index data would 

1535 # have scored `None` (fall-through) instead of 8. The runtime filter 

1536 # code in journal_reputation_filter.py does pass quartile, so the 

1537 # stored column should agree with the live score. 

1538 for r in records: 

1539 r["quality"] = derive_quality_score( 

1540 h_index=r.get("h_index"), 

1541 quartile=r.get("quartile"), 

1542 is_in_doaj=r.get("is_in_doaj") or False, 

1543 is_predatory=r.get("is_predatory") or False, 

1544 source_type=r.get("source_type"), 

1545 ) 

1546 

1547 logger.info( 

1548 f"Inserting {len(records)} source records ({doaj_added} DOAJ-only)..." 

1549 ) 

1550 for i in range(0, len(records), _BATCH_SIZE): 

1551 session.bulk_insert_mappings( 

1552 inspect(Source), records[i : i + _BATCH_SIZE] 

1553 ) 

1554 

1555 

1556def _populate_institutions(session: Session, institutions: dict) -> None: 

1557 records: list[dict] = [] 

1558 for inst_id, compact in institutions.items(): 

1559 name = (compact.get("n") or "").strip() 

1560 if not name: 1560 ↛ 1561line 1560 didn't jump to line 1561 because the condition on line 1560 was never true

1561 continue 

1562 records.append( 

1563 { 

1564 "openalex_id": inst_id, 

1565 "name": name, 

1566 "name_lower": normalize_name(name), 

1567 "ror_id": compact.get("r"), 

1568 "country": compact.get("c"), 

1569 "type": compact.get("t"), 

1570 "h_index": compact.get("h"), 

1571 "impact_factor": compact.get("if"), 

1572 "works_count": compact.get("w"), 

1573 "cited_by_count": compact.get("cb"), 

1574 } 

1575 ) 

1576 logger.info(f"Inserting {len(records)} institution records...") 

1577 for i in range(0, len(records), _BATCH_SIZE): 

1578 session.bulk_insert_mappings( 

1579 inspect(Institution), records[i : i + _BATCH_SIZE] 

1580 ) 

1581 

1582 

1583def _populate_abbreviations(session: Session, mappings: dict) -> None: 

1584 records: list[dict] = [] 

1585 seen: set[str] = set() 

1586 for abbrev, full in mappings.items(): 

1587 norm = normalize_name(abbrev) 

1588 if not norm or norm in seen: 

1589 continue 

1590 seen.add(norm) 

1591 records.append({"abbrev_lower": norm, "full_name": full}) 

1592 logger.info(f"Inserting {len(records)} abbreviation records...") 

1593 for i in range(0, len(records), _BATCH_SIZE): 

1594 session.bulk_insert_mappings( 

1595 inspect(Abbreviation), records[i : i + _BATCH_SIZE] 

1596 ) 

1597 

1598 

1599# --------------------------------------------------------------------------- 

1600# Backwards-compat shim for the old build_reference_db name 

1601# --------------------------------------------------------------------------- 

1602 

1603 

1604def build_reference_db( 

1605 data_dir: Optional[Path] = None, 

1606 output_path: Optional[Path] = None, 

1607) -> None: 

1608 """Deprecated alias for `build_db`.""" 

1609 build_db(data_dir=data_dir, output_path=output_path)