Coverage for oc_meta / run / patches / fix_duplicate_part_of.py: 90%

280 statements  

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

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

2# 

3# SPDX-License-Identifier: ISC 

4 

5from __future__ import annotations 

6 

7import argparse 

8import json 

9import multiprocessing 

10import os 

11import signal 

12from collections import defaultdict 

13from concurrent.futures import ProcessPoolExecutor, as_completed 

14from dataclasses import dataclass 

15from zipfile import ZipFile 

16 

17import orjson 

18import yaml 

19from oc_ocdm.graph import GraphSet 

20from oc_ocdm.support import get_prefix 

21from rich_argparse import RichHelpFormatter 

22 

23from oc_meta.core.editor import MetaEditor 

24from oc_meta.lib.console import console, create_progress 

25from oc_meta.lib.file_manager import collect_files, collect_zip_files, find_rdf_file 

26 

27FRBR_PART_OF = "http://purl.org/vocab/frbr/core#partOf" 

28HAS_IDENTIFIER = "http://purl.org/spar/datacite/hasIdentifier" 

29USES_ID_SCHEME = "http://purl.org/spar/datacite/usesIdentifierScheme" 

30HAS_LITERAL_VALUE = ( 

31 "http://www.essepuntato.it/2010/06/literalreification/hasLiteralValue" 

32) 

33DCTERMS_TITLE = "http://purl.org/dc/terms/title" 

34FABIO_EXPRESSION = "http://purl.org/spar/fabio/Expression" 

35 

36BATCH_SIZE = 100 

37 

38_stop_requested = False 

39 

40 

41def _worker_init() -> None: 

42 signal.signal(signal.SIGINT, signal.SIG_IGN) 

43 

44 

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

46 global _stop_requested 

47 _stop_requested = True 

48 console.print("[yellow]Interrupt received, finishing current entity...[/yellow]") 

49 

50 

51def _iter_entities(files: list, zip_output: bool): 

52 for fpath in files: 

53 if zip_output: 

54 with ZipFile(fpath, "r") as zf: 

55 data = orjson.loads(zf.read(zf.namelist()[0])) 

56 else: 

57 with open(fpath, "rb") as f: 

58 data = orjson.loads(f.read()) 

59 for graph in data: 

60 for entity in graph.get("@graph", []): 

61 yield entity 

62 

63 

64def _read_entity( 

65 uri: str, 

66 rdf_dir: str, 

67 dir_split: int, 

68 items_per_file: int, 

69 zip_output: bool, 

70) -> dict | None: 

71 fpath = find_rdf_file(uri, rdf_dir, dir_split, items_per_file, zip_output) 

72 if not os.path.exists(fpath): 

73 return None 

74 if zip_output: 

75 with ZipFile(fpath, "r") as zf: 

76 data = orjson.loads(zf.read(zf.namelist()[0])) 

77 else: 

78 with open(fpath, "rb") as f: 

79 data = orjson.loads(f.read()) 

80 for graph in data: 

81 for entity in graph.get("@graph", []): 

82 if entity["@id"] == uri: 

83 return entity 

84 return None 

85 

86 

87@dataclass 

88class ResolvedCase: 

89 br_uri: str 

90 correct_part_of: str | None 

91 to_remove: list[str] 

92 method: str 

93 reason: str 

94 

95 

96# ── Phase 1: Scan ── 

97 

98 

99def _scan_br_batch(files: list[str], zip_output: bool) -> list[tuple[str, list[str]]]: 

100 results: list[tuple[str, list[str]]] = [] 

101 for entity in _iter_entities(files, zip_output): 

102 if FRBR_PART_OF in entity: 

103 parents = entity[FRBR_PART_OF] 

104 if len(parents) > 1: 

105 results.append((entity["@id"], [p["@id"] for p in parents])) 

106 return results 

107 

108 

109def scan_duplicate_part_of( 

110 rdf_dir: str, 

111 zip_output: bool, 

112 workers: int = 4, 

113 batch_size: int = BATCH_SIZE, 

114) -> list[tuple[str, list[str]]]: 

115 br_dir = os.path.join(rdf_dir, "br") 

116 if zip_output: 

117 br_files = collect_zip_files(br_dir, only_data=True) 

118 else: 

119 br_files = collect_files(br_dir, "*.json", lambda p: "prov" not in p) 

120 

121 batches = [ 

122 br_files[i : i + batch_size] for i in range(0, len(br_files), batch_size) 

123 ] 

124 all_results: list[tuple[str, list[str]]] = [] 

125 

126 ctx = multiprocessing.get_context("forkserver") 

127 with create_progress() as progress: 

128 task = progress.add_task("Scanning BR files", total=len(br_files)) 

129 executor = ProcessPoolExecutor( 

130 max_workers=workers, initializer=_worker_init, mp_context=ctx 

131 ) 

132 try: 

133 futures = { 

134 executor.submit(_scan_br_batch, batch, zip_output): batch 

135 for batch in batches 

136 } 

137 for future in as_completed(futures): 

138 all_results.extend(future.result()) 

139 progress.advance(task, len(futures[future])) 

140 finally: 

141 executor.shutdown(wait=True) 

142 

143 return all_results 

144 

145 

146# ── Phase 2: Build chain map (targeted file reads) ── 

147 

148 

149def _get_title(entity: dict) -> str: 

150 titles = entity.get(DCTERMS_TITLE, []) 

151 if titles: 

152 return titles[0].get("@value", "") 

153 return "" 

154 

155 

156def _get_types(entity: dict) -> frozenset[str]: 

157 return frozenset(entity.get("@type", [])) 

158 

159 

160def _type_label(types: frozenset[str]) -> str: 

161 return ", ".join( 

162 sorted(t.rsplit("/", 1)[-1] for t in types if t != FABIO_EXPRESSION) 

163 ) 

164 

165 

166def build_chain_map( 

167 container_uris: set[str], 

168 rdf_dir: str, 

169 dir_split: int, 

170 items_per_file: int, 

171 zip_output: bool, 

172) -> tuple[dict[str, list[str]], dict[str, tuple[str, frozenset[str]]]]: 

173 chain_map: dict[str, list[str]] = {} 

174 entity_meta: dict[str, tuple[str, frozenset[str]]] = {} 

175 needed = set(container_uris) 

176 depth = 0 

177 

178 with create_progress() as progress: 

179 task = progress.add_task("Building chain map", total=len(needed)) 

180 while needed: 

181 depth += 1 

182 file_to_uris: dict[str, set[str]] = defaultdict(set) 

183 for uri in needed: 

184 fpath = find_rdf_file( 

185 uri, rdf_dir, dir_split, items_per_file, zip_output 

186 ) 

187 file_to_uris[fpath].add(uri) 

188 

189 next_needed: set[str] = set() 

190 for fpath, uris in file_to_uris.items(): 

191 if not os.path.exists(fpath): 

192 progress.advance(task, len(uris)) 

193 continue 

194 for entity in _iter_entities([fpath], zip_output): 

195 eid = entity["@id"] 

196 if eid in uris: 

197 entity_meta[eid] = (_get_title(entity), _get_types(entity)) 

198 if FRBR_PART_OF in entity: 

199 parents = [p["@id"] for p in entity[FRBR_PART_OF]] 

200 chain_map[eid] = parents 

201 for p in parents: 

202 if p not in chain_map and p not in needed: 

203 next_needed.add(p) 

204 progress.advance(task, len(uris)) 

205 

206 if next_needed: 

207 progress.update( 

208 task, 

209 description=f"Building chain map (depth {depth + 1})", 

210 total=(progress.tasks[task].total or 0) + len(next_needed), 

211 ) 

212 needed = next_needed 

213 

214 return chain_map, entity_meta 

215 

216 

217# ── Phase 3: Classify & Resolve ── 

218 

219 

220def _follow_to_venue(uri: str, chain_map: dict[str, list[str]]) -> str: 

221 visited: set[str] = set() 

222 current = uri 

223 while current in chain_map: 

224 if current in visited: 

225 break 

226 visited.add(current) 

227 parents = chain_map[current] 

228 if len(parents) != 1: 

229 break 

230 current = parents[0] 

231 return current 

232 

233 

234def resolve_cases( 

235 raw_cases: list[tuple[str, list[str]]], 

236 chain_map: dict[str, list[str]], 

237 entity_meta: dict[str, tuple[str, frozenset[str]]], 

238) -> list[ResolvedCase]: 

239 resolved: list[ResolvedCase] = [] 

240 for br_uri, container_uris in raw_cases: 

241 venues: list[str] = [] 

242 for c_uri in container_uris: 

243 venues.append(_follow_to_venue(c_uri, chain_map)) 

244 

245 venue_set = set(venues) 

246 if len(venue_set) == 1: 

247 sorted_containers = sorted(container_uris) 

248 resolved.append( 

249 ResolvedCase( 

250 br_uri=br_uri, 

251 correct_part_of=sorted_containers[0], 

252 to_remove=sorted_containers[1:], 

253 method="same_venue", 

254 reason=f"All chains converge to {venues[0]}", 

255 ) 

256 ) 

257 continue 

258 

259 venue_keys: set[tuple[str, frozenset[str]]] = set() 

260 for v_uri in venue_set: 

261 if v_uri in entity_meta: 

262 title, types = entity_meta[v_uri] 

263 normalized = " ".join(title.strip().lower().split()) 

264 venue_keys.add((normalized, types)) 

265 else: 

266 venue_keys.add((v_uri, frozenset())) 

267 

268 if len(venue_keys) == 1: 

269 sorted_containers = sorted(container_uris) 

270 resolved.append( 

271 ResolvedCase( 

272 br_uri=br_uri, 

273 correct_part_of=sorted_containers[0], 

274 to_remove=sorted_containers[1:], 

275 method="equivalent_venues", 

276 reason=( 

277 f"Chains reach different URIs " 

278 f"({', '.join(sorted(venue_set))}) " 

279 f"but same title/type" 

280 ), 

281 ) 

282 ) 

283 else: 

284 venue_descs = [] 

285 for v_uri in sorted(venue_set): 

286 if v_uri in entity_meta: 

287 title, types = entity_meta[v_uri] 

288 venue_descs.append(f"'{title}' ({_type_label(types)})") 

289 else: 

290 venue_descs.append(v_uri) 

291 resolved.append( 

292 ResolvedCase( 

293 br_uri=br_uri, 

294 correct_part_of=None, 

295 to_remove=[], 

296 method="manual_review", 

297 reason=( 

298 f"Chains reach different venues: {' vs '.join(venue_descs)}" 

299 ), 

300 ) 

301 ) 

302 return resolved 

303 

304 

305# ── Enrich manual review cases ── 

306 

307 

308def _batch_read_entities( 

309 uris: set[str], 

310 rdf_dir: str, 

311 dir_split: int, 

312 items_per_file: int, 

313 zip_output: bool, 

314) -> dict[str, dict]: 

315 file_to_uris: dict[str, set[str]] = defaultdict(set) 

316 for uri in uris: 

317 file_to_uris[ 

318 find_rdf_file(uri, rdf_dir, dir_split, items_per_file, zip_output) 

319 ].add(uri) 

320 

321 result: dict[str, dict] = {} 

322 for fpath, target_uris in file_to_uris.items(): 

323 if not os.path.exists(fpath): 

324 continue 

325 for entity in _iter_entities([fpath], zip_output): 

326 if entity["@id"] in target_uris: 

327 result[entity["@id"]] = entity 

328 return result 

329 

330 

331def enrich_manual_review( 

332 manual_cases: list[ResolvedCase], 

333 raw_case_map: dict[str, list[str]], 

334 entity_meta: dict[str, tuple[str, frozenset[str]]], 

335 rdf_dir: str, 

336 dir_split: int, 

337 items_per_file: int, 

338 zip_output: bool, 

339) -> list[dict]: 

340 with create_progress() as progress: 

341 task = progress.add_task("Enriching manual cases", total=3) 

342 

343 br_uris = {res.br_uri for res in manual_cases} 

344 br_entities = _batch_read_entities( 

345 br_uris, rdf_dir, dir_split, items_per_file, zip_output 

346 ) 

347 progress.advance(task) 

348 

349 id_uris: set[str] = set() 

350 for br_entity in br_entities.values(): 

351 for id_ref in br_entity.get(HAS_IDENTIFIER, []): 

352 id_uris.add(id_ref["@id"]) 

353 id_entities = _batch_read_entities( 

354 id_uris, rdf_dir, dir_split, items_per_file, zip_output 

355 ) 

356 progress.advance(task) 

357 

358 enriched = [] 

359 for res in manual_cases: 

360 br_ids: list[str] = [] 

361 br_entity = br_entities.get(res.br_uri) 

362 if br_entity: 

363 for id_ref in br_entity.get(HAS_IDENTIFIER, []): 

364 id_entity = id_entities.get(id_ref["@id"]) 

365 if id_entity: 

366 scheme_list = id_entity.get(USES_ID_SCHEME, []) 

367 value_list = id_entity.get(HAS_LITERAL_VALUE, []) 

368 if scheme_list and value_list: 

369 scheme = scheme_list[0]["@id"].rsplit("/", 1)[-1] 

370 value = value_list[0]["@value"] 

371 br_ids.append(f"{scheme}:{value}") 

372 

373 candidates = [] 

374 for c_uri in raw_case_map[res.br_uri]: 

375 title, types = entity_meta.get(c_uri, ("", frozenset())) 

376 candidates.append( 

377 {"uri": c_uri, "title": title, "type": _type_label(types)} 

378 ) 

379 

380 enriched.append( 

381 { 

382 "br_uri": res.br_uri, 

383 "identifiers": br_ids, 

384 "candidates": candidates, 

385 "reason": res.reason, 

386 } 

387 ) 

388 progress.advance(task) 

389 return enriched 

390 

391 

392# ── Fix ── 

393 

394 

395def fix_br_part_of( 

396 editor: MetaEditor, 

397 br_uri: str, 

398 correct_part_of_uri: str, 

399 incorrect_part_of_uris: list[str], 

400) -> None: 

401 supplier_prefix = get_prefix(br_uri) 

402 g_set = GraphSet( 

403 editor.base_iri, 

404 supplier_prefix=supplier_prefix, 

405 custom_counter_handler=editor.counter_handler, 

406 wanted_label=False, 

407 ) 

408 

409 file_paths: set[str] = set() 

410 for uri in [br_uri, correct_part_of_uri] + incorrect_part_of_uris: 

411 file_paths.add( 

412 find_rdf_file( 

413 uri, 

414 editor.base_dir, 

415 editor.dir_split, 

416 editor.n_file_item, 

417 editor.zip_output_rdf, 

418 ) 

419 ) 

420 

421 for fp in file_paths: 

422 imported = editor.reader.load(fp) 

423 if imported is not None: 

424 editor.reader.import_entities_from_graph(g_set, imported, editor.resp_agent) 

425 

426 br_entity = g_set.get_entity(br_uri) 

427 correct_container = g_set.get_entity(correct_part_of_uri) 

428 assert br_entity is not None, f"BR not found: {br_uri}" 

429 assert correct_container is not None, f"Container not found: {correct_part_of_uri}" 

430 

431 br_entity.remove_is_part_of() # type: ignore[attr-defined] 

432 br_entity.is_part_of(correct_container) # type: ignore[attr-defined] 

433 

434 editor.save(g_set, supplier_prefix) 

435 

436 

437# ── Orphan check ── 

438 

439 

440def _check_orphan_batch( 

441 files: list[str], zip_output: bool, target_uris: list[str] 

442) -> set[str]: 

443 target_set = set(target_uris) 

444 referenced: set[str] = set() 

445 for entity in _iter_entities(files, zip_output): 

446 if FRBR_PART_OF in entity: 

447 for parent in entity[FRBR_PART_OF]: 

448 pid = parent["@id"] 

449 if pid in target_set: 

450 referenced.add(pid) 

451 return referenced 

452 

453 

454def check_orphans( 

455 removed_uris: list[str], 

456 rdf_dir: str, 

457 zip_output: bool, 

458 workers: int = 4, 

459 batch_size: int = BATCH_SIZE, 

460) -> list[str]: 

461 if not removed_uris: 

462 return [] 

463 

464 removed_set = set(removed_uris) 

465 br_dir = os.path.join(rdf_dir, "br") 

466 if zip_output: 

467 br_files = collect_zip_files(br_dir, only_data=True) 

468 else: 

469 br_files = collect_files(br_dir, "*.json", lambda p: "prov" not in p) 

470 

471 referenced: set[str] = set() 

472 batches = [ 

473 br_files[i : i + batch_size] for i in range(0, len(br_files), batch_size) 

474 ] 

475 

476 ctx = multiprocessing.get_context("forkserver") 

477 with create_progress() as progress: 

478 task = progress.add_task("Checking orphans", total=len(br_files)) 

479 executor = ProcessPoolExecutor( 

480 max_workers=workers, initializer=_worker_init, mp_context=ctx 

481 ) 

482 try: 

483 futures = { 

484 executor.submit( 

485 _check_orphan_batch, batch, zip_output, list(removed_set) 

486 ): batch 

487 for batch in batches 

488 } 

489 for future in as_completed(futures): 

490 referenced.update(future.result()) 

491 progress.advance(task, len(futures[future])) 

492 finally: 

493 executor.shutdown(wait=True) 

494 

495 return sorted(uri for uri in removed_uris if uri not in referenced) 

496 

497 

498# ── Progress ── 

499 

500 

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

502 if not os.path.exists(path): 

503 return set() 

504 with open(path) as f: 

505 return set(json.load(f)) 

506 

507 

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

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

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

511 

512 

513# ── Report ── 

514 

515 

516def _build_report( 

517 resolved: list[ResolvedCase], 

518 manual_enriched: list[dict], 

519 orphans: list[str], 

520) -> dict: 

521 fixed = [] 

522 for res in resolved: 

523 if res.correct_part_of is not None: 

524 fixed.append( 

525 { 

526 "br_uri": res.br_uri, 

527 "kept_part_of": res.correct_part_of, 

528 "removed_part_of": res.to_remove, 

529 "method": res.method, 

530 "reason": res.reason, 

531 } 

532 ) 

533 

534 return { 

535 "summary": { 

536 "total_affected": len(resolved), 

537 "auto_fixed": len(fixed), 

538 "manual_review": len(manual_enriched), 

539 "orphaned_containers": len(orphans), 

540 }, 

541 "fixed": fixed, 

542 "manual_review": manual_enriched, 

543 "orphaned_containers": orphans, 

544 } 

545 

546 

547# ── CLI ── 

548 

549 

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

551 parser = argparse.ArgumentParser( 

552 description="Fix duplicate frbr:partOf values on bibliographic resources", 

553 formatter_class=RichHelpFormatter, 

554 ) 

555 parser.add_argument( 

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

557 ) 

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

559 parser.add_argument( 

560 "--dry-run", action="store_true", help="Report only, no modifications" 

561 ) 

562 parser.add_argument( 

563 "-w", "--workers", type=int, default=4, help="Parallel scan workers" 

564 ) 

565 parser.add_argument( 

566 "-b", "--batch-size", type=int, default=BATCH_SIZE, help="Files per batch" 

567 ) 

568 parser.add_argument( 

569 "--progress-file", default="fix_duplicate_part_of_progress.json" 

570 ) 

571 parser.add_argument("--report-file", default="fix_duplicate_part_of_report.json") 

572 args = parser.parse_args() 

573 

574 if not args.dry_run and not args.resp_agent: 

575 parser.error("--resp-agent is required when not using --dry-run") 

576 

577 with open(args.config) as f: 

578 settings = yaml.safe_load(f) 

579 

580 rdf_dir = os.path.join(settings["base_output_dir"], "rdf") + os.sep 

581 dir_split = settings["dir_split_number"] 

582 items_per_file = settings["items_per_file"] 

583 zip_output = settings["zip_output_rdf"] 

584 

585 # Phase 1: Scan 

586 console.print("[bold]Scanning RDF files for duplicate partOf...[/bold]") 

587 raw_cases = scan_duplicate_part_of( 

588 rdf_dir, zip_output, args.workers, args.batch_size 

589 ) 

590 console.print(f"Found {len(raw_cases)} BRs with duplicate partOf") 

591 

592 if not raw_cases: 

593 console.print("[green]No duplicate partOf found.[/green]") 

594 return 

595 

596 # Phase 2: Build chain map 

597 all_container_uris = {uri for _, containers in raw_cases for uri in containers} 

598 console.print( 

599 f"[bold]Building chain map for {len(all_container_uris)} containers...[/bold]" 

600 ) 

601 chain_map, entity_meta = build_chain_map( 

602 all_container_uris, rdf_dir, dir_split, items_per_file, zip_output 

603 ) 

604 

605 # Phase 3: Classify & resolve 

606 resolved = resolve_cases(raw_cases, chain_map, entity_meta) 

607 auto_fix = [r for r in resolved if r.correct_part_of is not None] 

608 manual = [r for r in resolved if r.correct_part_of is None] 

609 console.print(f"Auto-fixable: {len(auto_fix)}, Manual review: {len(manual)}") 

610 

611 # Enrich manual review cases for report 

612 raw_case_map = {br: containers for br, containers in raw_cases} 

613 manual_enriched = enrich_manual_review( 

614 manual, 

615 raw_case_map, 

616 entity_meta, 

617 rdf_dir, 

618 dir_split, 

619 items_per_file, 

620 zip_output, 

621 ) 

622 

623 if args.dry_run: 

624 report = _build_report(resolved, manual_enriched, []) 

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

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

627 console.print(f"\n[bold]Dry run report written to {args.report_file}[/bold]") 

628 for res in manual: 

629 console.print(f" [yellow]REVIEW[/yellow] {res.br_uri}: {res.reason}") 

630 return 

631 

632 # Phase 4: Fix 

633 editor = MetaEditor(args.config, args.resp_agent) 

634 signal.signal(signal.SIGINT, _handle_signal) 

635 signal.signal(signal.SIGTERM, _handle_signal) 

636 

637 completed = _load_progress(args.progress_file) 

638 if completed: 

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

640 

641 fixed_count = 0 

642 failed_count = 0 

643 

644 with create_progress() as progress: 

645 task = progress.add_task("Fixing partOf", total=len(auto_fix)) 

646 for res in auto_fix: 

647 if _stop_requested: 

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

649 break 

650 if res.br_uri in completed: 

651 progress.advance(task) 

652 continue 

653 try: 

654 assert res.correct_part_of is not None 

655 fix_br_part_of(editor, res.br_uri, res.correct_part_of, res.to_remove) 

656 completed.add(res.br_uri) 

657 _save_progress(args.progress_file, completed) 

658 fixed_count += 1 

659 except Exception as e: 

660 console.print(f"[red]Error fixing {res.br_uri}: {e}[/red]") 

661 failed_count += 1 

662 progress.advance(task) 

663 

664 console.print(f"Fixed: {fixed_count}, Failed: {failed_count}") 

665 

666 # Phase 5: Orphan check 

667 all_removed = [ 

668 uri for res in resolved if res.correct_part_of for uri in res.to_remove 

669 ] 

670 console.print("[bold]Checking for orphaned containers...[/bold]") 

671 orphans = check_orphans( 

672 all_removed, rdf_dir, zip_output, args.workers, args.batch_size 

673 ) 

674 console.print(f"Orphaned containers: {len(orphans)}") 

675 

676 # Report 

677 report = _build_report(resolved, manual_enriched, orphans) 

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

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

680 console.print(f"Report written to {args.report_file}") 

681 

682 if not _stop_requested and failed_count == 0 and os.path.exists(args.progress_file): 

683 os.remove(args.progress_file) 

684 

685 

686if __name__ == "__main__": 

687 main()