Coverage for oc_meta / run / patches / fix_omid_mismatches.py: 0%

271 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-07-25 10:39 +0000

1#!/usr/bin/python 

2 

3# SPDX-FileCopyrightText: 2026 Arcangelo Massari <arcangelo.massari@unibo.it> 

4# 

5# SPDX-License-Identifier: ISC 

6 

7from __future__ import annotations 

8 

9import argparse 

10import json 

11import os 

12import signal 

13import time 

14from collections import defaultdict 

15from dataclasses import dataclass, field 

16from urllib.parse import quote 

17 

18import requests 

19import yaml 

20from rapidfuzz.distance import Levenshtein 

21from rich_argparse import RichHelpFormatter 

22 

23from oc_meta.core.editor import MetaEditor 

24from oc_meta.run.merge.entities import EntityMerger, MergeRow 

25from oc_meta.lib.bibliographic_matching import ( 

26 CROSSREF_RATE_LIMIT, 

27 DATACITE_DOI, 

28 DATACITE_HAS_ID, 

29 DATACITE_USES_SCHEME, 

30 LITERAL_HAS_VALUE, 

31 MATCHING_THRESHOLD, 

32 compute_matching_score, 

33 fetch_crossref_metadata, 

34 fetch_triplestore_metadata, 

35) 

36from oc_meta.lib.console import console, create_progress 

37from oc_meta.lib.sparql import execute_sparql 

38 

39VENUE_TITLE_THRESHOLD = 0.6 

40 

41_stop_requested = False 

42 

43 

44def _handle_signal(_signum: int, _frame: object) -> None: 

45 global _stop_requested 

46 _stop_requested = True 

47 console.print("[yellow]Interrupt received, finishing current operation...[/yellow]") 

48 

49 

50def _sparql_escape(value: str) -> str: 

51 return value.replace("\\", "\\\\").replace('"', '\\"') 

52 

53 

54@dataclass 

55class MismatchCase: 

56 doi_value: str 

57 expected_entity: str 

58 found_entity: str 

59 column: str 

60 category: str = "" 

61 surviving_entity: str = "" 

62 duplicate_entities: list[str] = field(default_factory=list) 

63 duplicate_id_entities: list[str] = field(default_factory=list) 

64 expected_doi: str = "" 

65 found_doi: str = "" 

66 action: str = "" 

67 reason: str = "" 

68 validation: str = "" 

69 occurrence_count: int = 1 

70 

71 

72def _get_doi(endpoint: str, entity_uri: str) -> str: 

73 query = f""" 

74 SELECT ?val WHERE {{ 

75 <{entity_uri}> <{DATACITE_HAS_ID}> ?id . 

76 ?id <{DATACITE_USES_SCHEME}> <{DATACITE_DOI}> . 

77 ?id <{LITERAL_HAS_VALUE}> ?val . 

78 }} 

79 LIMIT 1 

80 """ 

81 result = execute_sparql(endpoint, query) 

82 bindings = result["results"]["bindings"] 

83 return bindings[0]["val"]["value"] if bindings else "" 

84 

85 

86def _find_id_entity(endpoint: str, br_uri: str, doi_value: str) -> str: 

87 escaped = _sparql_escape(doi_value) 

88 query = f""" 

89 SELECT ?id_entity WHERE {{ 

90 <{br_uri}> <{DATACITE_HAS_ID}> ?id_entity . 

91 ?id_entity <{DATACITE_USES_SCHEME}> <{DATACITE_DOI}> . 

92 ?id_entity <{LITERAL_HAS_VALUE}> "{escaped}" . 

93 }} 

94 LIMIT 1 

95 """ 

96 result = execute_sparql(endpoint, query) 

97 bindings = result["results"]["bindings"] 

98 return bindings[0]["id_entity"]["value"] if bindings else "" 

99 

100 

101def _find_entity_by_doi(endpoint: str, doi_value: str) -> str: 

102 escaped = _sparql_escape(doi_value) 

103 query = f""" 

104 SELECT ?entity WHERE {{ 

105 ?id <{LITERAL_HAS_VALUE}> "{escaped}" . 

106 ?id <{DATACITE_USES_SCHEME}> <{DATACITE_DOI}> . 

107 ?entity <{DATACITE_HAS_ID}> ?id . 

108 }} 

109 LIMIT 1 

110 """ 

111 result = execute_sparql(endpoint, query) 

112 bindings = result["results"]["bindings"] 

113 return bindings[0]["entity"]["value"] if bindings else "" 

114 

115 

116def load_errors(check_results_path: str) -> list[dict]: 

117 with open(check_results_path) as f: 

118 data = json.load(f) 

119 return [ 

120 e 

121 for e in data["errors"] 

122 if e["type"] == "omid_mismatch" and e["schema"] == "doi" 

123 ] 

124 

125 

126def build_cases(errors: list[dict]) -> list[MismatchCase]: 

127 key_to_case: dict[str, MismatchCase] = {} 

128 for e in errors: 

129 found = e["found_omids"][0] 

130 key = f"{e['expected_omid']}|{found}" 

131 if key not in key_to_case: 

132 key_to_case[key] = MismatchCase( 

133 doi_value=e["value"], 

134 expected_entity=e["expected_omid"], 

135 found_entity=found, 

136 column=e["column"], 

137 ) 

138 else: 

139 key_to_case[key].occurrence_count += 1 

140 return list(key_to_case.values()) 

141 

142 

143def classify_and_resolve( 

144 cases: list[MismatchCase], endpoint: str 

145) -> list[MismatchCase]: 

146 with create_progress() as progress: 

147 task = progress.add_task("Classifying cases", total=len(cases)) 

148 

149 for case in cases: 

150 if _stop_requested: 

151 break 

152 

153 case.expected_doi = _get_doi(endpoint, case.expected_entity) 

154 case.found_doi = _get_doi(endpoint, case.found_entity) 

155 

156 if "sj....bjc" in case.doi_value: 

157 _classify_bjc(case, endpoint) 

158 elif "(asce)" in case.doi_value.lower(): 

159 _classify_asce(case, endpoint) 

160 elif case.expected_doi.endswith("<"): 

161 _classify_angle_bracket(case, endpoint) 

162 elif case.expected_doi.endswith(";") and not case.found_doi.endswith(";"): 

163 case.category = "false_positive" 

164 case.action = "false_positive" 

165 case.reason = f"Semicolon is part of DOI '{case.expected_doi}'" 

166 elif "#" in case.expected_doi and "#" not in case.found_doi: 

167 case.category = "false_positive" 

168 case.action = "false_positive" 

169 case.reason = f"Fragment is part of DOI '{case.expected_doi}'" 

170 elif ( 

171 case.expected_doi == case.doi_value + "." 

172 or case.expected_doi == case.doi_value + "..." 

173 or case.expected_doi.rstrip(".") == case.doi_value.rstrip(".") 

174 ): 

175 _classify_trailing_period(case, endpoint) 

176 else: 

177 case.category = "manual_review" 

178 case.action = "manual_review" 

179 case.reason = ( 

180 f"Unrecognized pattern: expected='{case.expected_doi}'" 

181 f" found='{case.found_doi}'" 

182 ) 

183 

184 progress.advance(task) 

185 

186 return cases 

187 

188 

189def _classify_trailing_period(case: MismatchCase, endpoint: str) -> None: 

190 case.category = "trailing_period" 

191 case.surviving_entity = case.expected_entity 

192 case.duplicate_entities = [case.found_entity] 

193 

194 found_id = _find_id_entity(endpoint, case.found_entity, case.found_doi) 

195 case.duplicate_id_entities = [found_id] if found_id else [] 

196 case.action = "merge" 

197 case.reason = ( 

198 f"DOI '{case.expected_doi}' (with .) is correct; " 

199 f"'{case.found_doi}' (without .) is duplicate" 

200 ) 

201 

202 

203def _classify_bjc(case: MismatchCase, endpoint: str) -> None: 

204 case.category = "bjc" 

205 correct_doi = case.doi_value.replace("sj....bjc", "sj.bjc") 

206 correct_entity = _find_entity_by_doi(endpoint, correct_doi) 

207 

208 if not correct_entity: 

209 correct_doi_dot = correct_doi + "." 

210 correct_entity = _find_entity_by_doi(endpoint, correct_doi_dot) 

211 

212 if not correct_entity: 

213 case.action = "manual_review" 

214 case.reason = f"Correct entity for '{correct_doi}' not found in triplestore" 

215 return 

216 

217 case.surviving_entity = correct_entity 

218 case.duplicate_entities = [case.expected_entity, case.found_entity] 

219 

220 exp_id = _find_id_entity(endpoint, case.expected_entity, case.expected_doi) 

221 found_id = _find_id_entity(endpoint, case.found_entity, case.found_doi) 

222 case.duplicate_id_entities = [x for x in [exp_id, found_id] if x] 

223 case.action = "merge" 

224 case.reason = ( 

225 f"BJC: merge corrupted entities into correct entity " 

226 f"{correct_entity} (DOI {correct_doi})" 

227 ) 

228 

229 

230def _classify_asce(case: MismatchCase, endpoint: str) -> None: 

231 case.category = "asce" 

232 case.surviving_entity = case.expected_entity 

233 case.duplicate_entities = [case.found_entity] 

234 

235 found_id = _find_id_entity(endpoint, case.found_entity, case.found_doi) 

236 case.duplicate_id_entities = [found_id] if found_id else [] 

237 case.action = "merge" 

238 case.reason = ( 

239 f"ASCE: full DOI '{case.expected_doi}' on expected; " 

240 f"truncated '{case.found_doi}' on found" 

241 ) 

242 

243 

244def _classify_angle_bracket(case: MismatchCase, endpoint: str) -> None: 

245 case.category = "angle_bracket" 

246 case.surviving_entity = case.expected_entity 

247 case.duplicate_entities = [case.found_entity] 

248 

249 found_id = _find_id_entity(endpoint, case.found_entity, case.found_doi) 

250 case.duplicate_id_entities = [found_id] if found_id else [] 

251 case.action = "merge" 

252 case.reason = ( 

253 f"'<' is part of DOI '{case.expected_doi}'; '{case.found_doi}' is truncated" 

254 ) 

255 

256 

257def _doi_resolves(doi: str, mailto: str) -> bool: 

258 time.sleep(1.0 / CROSSREF_RATE_LIMIT) 

259 try: 

260 r = requests.head( 

261 f"https://doi.org/{quote(doi, safe='')}", 

262 headers={"User-Agent": f"oc_meta/mailto:{mailto}"}, 

263 allow_redirects=False, 

264 timeout=10, 

265 ) 

266 return r.status_code in (301, 302, 303) 

267 except requests.RequestException: 

268 return False 

269 

270 

271def validate_cases( 

272 cases: list[MismatchCase], endpoint: str, mailto: str 

273) -> list[MismatchCase]: 

274 crossref_cache: dict[str, dict | None] = {} 

275 

276 with create_progress() as progress: 

277 task = progress.add_task("Validating via API", total=len(cases)) 

278 

279 for case in cases: 

280 if _stop_requested: 

281 break 

282 

283 if case.action != "merge": 

284 progress.advance(task) 

285 continue 

286 

287 surviving_doi = "" 

288 if case.category == "bjc": 

289 surviving_doi = case.doi_value.replace("sj....bjc", "sj.bjc") 

290 else: 

291 surviving_doi = case.expected_doi 

292 

293 if not _doi_resolves(surviving_doi, mailto): 

294 case.action = "manual_review" 

295 case.reason += ( 

296 f" [VALIDATION FAILED: DOI '{surviving_doi}'" 

297 f" does not resolve on doi.org]" 

298 ) 

299 case.validation = "doi_not_resolved" 

300 progress.advance(task) 

301 continue 

302 

303 if case.column == "venue": 

304 ts_surv = fetch_triplestore_metadata(endpoint, case.surviving_entity) 

305 dup_entity = case.duplicate_entities[0] 

306 ts_dup = fetch_triplestore_metadata(endpoint, dup_entity) 

307 if ts_surv and ts_dup and ts_surv["title"] and ts_dup["title"]: 

308 max_len = max(len(ts_surv["title"]), len(ts_dup["title"])) 

309 dist = Levenshtein.distance(ts_surv["title"], ts_dup["title"]) 

310 sim = 1.0 - dist / max_len 

311 if sim >= VENUE_TITLE_THRESHOLD: 

312 case.validation = f"venue_title_match:{sim:.2f}" 

313 else: 

314 case.action = "manual_review" 

315 case.reason += ( 

316 f" [VALIDATION FAILED: venue title similarity {sim:.2f}]" 

317 ) 

318 case.validation = f"venue_title_mismatch:{sim:.2f}" 

319 else: 

320 case.action = "manual_review" 

321 case.reason += ( 

322 " [HELD: no venue titles to confirm the two" 

323 " entities are the same journal]" 

324 ) 

325 case.validation = "doi_resolved_no_metadata" 

326 else: 

327 cr_meta = fetch_crossref_metadata(surviving_doi, crossref_cache, mailto) 

328 if cr_meta: 

329 best_score = 0.0 

330 any_ts_meta = False 

331 entities_to_check = [ 

332 *case.duplicate_entities, 

333 case.surviving_entity, 

334 ] 

335 for ent in entities_to_check: 

336 ts_meta = fetch_triplestore_metadata(endpoint, ent) 

337 if not ts_meta or not ts_meta["title"]: 

338 continue 

339 any_ts_meta = True 

340 score = compute_matching_score(ts_meta, cr_meta) 

341 if score > best_score: 

342 best_score = score 

343 

344 if not any_ts_meta: 

345 case.action = "manual_review" 

346 case.reason += ( 

347 " [HELD: no triplestore metadata to confirm" 

348 " the two entities are the same work]" 

349 ) 

350 case.validation = "doi_resolved_no_ts_metadata" 

351 elif best_score >= MATCHING_THRESHOLD: 

352 case.validation = f"crossref_match:{best_score:.1f}" 

353 else: 

354 case.action = "manual_review" 

355 case.reason += ( 

356 f" [VALIDATION FAILED: best score" 

357 f" {best_score:.1f} < {MATCHING_THRESHOLD}]" 

358 ) 

359 case.validation = f"crossref_mismatch:{best_score:.1f}" 

360 else: 

361 case.action = "manual_review" 

362 case.reason += ( 

363 " [HELD: DOI not found on Crossref, cannot" 

364 " confirm the two entities are the same work]" 

365 ) 

366 case.validation = "doi_resolved_not_on_crossref" 

367 

368 progress.advance(task) 

369 

370 return cases 

371 

372 

373def build_report(cases: list[MismatchCase]) -> dict: 

374 actions: dict[str, list[dict]] = defaultdict(list) 

375 for case in cases: 

376 entry = { 

377 "doi_value": case.doi_value, 

378 "category": case.category, 

379 "expected_entity": case.expected_entity, 

380 "expected_doi": case.expected_doi, 

381 "found_entity": case.found_entity, 

382 "found_doi": case.found_doi, 

383 "surviving_entity": case.surviving_entity, 

384 "duplicate_entities": case.duplicate_entities, 

385 "duplicate_id_entities": case.duplicate_id_entities, 

386 "reason": case.reason, 

387 "validation": case.validation, 

388 "occurrence_count": case.occurrence_count, 

389 } 

390 actions[case.action].append(entry) 

391 

392 categories = defaultdict(int) 

393 for case in cases: 

394 categories[case.category] += 1 

395 

396 return { 

397 "summary": { 

398 "total_unique_cases": len(cases), 

399 "total_occurrences": sum(c.occurrence_count for c in cases), 

400 "merge": len(actions.get("merge", [])), 

401 "false_positive": len(actions.get("false_positive", [])), 

402 "manual_review": len(actions.get("manual_review", [])), 

403 "by_category": dict(categories), 

404 }, 

405 "merge": actions.get("merge", []), 

406 "false_positive": actions.get("false_positive", []), 

407 "manual_review": actions.get("manual_review", []), 

408 } 

409 

410 

411def _load_progress(path: str) -> set[str]: 

412 if os.path.exists(path): 

413 with open(path) as f: 

414 return set(json.load(f)) 

415 return set() 

416 

417 

418def _save_progress(path: str, completed: set[str]) -> None: 

419 with open(path, "w") as f: 

420 json.dump(sorted(completed), f) 

421 

422 

423def execute_merges( 

424 cases: list[MismatchCase], 

425 config_path: str, 

426 resp_agent: str, 

427) -> None: 

428 actionable = [c for c in cases if c.action == "merge"] 

429 if not actionable: 

430 console.print("[green]No merges to execute.[/green]") 

431 return 

432 

433 rows: list[MergeRow] = [ 

434 { 

435 "surviving_entity": case.surviving_entity, 

436 "merged_entities": list(case.duplicate_entities), 

437 } 

438 for case in actionable 

439 ] 

440 EntityMerger(config_path, resp_agent).process_rows(rows) 

441 

442 console.print(f"Merged: {len(actionable)} cases") 

443 console.print( 

444 "[bold]The merge wrote RDF files only. Re-index the triplestore from the " 

445 "files, then rerun with --cleanup to remove the duplicate identifiers.[/bold]" 

446 ) 

447 

448 

449def execute_cleanup( 

450 report_file: str, 

451 config_path: str, 

452 resp_agent: str, 

453 progress_file: str, 

454) -> None: 

455 with open(report_file) as f: 

456 report = json.load(f) 

457 

458 entries = [e for e in report["merge"] if e["duplicate_id_entities"]] 

459 if not entries: 

460 console.print("[green]No identifiers to clean up.[/green]") 

461 return 

462 

463 editor = MetaEditor(config_path, resp_agent) 

464 signal.signal(signal.SIGINT, _handle_signal) 

465 signal.signal(signal.SIGTERM, _handle_signal) 

466 

467 completed = _load_progress(progress_file) 

468 if completed: 

469 console.print(f"Resuming: {len(completed)} already processed") 

470 

471 with create_progress() as progress: 

472 task = progress.add_task("Removing duplicate identifiers", total=len(entries)) 

473 for entry in entries: 

474 if _stop_requested: 

475 console.print("[yellow]Interrupted, saving progress...[/yellow]") 

476 break 

477 

478 entry_key = ( 

479 f"{entry['surviving_entity']}|{','.join(entry['duplicate_entities'])}" 

480 ) 

481 if entry_key in completed: 

482 progress.advance(task) 

483 continue 

484 

485 for id_ent in entry["duplicate_id_entities"]: 

486 editor.delete(entry["surviving_entity"], DATACITE_HAS_ID, id_ent) 

487 editor.delete(id_ent) 

488 

489 completed.add(entry_key) 

490 _save_progress(progress_file, completed) 

491 progress.advance(task) 

492 

493 if not _stop_requested and os.path.exists(progress_file): 

494 os.remove(progress_file) 

495 

496 

497def main() -> None: # pragma: no cover 

498 parser = argparse.ArgumentParser( 

499 description="Fix omid_mismatch errors by merging duplicate entities", 

500 formatter_class=RichHelpFormatter, 

501 ) 

502 parser.add_argument( 

503 "-c", "--config", required=True, help="Path to meta_config.yaml" 

504 ) 

505 parser.add_argument("--check-results", help="Path to check_results.json") 

506 parser.add_argument("-r", "--resp-agent", help="Responsible agent URI") 

507 parser.add_argument( 

508 "--mailto", 

509 help="Email for the Crossref / doi.org polite pool User-Agent", 

510 ) 

511 mode = parser.add_mutually_exclusive_group(required=True) 

512 mode.add_argument( 

513 "--dry-run", 

514 action="store_true", 

515 dest="dry_run", 

516 help="Report only, no modifications", 

517 ) 

518 mode.add_argument( 

519 "--no-dry-run", 

520 action="store_true", 

521 dest="no_dry_run", 

522 help="Merge the duplicates into the RDF files", 

523 ) 

524 mode.add_argument( 

525 "--cleanup", 

526 action="store_true", 

527 help="Remove the duplicate identifiers listed in the report " 

528 "(run after re-indexing the triplestore from the merged files)", 

529 ) 

530 parser.add_argument( 

531 "--report-file", 

532 default="fix_omid_mismatches_report.json", 

533 help="Output report path", 

534 ) 

535 parser.add_argument( 

536 "--progress-file", 

537 default="fix_omid_mismatches_progress.json", 

538 help="Progress tracking file", 

539 ) 

540 args = parser.parse_args() 

541 

542 if (args.no_dry_run or args.cleanup) and not args.resp_agent: 

543 parser.error("--resp-agent is required with --no-dry-run and --cleanup") 

544 

545 if args.cleanup: 

546 execute_cleanup( 

547 args.report_file, args.config, args.resp_agent, args.progress_file 

548 ) 

549 return 

550 

551 if not args.check_results or not args.mailto: 

552 parser.error( 

553 "--check-results and --mailto are required with --dry-run and --no-dry-run" 

554 ) 

555 

556 with open(args.config) as f: 

557 settings = yaml.safe_load(f) 

558 

559 endpoint = settings["triplestore_url"] 

560 

561 console.print("[bold]Loading check_results.json...[/bold]") 

562 errors = load_errors(args.check_results) 

563 console.print(f" omid_mismatch DOI errors: {len(errors)}") 

564 

565 console.print("\n[bold]Building unique cases...[/bold]") 

566 cases = build_cases(errors) 

567 console.print(f" Unique (expected, found) pairs: {len(cases)}") 

568 

569 console.print("\n[bold]Classifying and resolving...[/bold]") 

570 cases = classify_and_resolve(cases, endpoint) 

571 

572 console.print("\n[bold]Validating surviving DOIs via API...[/bold]") 

573 cases = validate_cases(cases, endpoint, args.mailto) 

574 

575 report = build_report(cases) 

576 with open(args.report_file, "w") as f: 

577 json.dump(report, f, indent=2) 

578 

579 summary = report["summary"] 

580 console.print("\n[bold]Summary:[/bold]") 

581 console.print(f" Unique cases: {summary['total_unique_cases']}") 

582 console.print(f" Total occurrences: {summary['total_occurrences']}") 

583 console.print(f" Merge: {summary['merge']}") 

584 console.print(f" False positive: {summary['false_positive']}") 

585 console.print(f" Manual review: {summary['manual_review']}") 

586 console.print(f" By category: {summary['by_category']}") 

587 console.print(f"\nReport: {args.report_file}") 

588 

589 if args.no_dry_run: 

590 if summary["merge"]: 

591 console.print("\n[bold]Applying merges...[/bold]") 

592 execute_merges(cases, args.config, args.resp_agent) 

593 else: 

594 console.print( 

595 f"\n[dim]Dry run complete. {summary['merge']} merges pending." 

596 f" Use --no-dry-run to apply.[/dim]" 

597 ) 

598 

599 

600if __name__ == "__main__": 

601 main()