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

747 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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 math 

29import os 

30import secrets 

31import sqlite3 

32import sys 

33import threading 

34import time 

35from contextlib import closing, contextmanager 

36from pathlib import Path, PurePath 

37from typing import Iterable, Iterator, Optional 

38from urllib.parse import quote 

39 

40from loguru import logger 

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

42from sqlalchemy.engine import Engine 

43from sqlalchemy.exc import DatabaseError, OperationalError 

44from sqlalchemy.orm import Session, sessionmaker 

45 

46from .models import ( 

47 Abbreviation, 

48 Institution, 

49 JournalQualityBase, 

50 PredatoryHijacked, 

51 PredatoryJournal, 

52 PredatoryPublisher, 

53 Source, 

54) 

55from ..constants import PREDATORY_WHITELIST_HINDEX 

56from ..utilities.citation_normalizer import normalize_issn 

57from ..utilities.sql_utils import escape_like 

58from .scoring import ( 

59 derive_quality_score, 

60 institution_score_from_h_index, 

61 normalize_name, 

62) 

63 

64DB_FILENAME = "journal_quality.db" 

65_BATCH_SIZE = 5000 

66 

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

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

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

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

71# which tracks upstream source-data freshness. 

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

73JOURNAL_QUALITY_SCHEMA_VERSION = 4 

74 

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

76_TIER_RANGES = { 

77 "elite": (9, 10), 

78 "strong": (7, 8), 

79 "moderate": (5, 6), 

80 "low": (3, 4), 

81 "predatory": (1, 2), 

82} 

83 

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

85# `sort` query parameter). 

86_SORT_COLUMNS = frozenset( 

87 { 

88 "name", 

89 "quality", 

90 "quartile", 

91 "h_index", 

92 "impact_factor", 

93 "score_source", 

94 "source_type", 

95 "publisher", 

96 "is_predatory", 

97 } 

98) 

99 

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

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

102# to matter for CPU budget under concurrent requests. 

103_MAX_SEARCH_LEN = 100 

104 

105 

106def sqlite_readonly_uri(path: PurePath, *, immutable: bool = False) -> str: 

107 """Build a ``mode=ro`` SQLite URI for an absolute ``path``. 

108 

109 With ``immutable=True`` the query string is ``mode=ro&immutable=1`` 

110 (the runtime engine's flags). Every SQLite URI this package opens 

111 goes through this one helper so that no call site interpolates a raw 

112 path into a ``file:`` URI. 

113 

114 The URI always has an EMPTY authority (``file://`` + path), because 

115 SQLite rejects any authority other than empty or ``localhost`` with 

116 "invalid uri authority" unless it was compiled with 

117 ``SQLITE_ALLOW_URI_AUTHORITY`` — which the stock builds are not. 

118 ``Path.as_uri()`` is therefore unusable here: for a Windows UNC path 

119 (``\\\\server\\share\\...``, and a mapped drive that ``resolve()`` 

120 turns into one) it yields ``file://server/share/...``, which SQLite 

121 refuses to open at all. The UNC host goes into the PATH instead — 

122 ``file:////server/share/...`` — which SQLite decodes to 

123 ``//server/share/...``, a path Windows opens as UNC. A drive path 

124 ``C:/...`` becomes ``file:///C:/...``, SQLite's documented Windows 

125 form, and a POSIX path ``/a/b`` becomes ``file:///a/b``. 

126 

127 The path is percent-encoded with only ``/`` and ``:`` left literal, 

128 so ``?``, ``#``, ``%``, spaces and non-ASCII characters all 

129 round-trip through SQLite's URI decoder. What gets encoded is the 

130 path's filesystem BYTES (``os.fsencode``), not its ``str`` form: on 

131 POSIX a directory name that is not valid UTF-8 decodes to lone 

132 surrogates (``b"\\xe9"`` -> ``"\\udce9"``), which ``quote(str)`` 

133 refuses with ``UnicodeEncodeError``; percent-encoding the bytes makes 

134 SQLite open exactly the on-disk name, as a plain 

135 ``sqlite3.connect(str_path)`` does. On Windows ``os.fsencode`` is 

136 UTF-8, the same bytes ``quote(str)`` would produce. 

137 

138 Interpolating the raw path into ``f"file:{path}?mode=ro"`` instead 

139 lets a literal ``?`` or ``#`` in a data-directory name truncate the 

140 URI before ``?mode=ro`` — silently dropping the read-only flag and, 

141 on a database that does not otherwise exist at the truncated path, 

142 creating a stray 0-byte file there. 

143 

144 Takes a ``PurePath`` so the Windows forms are unit-testable on any 

145 OS via ``PureWindowsPath``; the caller resolves the path first. 

146 """ 

147 posix = PurePath(path).as_posix() 

148 if not posix.startswith("/"): 

149 # Windows drive path "C:/x" -> "/C:/x". A UNC path's as_posix() 

150 # already starts with "//server/share", giving four slashes. 

151 posix = "/" + posix 

152 query = "mode=ro&immutable=1" if immutable else "mode=ro" 

153 return "file://" + quote(os.fsencode(posix), safe=b"/:") + "?" + query 

154 

155 

156#: ``journal_db_file_status`` results. 

157JOURNAL_DB_VALID = "valid" 

158JOURNAL_DB_INVALID = "invalid" 

159JOURNAL_DB_UNOPENABLE = "unopenable" 

160 

161 

162def journal_db_file_status(path: Path) -> str: 

163 """Classify ``path`` as a compiled journal-quality DB, read-only. 

164 

165 Returns one of: 

166 

167 - ``JOURNAL_DB_VALID`` — the file opened and is usable as-is. 

168 - ``JOURNAL_DB_INVALID`` — the file is missing, or it OPENED but is 

169 not usable: a non-zero ``PRAGMA user_version`` that does not match 

170 the current schema (a real drift signal), or content SQLite 

171 rejects as a database (corruption). Rebuilding is the right fix. 

172 - ``JOURNAL_DB_UNOPENABLE`` — the file exists but the read-only open 

173 itself failed (permissions, a URI or path form this SQLite build 

174 refuses, an unreachable share). Nothing is known about the file's 

175 contents, so callers must NOT treat it as corrupt: deleting it on 

176 this signal would destroy a possibly-valid DB over an environment 

177 problem. 

178 

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

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

181 because the stamp is missing. 

182 

183 Side-effect-free on the target file, deliberately: it opens with the 

184 SQLite URI flag ``mode=ro`` (see ``sqlite_readonly_uri``), which 

185 never creates the target path, reads ``PRAGMA user_version``, 

186 touches ``sqlite_master`` to confirm the file really is a database, 

187 and closes the connection. It removes nothing and builds nothing. 

188 The one caveat is SQLite's own: opening a WAL-mode database — even 

189 read-only — can still create that database's ``-wal``/``-shm`` side 

190 files, since materializing them is how SQLite reads a WAL database 

191 at all. 

192 

193 That is what makes it safe to call from a read path that only needs 

194 to know whether a read WOULD trigger a rebuild — see 

195 ``web/routers/metrics.py``'s ``_journal_ref_db_read_would_download``. 

196 ``JournalQualityDB._validate_existing_db`` wraps it with the 

197 destructive half (unlinking an INVALID file), which a read route 

198 must not do. 

199 """ 

200 from ..utilities.resource_utils import safe_close 

201 

202 try: 

203 resolved = Path(path).resolve() 

204 uri = sqlite_readonly_uri(resolved) 

205 conn = sqlite3.connect(uri, uri=True) 

206 except (sqlite3.DatabaseError, OSError, ValueError): 

207 if not Path(path).exists(): 

208 # Nothing to protect — a missing file is simply "not a 

209 # usable DB", and a build creating it destroys nothing. 

210 return JOURNAL_DB_INVALID 

211 logger.exception( 

212 f"journal_quality.db at {path} exists but could not be " 

213 f"opened read-only" 

214 ) 

215 return JOURNAL_DB_UNOPENABLE 

216 

217 status = JOURNAL_DB_INVALID 

218 try: 

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

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

221 logger.warning( 

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

223 f"expected {JOURNAL_QUALITY_SCHEMA_VERSION} — " 

224 f"a rebuild is needed" 

225 ) 

226 else: 

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

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

229 status = JOURNAL_DB_VALID 

230 except (sqlite3.DatabaseError, OSError): 

231 logger.exception(f"journal_quality.db at {path} is unusable") 

232 finally: 

233 safe_close(conn, "journal_quality validate") 

234 return status 

235 

236 

237def journal_db_file_is_valid(path: Path) -> bool: 

238 """Return True if ``path`` is a usable compiled journal-quality DB. 

239 

240 Read-only boolean view of ``journal_db_file_status``: both INVALID 

241 and UNOPENABLE report False, since neither can be served as-is. 

242 Callers that would DESTROY the file on False must use the status 

243 function instead and leave an UNOPENABLE file alone. 

244 """ 

245 return journal_db_file_status(path) == JOURNAL_DB_VALID 

246 

247 

248# --------------------------------------------------------------------------- 

249# Read-only accessor 

250# --------------------------------------------------------------------------- 

251 

252 

253class JournalQualityDB: 

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

255 

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

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

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

259 dashboard methods can return either dicts or Source instances — 

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

261 """ 

262 

263 def __init__(self) -> None: 

264 self._engine: Optional[Engine] = None 

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

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

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

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

269 # the very first request on a fresh install. 

270 self._lock = threading.RLock() 

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

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

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

274 self._stale_version_warned = False 

275 

276 # --- engine + session lifecycle --- 

277 

278 def _resolve_db_path(self) -> Path: 

279 from ..config.paths import get_journal_data_directory 

280 

281 return get_journal_data_directory() / DB_FILENAME 

282 

283 def _ensure_engine(self) -> None: 

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

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

286 # the happens-before relationship clear and portable. 

287 with self._lock: 

288 if self._engine is not None: 

289 return 

290 path = self._resolve_db_path() 

291 if not path.exists(): 

292 self._build_or_raise(path) 

293 else: 

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

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

296 # of letting them propagate to first query: 

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

298 # built (PRAGMA user_version mismatch) → rebuild. 

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

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

301 if not self._validate_existing_db(path): 

302 self._build_or_raise(path) 

303 

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

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

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

307 # 

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

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

310 # reach sqlite3. The creator builds the connection directly 

311 # with the SQLite URI flags intact. 

312 # Percent-encoded, empty-authority URI (see 

313 # ``sqlite_readonly_uri``): a raw ``f"file:{path}?..."`` lets 

314 # a ``?``/``#`` in the data directory truncate the URI and 

315 # drop mode=ro/immutable, and mis-decodes a literal ``%XX``. 

316 engine_uri = sqlite_readonly_uri(path.resolve(), immutable=True) 

317 

318 def _make_ro_conn() -> sqlite3.Connection: 

319 return sqlite3.connect(engine_uri, uri=True) 

320 

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

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

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

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

325 # shares data across connections. NullPool ensures that 

326 # concurrent filter_results threads never share a single 

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

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

329 from sqlalchemy.pool import NullPool 

330 

331 engine = create_engine( 

332 "sqlite://", 

333 creator=_make_ro_conn, 

334 poolclass=NullPool, 

335 echo=False, 

336 ) 

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

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

339 self._engine = engine 

340 self._SessionLocal = session_local 

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

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

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

344 # Schema drift is already handled by `_validate_existing_db` 

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

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

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

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

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

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

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

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

353 # explicit refresh trigger. 

354 self._warn_on_stale_data_version(path.parent) 

355 

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

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

358 if self._stale_version_warned: 

359 return 

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

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

362 from .downloader import JOURNAL_DATA_VERSION 

363 

364 version_file = data_dir / "version.json" 

365 if not version_file.exists(): 

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

367 try: 

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

369 info = json.load(f) 

370 installed = info.get("version") 

371 except (json.JSONDecodeError, OSError): 

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

373 if installed and installed != JOURNAL_DATA_VERSION: 

374 logger.warning( 

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

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

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

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

379 ) 

380 self._stale_version_warned = True 

381 

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

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

384 

385 Thin wrapper over the module-level ``journal_db_file_status`` 

386 probe plus the destructive half: a file that opened but is 

387 unusable is removed here so the caller's rebuild starts from a 

388 clean slate. A file that exists but could not be opened at all 

389 is left alone and raises ``FileNotFoundError`` (reference DB 

390 unavailable) instead. Callers 

391 that only want to KNOW whether the file is usable — a read route 

392 deciding whether a read would trigger a build — must use the 

393 probe directly rather than this method, which would delete their 

394 DB as a side effect of asking. 

395 """ 

396 status = journal_db_file_status(path) 

397 if status == JOURNAL_DB_UNOPENABLE: 

398 # The file exists but could not even be opened read-only, so 

399 # nothing says its contents are bad. Unlinking it (and then 

400 # rebuilding or re-downloading over it) would destroy a 

401 # possibly-valid DB over a permissions / path-form problem; 

402 # under a forbidding egress scope the rebuild would then 

403 # refuse and leave the user with no DB at all. Report the 

404 # reference DB as unavailable instead — FileNotFoundError is 

405 # the signal every reader here already degrades on — and 

406 # leave the file in place. 

407 raise FileNotFoundError( 

408 f"{DB_FILENAME} exists at {path} but could not be opened " 

409 f"read-only; leaving it in place rather than rebuilding" 

410 ) 

411 is_valid = status == JOURNAL_DB_VALID 

412 if not is_valid: 

413 self._unlink_unusable_db(path) 

414 return is_valid 

415 

416 @staticmethod 

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

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

419 

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

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

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

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

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

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

426 warning to diagnose the real problem. 

427 """ 

428 try: 

429 # bearer:disable python_lang_file_permissions 

430 os.chmod(path, 0o644) 

431 except OSError: 

432 logger.warning( 

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

434 f"unlink (continuing to unlink attempt)" 

435 ) 

436 try: 

437 path.unlink() 

438 except OSError: 

439 logger.warning( 

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

441 f"overwritten on next build)" 

442 ) 

443 

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

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

446 

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

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

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

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

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

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

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

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

455 

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

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

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

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

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

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

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

463 

464 Tests that DO want the real download orchestration call 

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

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

467 does not touch. 

468 """ 

469 from ..settings.env_definitions.testing import testing_with_mocks 

470 from .downloader import ensure_journal_data 

471 

472 mock_mode = testing_with_mocks() 

473 # Third gate, alongside mock mode: the active egress scope. The 

474 # dataset sources (OpenAlex/DOAJ/GitHub) are all public, so under 

475 # PRIVATE_ONLY/STRICT the lazy build must refuse the network 

476 # download, like the dashboard button and the filter's background 

477 # fetch already do. The parity is close but not exact, and the 

478 # difference matters: both siblings resolve the user's PERSISTED 

479 # settings (the button the literal `policy.egress_scope`, the 

480 # filter that scope with ADAPTIVE resolved against the primary 

481 # engine) and fail closed when the scope cannot be parsed, 

482 # while this gate can only see a context armed on the calling 

483 # thread. Where no context is armed it allows the download — 

484 # request threads (Starlette runs sync routes on a threadpool 

485 # that arms none) and a run whose settings snapshot failed to 

486 # resolve (`search_system.py` deliberately leaves the backstop 

487 # unarmed there) both land in that case. Entry points that can 

488 # reach the persisted settings gate on them themselves: the 

489 # dashboard read routes in `web/routers/metrics.py` resolve the 

490 # scope a run would get (ADAPTIVE included), since a page load 

491 # would otherwise reach this build ungated. 

492 # A purely local build from already-present snapshots stays 

493 # allowed — only the network is off the table. 

494 from ..security.egress.audit_hook import get_active_context 

495 from ..security.egress.policy import EgressScope 

496 

497 ctx = get_active_context() 

498 scope_forbids_public = ctx is not None and ctx.scope in ( 

499 EgressScope.PRIVATE_ONLY, 

500 EgressScope.STRICT, 

501 ) 

502 data_dir, available = ensure_journal_data( 

503 auto_download=not mock_mode and not scope_forbids_public 

504 ) 

505 if not available: 

506 if mock_mode: 

507 raise FileNotFoundError( 

508 "Journal data files not available and " 

509 "LDR_TESTING_WITH_MOCKS=true — refusing to download " 

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

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

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

513 "real data." 

514 ) 

515 if scope_forbids_public: 

516 # Recorded here rather than next to the gate above: with 

517 # the snapshots already on disk `ensure_journal_data` 

518 # reports them available and the local build proceeds, so 

519 # nothing was refused and no policy record is due. 

520 logger.bind(policy_audit=True).info( 

521 "journal-data lazy build refused the network download: " 

522 "active egress scope forbids public fetches" 

523 ) 

524 raise FileNotFoundError( 

525 "Journal data files not available and the active " 

526 "egress scope forbids the public download. Place " 

527 "the gz snapshots in the data directory, or change " 

528 "the egress scope and then download from the " 

529 "dashboard." 

530 ) 

531 raise FileNotFoundError( 

532 "Journal data files not available. " 

533 "Check your network connection or download manually " 

534 "from the dashboard." 

535 ) 

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

537 build_db(data_dir=data_dir, output_path=path) 

538 

539 @contextmanager 

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

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

542 

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

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

545 DatabaseError propagates but we drop the cached engine so the 

546 next call rebuilds cleanly instead of failing forever. 

547 """ 

548 self._ensure_engine() 

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

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

551 from ..utilities.resource_utils import safe_close 

552 

553 s = self._SessionLocal() 

554 try: 

555 yield s 

556 except (OperationalError, DatabaseError): 

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

558 self.reset() 

559 raise 

560 finally: 

561 safe_close(s, "journal_quality session") 

562 

563 @property 

564 def available(self) -> bool: 

565 try: 

566 self._ensure_engine() 

567 return True 

568 except FileNotFoundError: 

569 return False 

570 

571 def reset(self) -> None: 

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

573 with self._lock: 

574 if self._engine is not None: 

575 self._engine.dispose() 

576 self._engine = None 

577 self._SessionLocal = None 

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

579 

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

581 

582 def lookup_openalex( 

583 self, 

584 *, 

585 source_id: Optional[str] = None, 

586 issn: Optional[str] = None, 

587 name: Optional[str] = None, 

588 ) -> Optional[dict]: 

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

590 

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

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

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

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

595 """ 

596 issn = normalize_issn(issn) 

597 try: 

598 self._ensure_engine() 

599 except FileNotFoundError: 

600 return None 

601 with self.session() as s: 

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

603 return _source_to_lookup_dict(row) if row else None 

604 

605 # Alias used by the dashboard / future call sites 

606 lookup_source = lookup_openalex 

607 

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

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

610 

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

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

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

614 

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

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

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

618 ``names`` is empty. 

619 

620 .. note:: 

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

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

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

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

625 audit — no chunking needed. 

626 """ 

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

628 normed.discard("") 

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

630 return 0 

631 try: 

632 self._ensure_engine() 

633 except FileNotFoundError: 

634 return 0 

635 with self.session() as s: 

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

637 Source.name_lower.in_(normed), 

638 Source.is_predatory.is_(True), 

639 ) 

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

641 return int(result or 0) 

642 

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

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

645 

646 Takes an iterable of raw display names and returns a 

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

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

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

650 

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

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

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

654 200 per-row lookups. 

655 

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

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

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

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

660 is for dashboard display where a miss is acceptable. 

661 

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

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

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

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

666 a deployment might pin to. 

667 """ 

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

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

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

671 return {} 

672 try: 

673 self._ensure_engine() 

674 except FileNotFoundError: 

675 return {} 

676 # De-duplicate while preserving insertion order for stable 

677 # iteration in tests. 

678 seen: set = set() 

679 uniq: list = [] 

680 for n in normed: 

681 if n not in seen: 

682 seen.add(n) 

683 uniq.append(n) 

684 

685 out: dict = {} 

686 CHUNK = 900 

687 with self.session() as s: 

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

689 batch = uniq[i : i + CHUNK] 

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

691 for row in s.scalars(stmt): 

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

693 return out 

694 

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

696 issn = normalize_issn(issn) 

697 if not issn: 

698 return None 

699 try: 

700 self._ensure_engine() 

701 except FileNotFoundError: 

702 return None 

703 with self.session() as s: 

704 stmt = ( 

705 select(Source) 

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

707 .limit(1) 

708 ) 

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

710 if row is None: 

711 return None 

712 return { 

713 "name": row.name, 

714 "publisher": row.publisher, 

715 } 

716 

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

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

719 

720 def is_predatory( 

721 self, 

722 *, 

723 journal_name: Optional[str] = None, 

724 publisher_name: Optional[str] = None, 

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

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

727 

728 Looks up the dedicated predatory tables (NOT just the 

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

730 input names that aren't in OpenAlex. 

731 """ 

732 try: 

733 self._ensure_engine() 

734 except FileNotFoundError: 

735 return False, None 

736 with self.session() as s: 

737 if journal_name: 

738 norm = normalize_name(journal_name) 

739 if s.get(PredatoryJournal, norm) is not None: 

740 return True, "stop-predatory-journals" 

741 if s.get(PredatoryHijacked, norm) is not None: 

742 return True, "stop-predatory-hijacked" 

743 

744 if publisher_name: 

745 pub_norm = normalize_name(publisher_name) 

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

747 return True, "stop-predatory-publishers" 

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

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

750 PredatoryPublisher.is_long.is_(True) 

751 ) 

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

753 if pub_norm in entry or entry in pub_norm: 

754 return True, "stop-predatory-publishers" 

755 

756 return False, None 

757 

758 def is_whitelisted( 

759 self, 

760 *, 

761 issn: Optional[str] = None, 

762 name: Optional[str] = None, 

763 ) -> bool: 

764 if self.is_in_doaj(issn): 

765 return True 

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

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

768 return True 

769 return False 

770 

771 def lookup_institution( 

772 self, 

773 *, 

774 ror_id: Optional[str] = None, 

775 openalex_id: Optional[str] = None, 

776 name: Optional[str] = None, 

777 ) -> Optional[dict]: 

778 """Look up an institution. 

779 

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

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

782 ``impact_factor``, ``works_count``, ``cited_by_count``, ``ror_id``) 

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

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

785 returns full names instead for legibility and schema robustness. 

786 """ 

787 try: 

788 self._ensure_engine() 

789 except FileNotFoundError: 

790 return None 

791 with self.session() as s: 

792 row: Optional[Institution] = None 

793 

794 if openalex_id: 

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

796 row = s.get(Institution, sid) 

797 

798 if row is None and ror_id: 

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

800 stmt = ( 

801 select(Institution) 

802 .where(Institution.ror_id == ror) 

803 .limit(1) 

804 ) 

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

806 

807 if row is None and name: 

808 norm = normalize_name(name) 

809 stmt = ( 

810 select(Institution) 

811 .where(Institution.name_lower == norm) 

812 .limit(1) 

813 ) 

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

815 

816 return _institution_to_dict(row) if row else None 

817 

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

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

820 if not affiliations: 820 ↛ 821line 820 didn't jump to line 821 because the condition on line 820 was never true

821 return None 

822 

823 openalex_ids: list[str] = [] 

824 ror_ids: list[str] = [] 

825 names: list[str] = [] 

826 

827 for aff in affiliations: 

828 if isinstance(aff, str): 

829 names.append(normalize_name(aff)) 

830 elif isinstance(aff, dict): 830 ↛ 827line 830 didn't jump to line 827 because the condition on line 830 was always true

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

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

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

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

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

836 names.append(normalize_name(nm)) 

837 

838 if not (openalex_ids or ror_ids or names): 838 ↛ 839line 838 didn't jump to line 839 because the condition on line 838 was never true

839 return None 

840 

841 try: 

842 self._ensure_engine() 

843 except FileNotFoundError: 

844 return None 

845 

846 clauses = [] 

847 if openalex_ids: 

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

849 if ror_ids: 

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

851 if names: 

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

853 

854 with self.session() as s: 

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

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

857 ) 

858 best_h = s.scalar(stmt) 

859 

860 # Single source of truth for institution scoring lives in 

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

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

863 return institution_score_from_h_index(best_h) 

864 

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

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

867 # scoring rules in scoring.py. 

868 derive_quality_score = staticmethod(derive_quality_score) 

869 

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

871 if not name: 

872 return None 

873 try: 

874 self._ensure_engine() 

875 except FileNotFoundError: 

876 return None 

877 normalized = normalize_name(name) 

878 with self.session() as s: 

879 row = s.get(Abbreviation, normalized) 

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

881 return row.full_name 

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

883 if no_dots != normalized: 

884 row = s.get(Abbreviation, no_dots) 

885 if row is not None: 885 ↛ 887line 885 didn't jump to line 887

886 return row.full_name 

887 return None 

888 

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

890 

891 def _lookup_source_row( 

892 self, 

893 s: Session, 

894 source_id: Optional[str], 

895 issn: Optional[str], 

896 name: Optional[str], 

897 ) -> Optional[Source]: 

898 if source_id: 

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

900 stmt = ( 

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

902 ) 

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

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

905 return row 

906 

907 if issn: 

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

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

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

911 return row 

912 

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

914 norm = normalize_name(name) 

915 row = self._fetch_by_name_lower(s, norm) 

916 if row is not None: 

917 return row 

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

919 if norm.startswith("the "): 

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

921 else: 

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

923 if row is not None: 

924 return row 

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

926 stripped = norm 

927 for prefix in ( 

928 "proceedings of the conference on ", 

929 "proceedings of the ", 

930 "proceedings of ", 

931 ): 

932 if stripped.startswith(prefix): 

933 stripped = stripped[len(prefix) :] 

934 break 

935 if stripped != norm: 

936 row = self._fetch_by_name_lower(s, stripped) 

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

938 return row 

939 

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

941 # before the colon. Catches PubMed names like 

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

943 # → "Molecular therapy" 

944 if " : " in norm: 

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

946 if head and head != norm: 946 ↛ 956line 946 didn't jump to line 956 because the condition on line 946 was always true

947 row = self._fetch_by_name_lower(s, head) 

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

949 return row 

950 

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

952 # before the first period. Catches PubMed names like 

953 # "Molecular therapy. Methods and clinical development" 

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

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

956 if "." in norm: 

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

958 if head and len(head) >= 6 and head != norm: 958 ↛ 963line 958 didn't jump to line 963 because the condition on line 958 was always true

959 row = self._fetch_by_name_lower(s, head) 

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

961 return row 

962 

963 return None 

964 

965 @staticmethod 

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

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

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

969 

970 # --- dashboard queries --- 

971 

972 def get_summary(self) -> dict: 

973 if not self.available: 

974 return { 

975 "total": 0, 

976 "avg_quality": 0, 

977 "avg_h_index": None, 

978 "predatory_count": 0, 

979 "doaj_count": 0, 

980 "llm_count": 0, 

981 } 

982 

983 with self.session() as s: 

984 row = s.execute( 

985 select( 

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

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

988 "avg_quality" 

989 ), 

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

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

992 "predatory_count" 

993 ), 

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

995 "doaj_count" 

996 ), 

997 func.sum( 

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

999 ).label("llm_count"), 

1000 ) 

1001 ).first() 

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

1003 

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

1005 if not self.available: 

1006 return {} 

1007 with self.session() as s: 

1008 rows = s.execute( 

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

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

1011 .group_by(Source.quality) 

1012 .order_by(Source.quality) 

1013 ).all() 

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

1015 

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

1017 if not self.available: 

1018 return {} 

1019 with self.session() as s: 

1020 rows = s.execute( 

1021 select( 

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

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

1024 ).group_by(Source.score_source) 

1025 ).all() 

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

1027 

1028 def get_journals_page( 

1029 self, 

1030 *, 

1031 page: int = 1, 

1032 per_page: int = 50, 

1033 search: str = "", 

1034 tier: str = "", 

1035 score_source: str = "", 

1036 sort: str = "quality", 

1037 order: str = "desc", 

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

1039 if not self.available: 

1040 return [], 0 

1041 

1042 if sort not in _SORT_COLUMNS: 

1043 sort = "quality" 

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

1045 order = "desc" 

1046 

1047 wheres: list = [] 

1048 if search: 

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

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

1051 if tier and tier in _TIER_RANGES: 

1052 lo, hi = _TIER_RANGES[tier] 

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

1054 if score_source: 

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

1056 

1057 sort_col = getattr(Source, sort) 

1058 order_clause = ( 

1059 sort_col.desc().nulls_last() 

1060 if order == "desc" 

1061 else sort_col.asc().nulls_last() 

1062 ) 

1063 

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

1065 

1066 with self.session() as s: 

1067 total = ( 

1068 s.scalar( 

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

1070 ) 

1071 or 0 

1072 ) 

1073 rows = s.scalars( 

1074 select(Source) 

1075 .where(*wheres) 

1076 .order_by(order_clause) 

1077 .limit(per_page) 

1078 .offset(offset) 

1079 ).all() 

1080 

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

1082 

1083 def get_institutions_page( 

1084 self, 

1085 *, 

1086 page: int = 1, 

1087 per_page: int = 50, 

1088 search: str = "", 

1089 sort: str = "h_index", 

1090 order: str = "desc", 

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

1092 if not self.available: 

1093 return [], 0 

1094 

1095 # Defensive allowlist — matches the pattern in get_journals_page. 

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

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

1098 # accidentally interpolating a tainted value into SQL. 

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

1100 order = "desc" 

1101 

1102 wheres = [] 

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

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

1105 wheres.append( 

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

1107 ) 

1108 

1109 sort_col = ( 

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

1111 ) 

1112 order_clause = ( 

1113 sort_col.desc().nulls_last() 

1114 if order == "desc" 

1115 else sort_col.asc().nulls_last() 

1116 ) 

1117 

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

1119 

1120 with self.session() as s: 

1121 total = ( 

1122 s.scalar( 

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

1124 ) 

1125 or 0 

1126 ) 

1127 rows = s.scalars( 

1128 select(Institution) 

1129 .where(*wheres) 

1130 .order_by(order_clause) 

1131 .limit(per_page) 

1132 .offset(offset) 

1133 ).all() 

1134 

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

1136 

1137 

1138# --------------------------------------------------------------------------- 

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

1140# --------------------------------------------------------------------------- 

1141 

1142 

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

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

1145 

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

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

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

1149 and feed it into score derivation. 

1150 """ 

1151 return { 

1152 "name": row.name, 

1153 "type": row.source_type, 

1154 "h_index": row.h_index, 

1155 "impact_factor": row.impact_factor, 

1156 "is_in_doaj": row.is_in_doaj, 

1157 "publisher": row.publisher, 

1158 "issn_l": row.issn, 

1159 "openalex_source_id": row.openalex_source_id, 

1160 "quartile": row.quartile, 

1161 } 

1162 

1163 

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

1165 return { 

1166 "name": row.name, 

1167 "quality": row.quality, 

1168 "quartile": row.quartile, 

1169 "cited_by_count": row.cited_by_count, 

1170 "h_index": row.h_index, 

1171 "impact_factor": ( 

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

1173 ), 

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

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

1176 "predatory_source": row.predatory_source, 

1177 "score_source": row.score_source, 

1178 "source_type": row.source_type, 

1179 "publisher": row.publisher, 

1180 "issn": row.issn, 

1181 "openalex_source_id": row.openalex_source_id, 

1182 } 

1183 

1184 

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

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

1187 

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

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

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

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

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

1193 """ 

1194 return { 

1195 "name": row.name, 

1196 "country": row.country, 

1197 "type": row.type, 

1198 "h_index": row.h_index, 

1199 "impact_factor": row.impact_factor, 

1200 "works_count": row.works_count, 

1201 "cited_by_count": row.cited_by_count, 

1202 "ror_id": row.ror_id, 

1203 } 

1204 

1205 

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

1207 return { 

1208 "openalex_id": row.openalex_id, 

1209 "name": row.name, 

1210 "ror_id": row.ror_id, 

1211 "country": row.country, 

1212 "type": row.type, 

1213 "h_index": row.h_index, 

1214 "impact_factor": row.impact_factor, 

1215 "works_count": row.works_count, 

1216 "cited_by_count": row.cited_by_count, 

1217 } 

1218 

1219 

1220# --------------------------------------------------------------------------- 

1221# Module singleton 

1222# --------------------------------------------------------------------------- 

1223 

1224 

1225_db: Optional[JournalQualityDB] = None 

1226_db_lock = threading.Lock() 

1227 

1228 

1229def get_db() -> JournalQualityDB: 

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

1231 global _db 

1232 if _db is None: 

1233 with _db_lock: 

1234 if _db is None: 1234 ↛ 1236line 1234 didn't jump to line 1236

1235 _db = JournalQualityDB() 

1236 return _db 

1237 

1238 

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

1240get_journal_reference_db = get_db 

1241JournalReferenceDB = JournalQualityDB 

1242 

1243 

1244def reset_db() -> None: 

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

1246 

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

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

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

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

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

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

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

1254 """ 

1255 global _db 

1256 with _db_lock: 

1257 if _db is not None: 

1258 _db.reset() 

1259 

1260 

1261# --------------------------------------------------------------------------- 

1262# Build phase — the ONLY writer 

1263# --------------------------------------------------------------------------- 

1264 

1265 

1266def build_db( 

1267 data_dir: Optional[Path] = None, 

1268 output_path: Optional[Path] = None, 

1269) -> None: 

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

1271 

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

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

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

1275 """ 

1276 start = time.time() 

1277 

1278 if data_dir is None: 1278 ↛ 1279line 1278 didn't jump to line 1279 because the condition on line 1278 was never true

1279 from ..config.paths import get_journal_data_directory 

1280 

1281 data_dir = get_journal_data_directory() 

1282 if output_path is None: 1282 ↛ 1283line 1282 didn't jump to line 1283 because the condition on line 1282 was never true

1283 output_path = data_dir / DB_FILENAME 

1284 

1285 logger.info( 

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

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

1288 ) 

1289 

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

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

1292 _sweep_stale_tmp_files(output_path.parent, output_path.name) 

1293 

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

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

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

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

1298 # the live file. 

1299 tmp_path = output_path.with_name( 

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

1301 ) 

1302 

1303 # Connect by plain filename through a creator, never via a 

1304 # ``sqlite:///{tmp_path}`` URL: SQLAlchemy's URL parser treats a 

1305 # ``?`` in the data-directory name as the start of a query string, 

1306 # so the writer would open (and create) a truncated path and the 

1307 # final ``os.replace`` would fail. A plain (non-URI) filename has no 

1308 # delimiters. QueuePool is what the URL form selected for a file DB. 

1309 from sqlalchemy.pool import QueuePool 

1310 

1311 def _make_write_conn() -> sqlite3.Connection: 

1312 return sqlite3.connect(os.fspath(tmp_path), check_same_thread=False) 

1313 

1314 engine = create_engine( 

1315 "sqlite://", creator=_make_write_conn, poolclass=QueuePool 

1316 ) 

1317 

1318 try: 

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

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

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

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

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

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

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

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

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

1328 # elsewhere without the same atomic rename discipline. 

1329 with engine.connect() as conn: 

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

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

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

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

1334 

1335 JournalQualityBase.metadata.create_all(engine) 

1336 

1337 SessionWrite = sessionmaker(bind=engine) 

1338 with SessionWrite() as session: 

1339 sources = _load_openalex(data_dir) 

1340 doaj_data = _load_doaj(data_dir) 

1341 pred_data = _load_predatory(data_dir) 

1342 institutions = _load_institutions(data_dir) 

1343 abbreviations = _load_abbreviations(data_dir) 

1344 

1345 _populate_predatory(session, pred_data) 

1346 _populate_sources(session, sources, doaj_data, pred_data) 

1347 _populate_institutions(session, institutions) 

1348 _populate_abbreviations(session, abbreviations) 

1349 session.commit() 

1350 

1351 with engine.connect() as conn: 

1352 conn.exec_driver_sql("ANALYZE") 

1353 conn.exec_driver_sql("VACUUM") 

1354 # Stamp schema version so _ensure_engine can detect drift 

1355 # without depending on the external version.json. 

1356 conn.exec_driver_sql( 

1357 # nosemgrep: semgrep.rules.sql-string-concatenation, reason: Schema version is the module integer constant JOURNAL_QUALITY_SCHEMA_VERSION 

1358 f"PRAGMA user_version = {JOURNAL_QUALITY_SCHEMA_VERSION}" 

1359 ) 

1360 except Exception: 

1361 engine.dispose() 

1362 if tmp_path.exists(): 1362 ↛ 1369line 1362 didn't jump to line 1369 because the condition on line 1362 was always true

1363 try: 

1364 # bearer:disable python_lang_file_permissions 

1365 os.chmod(tmp_path, 0o644) 

1366 tmp_path.unlink() 

1367 except OSError: 

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

1369 raise 

1370 

1371 engine.dispose() 

1372 

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

1374 # overwrites an existing output_path if present. 

1375 if output_path.exists(): 

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

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

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

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

1380 try: 

1381 # bearer:disable python_lang_file_permissions 

1382 os.chmod(output_path, 0o644) 

1383 except OSError: 

1384 logger.warning( 

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

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

1387 ) 

1388 os.replace(tmp_path, output_path) 

1389 

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

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

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

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

1394 # defense against accidental writable opens. 

1395 # bearer:disable python_lang_file_permissions 

1396 os.chmod(output_path, 0o444) 

1397 if sys.platform == "win32": 1397 ↛ 1398line 1397 didn't jump to line 1398 because the condition on line 1397 was never true

1398 try: 

1399 import ctypes 

1400 

1401 # FILE_ATTRIBUTE_READONLY = 0x1 

1402 ok = ctypes.windll.kernel32.SetFileAttributesW( 

1403 str(output_path), 0x1 

1404 ) 

1405 if not ok: 

1406 logger.warning( 

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

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

1409 ) 

1410 except Exception: 

1411 logger.warning( 

1412 f"Could not set Windows readonly attribute on " 

1413 f"{output_path.name}" 

1414 ) 

1415 

1416 elapsed = time.time() - start 

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

1418 with closing( 

1419 sqlite3.connect( 

1420 sqlite_readonly_uri(Path(output_path).resolve(), immutable=True), 

1421 uri=True, 

1422 ) 

1423 ) as _count_conn: 

1424 source_count = _count_conn.execute( 

1425 "SELECT COUNT(*) FROM sources" 

1426 ).fetchone()[0] 

1427 logger.info( 

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

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

1430 ) 

1431 

1432 reset_db() 

1433 

1434 

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

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

1437 

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

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

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

1441 accumulating stale tmp files that would otherwise eat disk over 

1442 time. 

1443 """ 

1444 if not directory.exists(): 

1445 return 

1446 cutoff = time.time() - 3600 

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

1448 try: 

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

1450 tmp.unlink() 

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

1452 except OSError: 

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

1454 

1455 

1456# --------------------------------------------------------------------------- 

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

1458# --------------------------------------------------------------------------- 

1459 

1460# The gzipped snapshots are this pipeline's own output, but a replaced or 

1461# corrupted file must not expand without limit into one string. The bound is 

1462# derived from the compressed size: compact JSON of these records compresses 

1463# well under 32:1, so a file past the bound is refused rather than loaded. 

1464_GZIP_SNAPSHOT_RATIO = 32 

1465_GZIP_SNAPSHOT_SLACK_CHARS = 1024 * 1024 

1466_GZIP_SNAPSHOT_MAX_CHARS = 4 * 1024**3 

1467 

1468 

1469def _load_gzip_json(path: Path) -> dict: 

1470 """Parse a gzipped JSON snapshot, refusing one that decompresses too far.""" 

1471 cap = min( 

1472 path.stat().st_size * _GZIP_SNAPSHOT_RATIO + _GZIP_SNAPSHOT_SLACK_CHARS, 

1473 _GZIP_SNAPSHOT_MAX_CHARS, 

1474 ) 

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

1476 text = f.read(cap + 1) 

1477 if len(text) > cap: 

1478 raise ValueError( 

1479 f"{path.name} decompresses past {cap:,} characters, " 

1480 f"{_GZIP_SNAPSHOT_RATIO}x its compressed size; refusing to load it" 

1481 ) 

1482 data = json.loads(text) 

1483 if not isinstance(data, dict): 

1484 raise ValueError(f"{path.name} is not a JSON object") 

1485 return data 

1486 

1487 

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

1489 # Lazy import to avoid any downloader <-> db module-level cycle, 

1490 # matching the other lazy imports of `.downloader` in this file. 

1491 from .downloader import REQUIRED_SNAPSHOT_FILENAME 

1492 

1493 path = data_dir / REQUIRED_SNAPSHOT_FILENAME 

1494 if not path.exists(): 

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

1496 data = _load_gzip_json(path) 

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

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

1499 return dict(sources) 

1500 

1501 

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

1503 path = data_dir / "doaj_journals.json" 

1504 if not path.exists(): 

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

1506 return {} 

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

1508 data = json.load(f) 

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

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

1511 return dict(journals) 

1512 

1513 

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

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

1516 path = data_dir / "predatory.json" 

1517 if not path.exists(): 

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

1519 return { 

1520 "journals": set(), 

1521 "publishers": set(), 

1522 "hijacked": set(), 

1523 "long_pubs": [], 

1524 } 

1525 

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

1527 data = json.load(f) 

1528 

1529 journal_names = { 

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

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

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

1533 } 

1534 publisher_names = { 

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

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

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

1538 } 

1539 hijacked_names = { 

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

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

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

1543 } 

1544 long_pubs = [ 

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

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

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

1548 ] 

1549 logger.info( 

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

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

1552 f"{len(hijacked_names)} hijacked" 

1553 ) 

1554 return { 

1555 "journals": journal_names, 

1556 "publishers": publisher_names, 

1557 "hijacked": hijacked_names, 

1558 "long_pubs": long_pubs, 

1559 } 

1560 

1561 

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

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

1564 if not path.exists(): 

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

1566 return {} 

1567 data = _load_gzip_json(path) 

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

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

1570 return dict(institutions) 

1571 

1572 

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

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

1575 if not path.exists(): 

1576 logger.warning( 

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

1578 ) 

1579 return {} 

1580 data = _load_gzip_json(path) 

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

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

1583 return dict(mappings) 

1584 

1585 

1586# --------------------------------------------------------------------------- 

1587# Table populators 

1588# --------------------------------------------------------------------------- 

1589 

1590 

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

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

1593 

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

1595 if journals: 

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

1597 

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

1599 if hijacked: 

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

1601 

1602 publishers = [ 

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

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

1605 if n 

1606 ] 

1607 if publishers: 

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

1609 

1610 logger.info( 

1611 f"Inserted predatory tables: " 

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

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

1614 f"{len(hijacked)} hijacked" 

1615 ) 

1616 

1617 

1618def _as_number(value): 

1619 """Coerce a dataset-supplied numeric field; junk types become None. 

1620 

1621 The datasets come from a public upstream over HTTPS — the same 

1622 hostile-upstream threat model as the manifest URL allowlist (and, 

1623 once merged, the pending digest verification, decompression caps 

1624 and https-pin PRs). Junk-typed numerics must land as NULL, not 

1625 crash the build's comparisons or the dashboard reads. That 

1626 includes values SQLite cannot round-trip or store: floats that 

1627 are inf/NaN (``json.loads('1e999')`` yields inf) and ints 

1628 outside the int64 range an INTEGER column can hold. 

1629 """ 

1630 if isinstance(value, bool) or not isinstance(value, (int, float)): 

1631 return None 

1632 if isinstance(value, int): 

1633 if not -(2**63) <= value <= 2**63 - 1: 

1634 return None 

1635 elif not math.isfinite(value): 

1636 return None 

1637 return value 

1638 

1639 

1640def _as_text(value): 

1641 """Coerce a dataset-supplied text field; non-strings become None.""" 

1642 if isinstance(value, str): 

1643 stripped = value.strip() 

1644 if stripped: 

1645 return stripped 

1646 return None 

1647 

1648 

1649# Row-count floors for the two populators below. They mirror the 

1650# fetch-stage floors that already guard the raw snapshot 

1651# (``_MIN_OPENALEX_SOURCES = 10_000`` in 

1652# ``data_sources/openalex.py``, ``_MIN_INSTITUTIONS = 50_000`` in 

1653# ``data_sources/institutions.py``) — but those floors count records 

1654# reaching the snapshot, not how many of those this module's coercion 

1655# then drops for a blank/non-string name. A fetch that clears its own 

1656# floor can still collapse here: e.g. every fetched record sharing a 

1657# well-typed but empty ``display_name`` passes the fetch floor and 

1658# then loses 100% of rows to the ``if not name: continue`` skip below, 

1659# silently producing a near-empty (or fully empty) table with no 

1660# error. Set to half the corresponding fetch floor: normal attrition 

1661# from a healthy OpenAlex snapshot is a small fraction of blank names, 

1662# so losing more than half of an otherwise fetch-floor-sized snapshot 

1663# to name/type coercion is itself evidence of a collapsed or hostile 

1664# feed, not ordinary data quality noise. 

1665# 

1666# The check only applies once the input is already at fetch-floor 

1667# scale (``len(sources) >= _MIN_BUILD_SOURCES``, same pattern for 

1668# institutions) — mirroring the fetch stage's own schema-drift check 

1669# (``openalex.py``'s ``if len(journal_sample) >= _SCHEMA_SAMPLE_SIZE`` 

1670# guard), which likewise only fires once the sample is large enough 

1671# to be meaningful. A hand-built test fixture of a handful of records 

1672# could never pass the fetch-stage floor in the first place, so it 

1673# never reaches build-floor scale either and the check does not fire 

1674# for it; only a real (or realistically-sized) snapshot that 

1675# collapses during populate does. 

1676_MIN_BUILD_SOURCES = 5_000 # half of openalex.py's _MIN_OPENALEX_SOURCES 

1677_MIN_BUILD_INSTITUTIONS = 25_000 # half of institutions.py's _MIN_INSTITUTIONS 

1678 

1679 

1680def _populate_sources( 

1681 session: Session, 

1682 sources: dict, 

1683 doaj_data: dict, 

1684 pred: dict, 

1685) -> None: 

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

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

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

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

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

1691 

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

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

1694 # collapsing onto one row. 

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

1696 skipped = 0 

1697 

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

1699 name = _as_text(compact.get("n")) 

1700 if not name: 

1701 skipped += 1 

1702 continue 

1703 

1704 name_lower = normalize_name(name) 

1705 issn = normalize_issn(_as_text(compact.get("i"))) 

1706 publisher = _as_text(compact.get("p")) 

1707 h_index = _as_number(compact.get("h")) 

1708 impact_factor = _as_number(compact.get("if")) 

1709 cited_by_count = _as_number(compact.get("cb")) 

1710 type_tag = _as_text(compact.get("t")) or "" 

1711 source_type = type_map.get(type_tag, type_tag) 

1712 

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

1714 is_in_doaj = doaj_entry is not None 

1715 

1716 is_pred = name_lower in pred_journals 

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

1718 if not is_pred and publisher: 

1719 pub_norm = normalize_name(publisher) 

1720 if pub_norm in pred_publishers: 

1721 is_pred = True 

1722 pred_source = "stop-predatory-publishers" 

1723 if not is_pred and name_lower in pred_hijacked: 

1724 is_pred = True 

1725 pred_source = "stop-predatory-hijacked" 

1726 

1727 # Whitelist override 

1728 if is_pred and ( 

1729 is_in_doaj or (h_index or 0) > PREDATORY_WHITELIST_HINDEX 

1730 ): 

1731 is_pred = False 

1732 pred_source = None 

1733 

1734 quality = derive_quality_score( 

1735 h_index=h_index, 

1736 is_in_doaj=is_in_doaj, 

1737 is_predatory=is_pred, 

1738 source_type=source_type, 

1739 ) 

1740 

1741 rec = { 

1742 "name": name, 

1743 "name_lower": name_lower, 

1744 "issn": issn, 

1745 "openalex_source_id": source_id, 

1746 "source_type": source_type, 

1747 "publisher": publisher, 

1748 "h_index": h_index, 

1749 "impact_factor": impact_factor, 

1750 "cited_by_count": cited_by_count, 

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

1752 "quality": quality, 

1753 "is_in_doaj": is_in_doaj, 

1754 "is_predatory": is_pred, 

1755 "predatory_source": pred_source, 

1756 "score_source": "openalex", 

1757 } 

1758 

1759 key = (name_lower, issn or "") 

1760 prev = seen.get(key) 

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

1762 seen[key] = rec 

1763 

1764 if skipped: 

1765 logger.warning( 

1766 f"OpenAlex sources: skipped {skipped:,} of {len(sources):,} " 

1767 "records with a blank or non-string name" 

1768 ) 

1769 kept = len(sources) - skipped 

1770 if len(sources) >= _MIN_BUILD_SOURCES and kept < _MIN_BUILD_SOURCES: 1770 ↛ 1771line 1770 didn't jump to line 1771 because the condition on line 1770 was never true

1771 raise RuntimeError( 

1772 f"OpenAlex sources: suspiciously few records survived " 

1773 f"ingestion ({kept:,} < {_MIN_BUILD_SOURCES:,} of " 

1774 f"{len(sources):,} fetched); refusing to overwrite existing data" 

1775 ) 

1776 

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

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

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

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

1781 doaj_added = 0 

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

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

1784 if not name: 

1785 continue 

1786 name_lower = normalize_name(name) 

1787 if name_lower in openalex_names: 

1788 continue 

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

1790 quality = derive_quality_score( 

1791 h_index=None, 

1792 is_in_doaj=True, 

1793 is_predatory=False, 

1794 source_type="journal", 

1795 ) 

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

1797 "name": name, 

1798 "name_lower": name_lower, 

1799 "issn": issn, 

1800 "openalex_source_id": None, 

1801 "source_type": "journal", 

1802 "publisher": publisher, 

1803 "h_index": None, 

1804 "impact_factor": None, 

1805 "cited_by_count": None, 

1806 "quartile": None, 

1807 "quality": quality, 

1808 "is_in_doaj": True, 

1809 "is_predatory": False, 

1810 "predatory_source": None, 

1811 "score_source": "doaj", 

1812 } 

1813 openalex_names.add(name_lower) 

1814 doaj_added += 1 

1815 

1816 records = list(seen.values()) 

1817 

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

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

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

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

1822 # given the license constraint that ruled out SJR. 

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

1824 for r in records: 

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

1826 continue # NULL quartile for entries without citation data 

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

1828 for type_records in by_type.values(): 

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

1830 n = len(type_records) 

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

1832 continue 

1833 for rank, r in enumerate(type_records): 

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

1835 if pct >= 0.75: 

1836 r["quartile"] = "Q1" 

1837 elif pct >= 0.50: 

1838 r["quartile"] = "Q2" 

1839 elif pct >= 0.25: 

1840 r["quartile"] = "Q3" 

1841 else: 

1842 r["quartile"] = "Q4" 

1843 

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

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

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

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

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

1849 # stored column should agree with the live score. 

1850 for r in records: 

1851 r["quality"] = derive_quality_score( 

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

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

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

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

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

1857 ) 

1858 

1859 logger.info( 

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

1861 ) 

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

1863 session.bulk_insert_mappings( 

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

1865 ) 

1866 

1867 

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

1869 records: list[dict] = [] 

1870 skipped = 0 

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

1872 name = _as_text(compact.get("n")) 

1873 if not name: 

1874 skipped += 1 

1875 continue 

1876 records.append( 

1877 { 

1878 "openalex_id": inst_id, 

1879 "name": name, 

1880 "name_lower": normalize_name(name), 

1881 "ror_id": _as_text(compact.get("r")), 

1882 "country": _as_text(compact.get("c")), 

1883 "type": _as_text(compact.get("t")), 

1884 "h_index": _as_number(compact.get("h")), 

1885 "impact_factor": _as_number(compact.get("if")), 

1886 "works_count": _as_number(compact.get("w")), 

1887 "cited_by_count": _as_number(compact.get("cb")), 

1888 } 

1889 ) 

1890 if skipped: 

1891 logger.warning( 

1892 f"OpenAlex institutions: skipped {skipped:,} of " 

1893 f"{len(institutions):,} records with a blank or non-string name" 

1894 ) 

1895 kept = len(records) 

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

1897 len(institutions) >= _MIN_BUILD_INSTITUTIONS 

1898 and kept < _MIN_BUILD_INSTITUTIONS 

1899 ): 

1900 raise RuntimeError( 

1901 f"OpenAlex institutions: suspiciously few records survived " 

1902 f"ingestion ({kept:,} < {_MIN_BUILD_INSTITUTIONS:,} of " 

1903 f"{len(institutions):,} fetched); refusing to overwrite " 

1904 "existing data" 

1905 ) 

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

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

1908 session.bulk_insert_mappings( 

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

1910 ) 

1911 

1912 

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

1914 records: list[dict] = [] 

1915 seen: set[str] = set() 

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

1917 norm = normalize_name(abbrev) 

1918 if not norm or norm in seen: 

1919 continue 

1920 seen.add(norm) 

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

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

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

1924 session.bulk_insert_mappings( 

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

1926 ) 

1927 

1928 

1929# --------------------------------------------------------------------------- 

1930# Backwards-compat shim for the old build_reference_db name 

1931# --------------------------------------------------------------------------- 

1932 

1933 

1934def build_reference_db( 

1935 data_dir: Optional[Path] = None, 

1936 output_path: Optional[Path] = None, 

1937) -> None: 

1938 """Deprecated alias for `build_db`.""" 

1939 build_db(data_dir=data_dir, output_path=output_path)