Coverage for src/oc_graphenricher/deduplication.py: 95%
590 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-10 10:04 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-10 10:04 +0000
1# SPDX-FileCopyrightText: 2021 Gabriele Pisciotta <ga.pisciotta@gmail.com>
2# SPDX-FileCopyrightText: 2021 Simone Persiani
3# SPDX-FileCopyrightText: 2026 Arcangelo Massari <arcangelo.massari@unibo.it>
4#
5# SPDX-License-Identifier: ISC
7from __future__ import annotations
9import logging
10from collections import defaultdict
11from dataclasses import replace
12from typing import TYPE_CHECKING, TypeAlias, TypeVar, cast
14import Levenshtein
15import networkx as nx
16from oc_ocdm.graph.entities.bibliographic.bibliographic_resource import BibliographicResource
17from oc_ocdm.graph.entities.bibliographic.responsible_agent import ResponsibleAgent
18from oc_ocdm.graph.entities.identifier import Identifier
19from oc_ocdm.graph.graph_entity import GraphEntity
20from oc_ocdm.prov.prov_set import ProvSet
22from oc_graphenricher._storage import store_graph_set, store_provenance
24if TYPE_CHECKING:
25 from collections.abc import Iterable, Mapping
27 from oc_ocdm.graph.entities.bibliographic.agent_role import AgentRole
28 from oc_ocdm.graph.graph_set import GraphSet
30 from oc_graphenricher.storage import Storage
32LOGGER = logging.getLogger(__name__)
33NAME_SIMILARITY_THRESHOLD = 0.95
34Entity = TypeVar("Entity")
35ManualMergeEntity: TypeAlias = BibliographicResource | ResponsibleAgent | Identifier
36IdentifierSignature: TypeAlias = tuple[str, str]
37IdentifierReferenceSources: TypeAlias = defaultdict[Identifier, list[GraphEntity]]
38MANUAL_BR_ROLE_TYPES = (GraphEntity.iri_author, GraphEntity.iri_editor)
41class GraphDeduplicator:
42 def __init__(
43 self,
44 graph_set: GraphSet,
45 storage: Storage | None = None,
46 *,
47 debug: bool = False,
48 merge_similar_named_contributors: bool = False,
49 preferred_survivors: set[str] | None = None,
50 ) -> None:
51 """
52 Initialize the graph deduplicator.
54 The deduplicator merges duplicate entities in a graph set compliant with the OpenCitations Data Model.
56 :param graph_set: input graph set
57 :param storage: output storage configuration
58 :param debug: a bool flag to enable richer output
59 :param merge_similar_named_contributors: merge contributor roles with similar author names within merged BRs
60 :param preferred_survivors: entity URIs to keep when they appear in duplicate clusters
61 """
62 self.graph_set = graph_set
63 self.storage = storage
64 self.debug = debug
65 self.merge_similar_named_contributors = merge_similar_named_contributors
66 self.preferred_survivors = preferred_survivors if preferred_survivors is not None else set()
67 self.modified_entities: set[str] = set()
68 self.prov = self.__provenance()
70 def deduplicate_and_save(self) -> GraphSet:
71 """
72 Deduplicate the graph set and save the graph and provenance.
74 The process will:
75 - deduplicate the Responsible Agents (RAs)
76 - deduplicate the Bibliographic Resources (BRs)
77 - deduplicate the IDs.
79 In the end, this process will produce:
80 - the configured graph output without the duplicates.
81 - the configured provenance output tracking the changes done.
82 """
83 self.__deduplicate_responsible_agents()
84 self.__save_and_commit()
85 self.__deduplicate_bibliographic_resources()
86 self.__save_and_commit()
87 self.__deduplicate_identifiers()
88 self.__save_and_commit()
89 return self.graph_set
91 def deduplicate(self) -> GraphSet:
92 """Deduplicate the graph set without serializing the graph set or provenance."""
93 self.deduplicate_responsible_agents()
94 self.deduplicate_bibliographic_resources()
95 self.deduplicate_identifiers()
96 return self.graph_set
98 def merge_clusters(self, clusters: Mapping[str, Iterable[str]]) -> GraphSet:
99 self.__merge_clusters(clusters)
100 self.graph_set.commit_changes()
101 return self.graph_set
103 def __merge_clusters(self, clusters: Mapping[str, Iterable[str]]) -> None:
104 normalized_clusters = self.__manual_merge_clusters(clusters)
105 if not normalized_clusters:
106 return
108 self.__validate_identifier_clusters(normalized_clusters)
109 associated_ar_ra = self.__get_association_ar_ra()
110 identifier_reference_sources: IdentifierReferenceSources | None = None
112 for surviving_entity, merged_entities in self.__ordered_clusters(normalized_clusters):
113 self.__ensure_cluster_entities_alive(surviving_entity, merged_entities)
114 if isinstance(surviving_entity, ResponsibleAgent):
115 for merged_entity in merged_entities:
116 self.__merge_responsible_agent(
117 surviving_entity,
118 self.__as_responsible_agent(merged_entity),
119 associated_ar_ra,
120 )
121 elif isinstance(surviving_entity, BibliographicResource):
122 self.__merge_manual_bibliographic_resources(
123 surviving_entity,
124 [self.__as_bibliographic_resource(merged_entity) for merged_entity in merged_entities],
125 )
126 else:
127 if identifier_reference_sources is None:
128 identifier_reference_sources = self.__identifier_reference_sources()
129 self.__merge_identifiers(
130 surviving_entity,
131 [self.__as_identifier(merged_entity) for merged_entity in merged_entities],
132 identifier_reference_sources,
133 )
135 self.modified_entities.update(self.prov.generate_provenance())
137 def __ordered_clusters(
138 self,
139 normalized_clusters: list[tuple[ManualMergeEntity, list[ManualMergeEntity]]],
140 ) -> list[tuple[ManualMergeEntity, list[ManualMergeEntity]]]:
141 # A merge can delete entities other clusters name: a BR/RA merge
142 # deduplicates identifiers by scheme and literal (one of the two dies)
143 # and the container cascade of a BR merge deletes ancestor containers.
144 # Identifier clusters therefore run first, and BR clusters run top-down
145 # by container depth, so every cluster is merged before anything can
146 # delete its entities.
147 return sorted(normalized_clusters, key=self.__cluster_rank)
149 def __cluster_rank(
150 self,
151 cluster: tuple[ManualMergeEntity, list[ManualMergeEntity]],
152 ) -> tuple[int, int]:
153 surviving_entity, merged_entities = cluster
154 if isinstance(surviving_entity, Identifier):
155 return (0, 0)
156 if isinstance(surviving_entity, ResponsibleAgent):
157 return (1, 0)
158 depth = max(
159 len(self.__get_part_of(self.__as_bibliographic_resource(entity)))
160 for entity in (surviving_entity, *merged_entities)
161 )
162 return (2, depth)
164 def __ensure_cluster_entities_alive(
165 self,
166 surviving_entity: ManualMergeEntity,
167 merged_entities: list[ManualMergeEntity],
168 ) -> None:
169 for entity in (surviving_entity, *merged_entities):
170 if entity.to_be_deleted:
171 message = (
172 f"Entity {entity.res} is deleted ({self.__deletion_cause(entity)}) "
173 f"and cannot take part in the merge cluster of {surviving_entity.res}."
174 )
175 raise ValueError(message)
177 def __deletion_cause(self, entity: ManualMergeEntity) -> str:
178 target = str(entity.res)
179 for candidate in self.graph_set.res_to_entity.values():
180 if any(str(merged.res) == target for merged in candidate.merge_list):
181 return f"merged into {candidate.res}"
182 return "marked for deletion"
184 def merge_clusters_and_save(self, clusters: Mapping[str, Iterable[str]]) -> GraphSet:
185 self.__merge_clusters(clusters)
186 self.__save_and_commit()
187 return self.graph_set
189 def save(self) -> None:
190 """
191 Serialize the graph set into the specified RDF file.
193 Serialize the provenance in another specified RDF file.
194 """
195 storage = self.__storage()
196 store_graph_set(self.graph_set, storage)
197 store_provenance(self.prov, storage)
199 def __save_and_commit(self) -> None:
200 self.save()
201 self.graph_set.commit_changes()
202 self.modified_entities.clear()
204 def deduplicate_responsible_agents(self) -> None:
205 """
206 Discover Responsible Agents (RAs) that share the same identifier literal.
208 The process creates a graph of duplicate entities, merges each connected component into one RA, updates Agent
209 Role references, generates provenance and commits pending changes in the graph set.
210 """
211 self.__deduplicate_responsible_agents()
212 self.graph_set.commit_changes()
214 def __deduplicate_responsible_agents(self) -> None:
215 associated_ar_ra = self.__get_association_ar_ra()
216 clusters = self.__sorted_clusters(
217 self.__merge_graph(self.graph_set.get_ra(), "[dedup-RA] Will merge %s and %s due to %s:%s in common"),
218 )
219 LOGGER.info("[dedup-RA] Number of clusters: %s", len(clusters))
221 for cluster_index, cluster in enumerate(clusters):
222 entity_first_raw, other_entities_raw = self.__ordered_entities(cluster)
223 entity_first = self.__as_responsible_agent(entity_first_raw)
224 self.__debug("[dedup-RA] Merging cluster #%s, with %s entities", cluster_index, len(cluster))
225 for other_entity_raw in other_entities_raw:
226 self.__merge_responsible_agent(
227 entity_first,
228 self.__as_responsible_agent(other_entity_raw),
229 associated_ar_ra,
230 )
232 self.modified_entities.update(self.prov.generate_provenance())
234 def deduplicate_bibliographic_resources(self) -> None:
235 """
236 Discover Bibliographic Resources (BRs) that share the same identifier literal.
238 The process creates a graph of duplicate BRs, merges each connected component into one BR, merges containers and
239 publishers where possible, generates provenance and commits pending changes in the graph set.
240 """
241 self.__deduplicate_bibliographic_resources()
242 self.graph_set.commit_changes()
244 def __deduplicate_bibliographic_resources(self) -> None:
245 clusters = self.__sorted_clusters(
246 self.__merge_graph(self.graph_set.get_br(), "[dedup-BR] Will merge %s into %s due to %s:%s in common"),
247 )
248 LOGGER.info("[dedup-BR] Number of clusters: %s", len(clusters))
250 for cluster_index, cluster in enumerate(clusters):
251 self.__debug("[dedup-BR] Merging cluster #%s, with %s entities", cluster_index, len(cluster))
252 self.__merge_br_cluster(cluster)
254 self.modified_entities.update(self.prov.generate_provenance())
256 def deduplicate_identifiers(self) -> None:
257 """
258 Discover duplicate IDs related to Bibliographic Resources and Responsible Agents.
260 IDs are duplicates when they share the same schema and literal. The process merges duplicates into one ID,
261 substitutes references with the merged ID, generates provenance and commits pending changes in the graph set.
262 """
263 self.__deduplicate_identifiers()
264 self.graph_set.commit_changes()
266 def __deduplicate_identifiers(self) -> None:
267 literal_to_id = self.__identifier_groups()
268 identifier_reference_sources: IdentifierReferenceSources | None = None
269 for literal, identifiers in literal_to_id.items():
270 if len(identifiers) > 1:
271 if identifier_reference_sources is None:
272 identifier_reference_sources = self.__identifier_reference_sources()
273 self.__merge_identifier_group(literal, identifiers, identifier_reference_sources)
275 self.modified_entities.update(self.prov.generate_provenance())
277 def __merge_graph(
278 self,
279 entities: Iterable[ResponsibleAgent | BibliographicResource],
280 debug_message: str,
281 ) -> nx.Graph:
282 merge_graph: nx.Graph = nx.Graph()
283 identifiers: dict[str, dict[str, ResponsibleAgent | BibliographicResource]] = {}
284 for entity in entities:
285 for identifier in entity.get_identifiers():
286 scheme = identifier.get_scheme()
287 literal_value = identifier.get_literal_value()
288 if scheme is None or literal_value is None:
289 continue
290 identifiers.setdefault(scheme, {})
291 entity_first = identifiers[scheme].get(literal_value)
292 if entity_first is None:
293 identifiers[scheme][literal_value] = entity
294 else:
295 merge_graph.add_edge(entity_first, entity)
296 self.__debug(
297 debug_message,
298 entity.res,
299 entity_first.res,
300 scheme.split("/")[-1],
301 literal_value,
302 )
303 return merge_graph
305 def __sorted_clusters(self, merge_graph: nx.Graph) -> list[set[ResponsibleAgent | BibliographicResource]]:
306 return sorted(nx.connected_components(merge_graph), key=len, reverse=True)
308 def __manual_merge_clusters(
309 self,
310 clusters: Mapping[str, Iterable[str]],
311 ) -> list[tuple[ManualMergeEntity, list[ManualMergeEntity]]]:
312 normalized_clusters: list[tuple[ManualMergeEntity, list[ManualMergeEntity]]] = []
313 survivors: set[str] = set()
314 merged_to_survivor: dict[str, str] = {}
315 container_uris = self.__container_uris_with_members()
317 for survivor_uri, merged_uris in clusters.items():
318 survivor = self.__manual_survivor(survivor_uri, merged_uris, survivors, merged_to_survivor)
319 survivor_type = self.__merge_entity_type(survivor)
320 merged_entities = [
321 self.__manual_merged_entity(
322 survivor_uri,
323 survivor_type,
324 merged_uri,
325 survivors,
326 merged_to_survivor,
327 )
328 for merged_uri in merged_uris
329 ]
331 if not merged_entities:
332 message = f"Merge cluster for survivor {survivor_uri} must include at least one merged entity."
333 raise ValueError(message)
334 self.__validate_container_type_compatibility(survivor, merged_entities, container_uris)
335 normalized_clusters.append((survivor, merged_entities))
337 return normalized_clusters
339 def __container_uris_with_members(self) -> set[str]:
340 members: set[str] = set()
341 for br in self.graph_set.get_br():
342 container = br.get_is_part_of()
343 if container is not None:
344 members.add(str(container.res))
345 return members
347 def __validate_container_type_compatibility(
348 self,
349 survivor: ManualMergeEntity,
350 merged_entities: Iterable[ManualMergeEntity],
351 container_uris: set[str],
352 ) -> None:
353 if not isinstance(survivor, BibliographicResource):
354 return
355 survivor_types = set(self.__specific_types(survivor))
356 for merged_entity in merged_entities:
357 merged = self.__as_bibliographic_resource(merged_entity)
358 merged_types = set(self.__specific_types(merged))
359 if not survivor_types or not merged_types or not survivor_types.isdisjoint(merged_types):
360 continue
361 if str(survivor.res) in container_uris or str(merged.res) in container_uris:
362 message = (
363 "Cannot merge bibliographic resources with incompatible types: "
364 f"{survivor.res} is {sorted(survivor_types)}, {merged.res} is {sorted(merged_types)}."
365 )
366 raise ValueError(message)
368 def __validate_identifier_clusters(
369 self,
370 normalized_clusters: Iterable[tuple[ManualMergeEntity, list[ManualMergeEntity]]],
371 ) -> None:
372 for surviving_entity, merged_entities in normalized_clusters:
373 if not isinstance(surviving_entity, Identifier):
374 continue
376 surviving_signature = self.__identifier_signature(surviving_entity)
377 for merged_entity in merged_entities:
378 merged_identifier = self.__as_identifier(merged_entity)
379 merged_signature = self.__identifier_signature(merged_identifier)
380 if merged_signature != surviving_signature:
381 message = (
382 "Cannot merge identifiers with different scheme/literal: "
383 f"{surviving_entity.res} has {surviving_signature[0]}#{surviving_signature[1]}, "
384 f"{merged_identifier.res} has {merged_signature[0]}#{merged_signature[1]}."
385 )
386 raise ValueError(message)
388 @staticmethod
389 def __identifier_signature(identifier: Identifier) -> IdentifierSignature:
390 scheme = identifier.get_scheme()
391 literal = identifier.get_literal_value()
392 if scheme is None or literal is None:
393 message = f"Cannot merge identifier {identifier.res} without scheme or literal."
394 raise ValueError(message)
395 return scheme, literal
397 def __manual_survivor(
398 self,
399 survivor_uri: str,
400 merged_uris: Iterable[str],
401 survivors: set[str],
402 merged_to_survivor: dict[str, str],
403 ) -> ManualMergeEntity:
404 if survivor_uri in merged_to_survivor:
405 message = f"Entity {survivor_uri} cannot be both survivor and merged entity."
406 raise ValueError(message)
407 if survivor_uri in survivors:
408 message = f"Duplicate survivor in merge clusters: {survivor_uri}."
409 raise ValueError(message)
410 if isinstance(merged_uris, str):
411 message = f"Merge cluster for survivor {survivor_uri} must be an iterable of entity URIs."
412 raise TypeError(message)
414 survivors.add(survivor_uri)
415 return self.__merge_entity(survivor_uri)
417 def __manual_merged_entity(
418 self,
419 survivor_uri: str,
420 survivor_type: str,
421 merged_uri: str,
422 survivors: set[str],
423 merged_to_survivor: dict[str, str],
424 ) -> ManualMergeEntity:
425 if merged_uri == survivor_uri:
426 message = f"Entity {survivor_uri} cannot be merged into itself."
427 raise ValueError(message)
428 if merged_uri in survivors:
429 message = f"Entity {merged_uri} cannot be both survivor and merged entity."
430 raise ValueError(message)
431 if merged_uri in merged_to_survivor:
432 message = f"Entity {merged_uri} is already assigned to survivor {merged_to_survivor[merged_uri]}."
433 raise ValueError(message)
435 merged_entity = self.__merge_entity(merged_uri)
436 merged_entity_type = self.__merge_entity_type(merged_entity)
437 if merged_entity_type != survivor_type:
438 message = (
439 f"Merge cluster for survivor {survivor_uri} mixes entity types: "
440 f"{survivor_type} and {merged_entity_type}."
441 )
442 raise ValueError(message)
444 merged_to_survivor[merged_uri] = survivor_uri
445 return merged_entity
447 def __merge_entity(self, uri: str) -> ManualMergeEntity:
448 entity = self.graph_set.get_entity(uri)
449 if entity is None:
450 message = f"Entity not found in GraphSet: {uri}."
451 raise ValueError(message)
452 if isinstance(entity, BibliographicResource | ResponsibleAgent | Identifier):
453 return entity
454 message = f"Entity {uri} has unsupported merge type: {type(entity).__name__}."
455 raise ValueError(message)
457 @staticmethod
458 def __merge_entity_type(entity: ManualMergeEntity) -> str:
459 if isinstance(entity, ResponsibleAgent):
460 return "ra"
461 if isinstance(entity, BibliographicResource):
462 return "br"
463 return "id"
465 def __merge_responsible_agent(
466 self,
467 entity_first: ResponsibleAgent,
468 other_entity: ResponsibleAgent,
469 associated_ar_ra: dict[ResponsibleAgent, list[AgentRole]],
470 ) -> None:
471 self.__debug("\tMerging responsible agent %s in responsible agent %s", other_entity, entity_first)
472 entity_first.merge(other_entity, prefer_self=True)
473 associated_ars = associated_ar_ra.get(other_entity, [])
474 for ar in associated_ars:
475 ar.is_held_by(entity_first)
476 self.__debug("\tUnset %s as helded by of %s", other_entity, ar)
477 self.__debug("\tSet %s as helded by of %s", entity_first, ar)
478 self.__deduplicate_contributors_for_bibliographic_resources(
479 self.__bibliographic_resources_for_contributors(associated_ars),
480 )
481 self.__debug("\tMarking to delete: %s", other_entity)
483 def __merge_br_cluster(self, cluster: set[ResponsibleAgent | BibliographicResource]) -> None:
484 entity_first_raw, other_entities_raw = self.__ordered_entities(cluster)
485 self.__merge_bibliographic_resources(
486 self.__as_bibliographic_resource(entity_first_raw),
487 [self.__as_bibliographic_resource(other_entity) for other_entity in other_entities_raw],
488 )
490 def __merge_bibliographic_resources(
491 self,
492 entity_first: BibliographicResource,
493 other_entities: Iterable[BibliographicResource],
494 ) -> None:
495 for other_entity in other_entities:
496 # Recompute the survivor's publisher and part-of chain on every iteration: an
497 # earlier merge may have filled a previously missing publisher or container, and
498 # the next equivalent one must merge into it instead of surviving as a duplicate.
499 publisher_first = self.__get_publisher(entity_first)
500 entity_first_partofs = self.__get_part_of(entity_first)
501 self.__merge_containers(entity_first_partofs, self.__get_part_of(other_entity))
502 self.__merge_publisher(publisher_first, other_entity)
503 entity_first.merge(other_entity, prefer_self=True)
504 self.__deduplicate_contributors_for_bibliographic_resources([entity_first])
506 def __merge_manual_bibliographic_resources(
507 self,
508 entity_first: BibliographicResource,
509 other_entities: Iterable[BibliographicResource],
510 ) -> None:
511 merged_entities = list(other_entities)
512 self.__discard_merged_author_editor_roles(entity_first, merged_entities)
513 for other_entity in merged_entities:
514 for first_partof, second_partof in self.__container_merge_pairs(
515 self.__get_part_of(entity_first),
516 self.__get_part_of(other_entity),
517 ):
518 self.__discard_merged_author_editor_roles(first_partof, [second_partof])
519 self.__merge_bibliographic_resources(entity_first, [other_entity])
521 def __merge_containers(
522 self,
523 entity_first_partofs: list[BibliographicResource],
524 partofs: list[BibliographicResource],
525 ) -> None:
526 for first_partof, second_partof in self.__container_merge_pairs(entity_first_partofs, partofs):
527 self.__merge_publisher(self.__get_publisher(first_partof), second_partof)
528 first_partof.merge(second_partof, prefer_self=True)
529 self.__deduplicate_contributors_for_bibliographic_resources([first_partof])
530 self.__debug("\tMerging container %s in container %s", second_partof, first_partof)
532 def __container_merge_pairs(
533 self,
534 entity_first_partofs: list[BibliographicResource],
535 partofs: list[BibliographicResource],
536 ) -> list[tuple[BibliographicResource, BibliographicResource]]:
537 second_by_type: dict[str, BibliographicResource] = {}
538 for container in partofs:
539 for type_iri in self.__specific_types(container):
540 second_by_type.setdefault(type_iri, container)
542 pairs: list[tuple[BibliographicResource, BibliographicResource]] = []
543 parent_merged = True
544 for first_partof in reversed(entity_first_partofs): # walk from the top of the hierarchy downward
545 second_partof = self.__matching_container(first_partof, second_by_type)
546 if second_partof is None or first_partof.res == second_partof.res:
547 continue
548 if parent_merged and self.__containers_are_equivalent(first_partof, second_partof):
549 pairs.append((first_partof, second_partof))
550 else:
551 parent_merged = False
552 return pairs
554 @staticmethod
555 def __specific_types(container: BibliographicResource) -> list[str]:
556 return [type_iri for type_iri in container.get_types() if type_iri != GraphEntity.iri_expression]
558 def __matching_container(
559 self,
560 container: BibliographicResource,
561 second_by_type: dict[str, BibliographicResource],
562 ) -> BibliographicResource | None:
563 for type_iri in self.__specific_types(container):
564 if type_iri in second_by_type:
565 return second_by_type[type_iri]
566 return None
568 def __containers_are_equivalent(
569 self,
570 first: BibliographicResource,
571 second: BibliographicResource,
572 ) -> bool:
573 first_signatures = self.__identifier_signatures(first)
574 second_signatures = self.__identifier_signatures(second)
575 if first_signatures and second_signatures:
576 return not first_signatures.isdisjoint(second_signatures)
578 first_number = first.get_number()
579 second_number = second.get_number()
580 if first_number is not None and second_number is not None:
581 return first_number == second_number
583 first_title = first.get_title()
584 second_title = second.get_title()
585 if first_title is not None and second_title is not None:
586 return self.__normalized_text(first_title) == self.__normalized_text(second_title)
588 return False
590 @staticmethod
591 def __identifier_signatures(entity: BibliographicResource | ResponsibleAgent) -> set[IdentifierSignature]:
592 signatures: set[IdentifierSignature] = set()
593 for identifier in entity.get_identifiers():
594 scheme = identifier.get_scheme()
595 literal = identifier.get_literal_value()
596 if scheme is not None and literal is not None:
597 signatures.add((scheme, literal))
598 return signatures
600 @staticmethod
601 def __normalized_text(value: str) -> str:
602 return " ".join(value.casefold().split())
604 def __merge_publisher(self, publisher_first: AgentRole | None, entity: BibliographicResource) -> None:
605 publisher = self.__get_publisher(entity)
606 if (
607 publisher is not None
608 and publisher_first is not None
609 and publisher != publisher_first
610 and self.__publishers_are_equivalent(publisher_first, publisher)
611 ):
612 publisher_first.merge(publisher, prefer_self=True)
613 self.__debug("\tMerging publisher %s in publisher %s", publisher, publisher_first)
615 def __publishers_are_equivalent(self, first: AgentRole, second: AgentRole) -> bool:
616 first_agent = first.get_is_held_by()
617 second_agent = second.get_is_held_by()
618 if first_agent is None or second_agent is None:
619 return False
620 # The responsible agent behind a publisher role is often outside the loaded
621 # closure, so its identifiers and name are unavailable; two roles held by the
622 # same agent URI are the same publisher regardless of what data is loaded.
623 if str(first_agent.res) == str(second_agent.res):
624 return True
625 first_signatures = self.__identifier_signatures(first_agent)
626 second_signatures = self.__identifier_signatures(second_agent)
627 if first_signatures and second_signatures:
628 return not first_signatures.isdisjoint(second_signatures)
629 first_name = self.__agent_full_name(first_agent)
630 second_name = self.__agent_full_name(second_agent)
631 if first_name and second_name:
632 return first_name == second_name
633 return False
635 def __agent_full_name(self, agent: ResponsibleAgent) -> str:
636 name = agent.get_name()
637 if name is not None:
638 return self.__normalized_text(name)
639 parts = [part for part in (agent.get_given_name(), agent.get_family_name()) if part is not None]
640 return self.__normalized_text(" ".join(parts)) if parts else ""
642 def __bibliographic_resources_for_contributors(
643 self,
644 contributors: Iterable[AgentRole],
645 ) -> list[BibliographicResource]:
646 contributor_uris = {str(contributor.res) for contributor in contributors}
647 if not contributor_uris:
648 return []
649 return [
650 br
651 for br in sorted(self.graph_set.get_br(), key=str)
652 if any(str(contributor.res) in contributor_uris for contributor in br.get_contributors())
653 ]
655 def __deduplicate_contributors_for_bibliographic_resources(
656 self,
657 bibliographic_resources: Iterable[BibliographicResource],
658 ) -> None:
659 for bibliographic_resource in bibliographic_resources:
660 already_merged = self.__merge_same_ra_contributors(bibliographic_resource)
661 if self.merge_similar_named_contributors:
662 self.__merge_similar_named_contributors(bibliographic_resource, already_merged)
663 self.__remove_contributors_without_ra(bibliographic_resource)
665 def __discard_merged_author_editor_roles(
666 self,
667 entity_first: BibliographicResource,
668 merged_entities: list[BibliographicResource],
669 ) -> None:
670 for role_type in MANUAL_BR_ROLE_TYPES:
671 self.__keep_single_role_chain(entity_first, merged_entities, role_type)
673 def __keep_single_role_chain(
674 self,
675 entity_first: BibliographicResource,
676 merged_entities: list[BibliographicResource],
677 role_type: str,
678 ) -> None:
679 donor = None
680 if not self.__has_contributor_role(entity_first, role_type):
681 donor = self.__richest_role_donor(merged_entities, role_type)
682 for merged_entity in merged_entities:
683 if merged_entity is donor:
684 continue
685 for contributor in list(merged_entity.get_contributors()):
686 if contributor.get_role_type() == role_type:
687 merged_entity.remove_contributor(contributor)
688 contributor.mark_as_to_be_deleted()
690 def __richest_role_donor(
691 self,
692 merged_entities: list[BibliographicResource],
693 role_type: str,
694 ) -> BibliographicResource | None:
695 donors = [entity for entity in merged_entities if self.__contributor_role_count(entity, role_type) > 0]
696 if not donors:
697 return None
698 return min(donors, key=lambda entity: (-self.__contributor_role_count(entity, role_type), str(entity.res)))
700 def __has_contributor_role(self, entity: BibliographicResource, role_type: str) -> bool:
701 return any(contributor.get_role_type() == role_type for contributor in entity.get_contributors())
703 def __contributor_role_count(self, entity: BibliographicResource, role_type: str) -> int:
704 return sum(1 for contributor in entity.get_contributors() if contributor.get_role_type() == role_type)
706 def __merge_same_ra_contributors(self, entity_first: BibliographicResource) -> set[AgentRole]:
707 # Group every contributor role, publishers included, by (RA, role type). A
708 # direct RA merge or a container merge can leave two publisher roles held by
709 # the same agent on one resource, and __merge_publisher only reconciles the
710 # single publisher returned by __get_publisher, so the extras must collapse
711 # here alongside authors and editors.
712 contributors_by_agent_role: dict[tuple[str, str], list[AgentRole]] = {}
713 for contributor in entity_first.get_contributors():
714 responsible_agent = contributor.get_is_held_by()
715 role_type = contributor.get_role_type()
716 if responsible_agent is None or role_type is None:
717 continue
718 key = (str(responsible_agent.res), role_type)
719 contributors_by_agent_role.setdefault(key, []).append(contributor)
721 already_merged: set[AgentRole] = set()
722 for contributors in contributors_by_agent_role.values():
723 ordered_contributors = sorted(contributors, key=str)
724 surviving_contributor = ordered_contributors[0]
725 for merged_contributor in ordered_contributors[1:]:
726 self.__debug(
727 "\tRemoving agent role %s from bibliographic resource %s because both point to the same RA",
728 merged_contributor,
729 entity_first,
730 )
731 self.__merge_contributor(entity_first, surviving_contributor, merged_contributor)
732 already_merged.add(surviving_contributor)
733 already_merged.add(merged_contributor)
734 return already_merged
736 def __merge_similar_named_contributors(
737 self,
738 entity_first: BibliographicResource,
739 already_merged: set[AgentRole],
740 ) -> None:
741 already_merged_uris = {str(contributor.res) for contributor in already_merged}
742 contributors = [
743 contributor
744 for contributor in sorted(self.__author_contributors(entity_first), key=str)
745 if str(contributor.res) not in already_merged_uris and contributor.get_role_type() is not None
746 ]
747 merged_contributor_uris: set[str] = set()
748 for ar1_index, ar1 in enumerate(contributors):
749 if str(ar1.res) in merged_contributor_uris:
750 continue
751 ar1_name = self.__agent_name(ar1)
752 for ar2 in contributors[ar1_index + 1 :]:
753 if str(ar2.res) in merged_contributor_uris or ar1.get_role_type() != ar2.get_role_type():
754 continue
755 ar2_name = self.__agent_name(ar2)
756 name_similarity = self.__name_similarity(ar1_name, ar2_name)
757 if name_similarity > NAME_SIMILARITY_THRESHOLD:
758 self.__merge_contributor(entity_first, ar1, ar2)
759 merged_contributor_uris.add(str(ar2.res))
760 self.__debug(
761 "\tRemoving agent role %s from bibliographic resource %s because it merged to %s",
762 ar2,
763 entity_first,
764 ar1,
765 )
767 def __remove_contributors_without_ra(self, entity_first: BibliographicResource) -> None:
768 for ar in self.__author_contributors(entity_first):
769 if ar.to_be_deleted or ar.get_is_held_by() is None:
770 entity_first.remove_contributor(ar)
772 def __merge_contributor(
773 self,
774 entity_first: BibliographicResource,
775 surviving_contributor: AgentRole,
776 merged_contributor: AgentRole,
777 ) -> None:
778 surviving_responsible_agent = surviving_contributor.get_is_held_by()
779 self.__unlink_contributor_from_order(entity_first, merged_contributor)
780 surviving_contributor.merge(merged_contributor, prefer_self=True)
781 if surviving_responsible_agent is not None:
782 surviving_contributor.is_held_by(surviving_responsible_agent)
783 entity_first.remove_contributor(merged_contributor)
784 if str(surviving_contributor.res) not in {str(ar.res) for ar in entity_first.get_contributors()}:
785 entity_first.has_contributor(surviving_contributor)
787 def __unlink_contributor_from_order(
788 self,
789 entity_first: BibliographicResource,
790 contributor: AgentRole,
791 ) -> None:
792 # Splice the duplicate role out of the oco:hasNext order: its predecessor must
793 # point at its successor. Merging it into the survivor instead would redirect the
794 # predecessor onto the survivor and turn the author list into a cycle.
795 successor = contributor.get_next()
796 for role in self.__author_contributors(entity_first):
797 role_next = role.get_next()
798 if role_next is None or str(role_next.res) != str(contributor.res):
799 continue
800 if successor is not None and str(successor.res) != str(role.res):
801 role.has_next(successor)
802 else:
803 role.remove_next()
804 contributor.remove_next()
806 def __author_contributors(self, br: BibliographicResource) -> list[AgentRole]:
807 return [
808 contributor
809 for contributor in br.get_contributors()
810 if contributor.get_role_type() != GraphEntity.iri_publisher
811 ]
813 def __agent_name(self, ar: AgentRole) -> str:
814 responsible_agent = ar.get_is_held_by()
815 if responsible_agent is None:
816 return ""
817 given_name = responsible_agent.get_given_name()
818 family_name = responsible_agent.get_family_name()
819 name_parts = []
820 if given_name is not None:
821 name_parts.append(given_name)
822 if family_name is not None:
823 name_parts.append(family_name)
824 return " ".join(name_parts)
826 @staticmethod
827 def __name_similarity(left: str, right: str) -> float:
828 left_normalized = " ".join(left.casefold().split())
829 right_normalized = " ".join(right.casefold().split())
830 if left_normalized == "" or right_normalized == "":
831 return 0.0
832 max_length = max(len(left_normalized), len(right_normalized))
833 return 1 - Levenshtein.distance(left_normalized, right_normalized) / max_length
835 def __identifier_groups(self) -> dict[str, list[Identifier]]:
836 literal_to_id: dict[str, list[Identifier]] = {}
837 entities: list[BibliographicResource | ResponsibleAgent] = list(self.graph_set.get_br())
838 entities.extend(list(self.graph_set.get_ra()))
840 for entity in entities:
841 for identifier in entity.get_identifiers():
842 scheme = identifier.get_scheme()
843 value = identifier.get_literal_value()
844 if scheme is None or value is None:
845 continue
846 literal = f"{scheme}#{value}"
847 literal_to_id.setdefault(literal, []).append(identifier)
848 return literal_to_id
850 def __identifier_reference_sources(self) -> IdentifierReferenceSources:
851 reference_sources: IdentifierReferenceSources = defaultdict(list)
852 for entity in self.graph_set.res_to_entity.values():
853 referenced_identifiers: set[Identifier] = set()
854 for _, _, obj in entity.g.triples((entity.res, None, None)):
855 if obj.type != "uri":
856 continue
857 referenced_entity = self.graph_set.res_to_entity.get(obj.value)
858 if isinstance(referenced_entity, Identifier):
859 referenced_identifiers.add(referenced_entity)
860 for identifier in referenced_identifiers:
861 reference_sources[identifier].append(entity)
862 return reference_sources
864 def __merge_identifier_group(
865 self,
866 literal: str,
867 identifiers: list[Identifier],
868 identifier_reference_sources: IdentifierReferenceSources,
869 ) -> None:
870 schema, value = literal.split("#", maxsplit=1)
871 merged_identifier, other_identifiers = self.__ordered_entities(identifiers)
872 self.__debug(
873 "[dedup-ID] Will merge %s identifiers into %s because they share literal %s and schema %s",
874 len(identifiers) - 1,
875 merged_identifier,
876 value,
877 schema,
878 )
879 for actual_id in other_identifiers:
880 self.__merge_identifier(merged_identifier, actual_id, identifier_reference_sources)
882 def __merge_identifiers(
883 self,
884 surviving_identifier: Identifier,
885 merged_identifiers: Iterable[Identifier],
886 identifier_reference_sources: IdentifierReferenceSources,
887 ) -> None:
888 for merged_identifier in merged_identifiers:
889 self.__merge_identifier(surviving_identifier, merged_identifier, identifier_reference_sources)
891 def __merge_identifier(
892 self,
893 surviving_identifier: Identifier,
894 merged_identifier: Identifier,
895 identifier_reference_sources: IdentifierReferenceSources,
896 ) -> None:
897 surviving_identifier.merge(
898 merged_identifier,
899 reference_sources=identifier_reference_sources[merged_identifier],
900 )
902 def __ordered_entities(self, entities: Iterable[Entity]) -> tuple[Entity, list[Entity]]:
903 entities_by_key = {str(entity): entity for entity in entities}
904 sorted_keys = sorted(entities_by_key)
905 if not sorted_keys:
906 message = "Cannot order an empty entity group."
907 raise ValueError(message)
908 surviving_key = self.__surviving_key(entities_by_key, sorted_keys)
909 return entities_by_key[surviving_key], [entities_by_key[key] for key in sorted_keys if key != surviving_key]
911 def __surviving_key(self, entities_by_key: Mapping[str, Entity], sorted_keys: list[str]) -> str:
912 cluster_keys = set(sorted_keys)
913 preferred_keys = sorted(cluster_keys & self.preferred_survivors)
914 if len(preferred_keys) > 1:
915 message = f"Conflicting preferred survivors for merge cluster {sorted_keys}: {preferred_keys}."
916 raise ValueError(message)
917 if preferred_keys:
918 return preferred_keys[0]
919 return min(
920 sorted_keys,
921 key=lambda key: self.__survivor_sort_key(entities_by_key[key], key),
922 )
924 @classmethod
925 def __survivor_sort_key(cls, entity: object, entity_key: str) -> tuple[int | str, ...]:
926 quality = cls.__entity_quality(entity)
927 return (*[-value for value in quality], entity_key)
929 @staticmethod
930 def __entity_quality(entity: object) -> tuple[int, ...]:
931 if isinstance(entity, BibliographicResource):
932 pub_date = entity.get_pub_date()
933 return (
934 int(entity.get_title() is not None),
935 len(pub_date) if pub_date is not None else 0,
936 int(entity.get_subtitle() is not None),
937 int(entity.get_is_part_of() is not None),
938 int(entity.get_number() is not None),
939 int(entity.get_edition() is not None),
940 len(entity.get_types()),
941 )
942 if isinstance(entity, ResponsibleAgent):
943 name_parts = [
944 entity.get_name(),
945 entity.get_given_name(),
946 entity.get_family_name(),
947 ]
948 return (
949 sum(name is not None for name in name_parts),
950 sum(len(name) for name in name_parts if name is not None),
951 )
952 if isinstance(entity, Identifier):
953 return (
954 int(entity.get_scheme() is not None),
955 int(entity.get_literal_value() is not None),
956 )
957 return (0,)
959 def __storage(self) -> Storage:
960 if self.storage is None:
961 message = "storage is required to save deduplicated graph and provenance."
962 raise ValueError(message)
963 if self.storage.modified_entities is not None:
964 return self.storage
965 return replace(self.storage, modified_entities=set(self.modified_entities))
967 def __provenance(self) -> ProvSet:
968 if self.storage is None:
969 return ProvSet(self.graph_set, self.graph_set.base_iri)
970 return ProvSet(
971 self.graph_set,
972 self.graph_set.base_iri,
973 info_dir=self.storage.info_dir,
974 wanted_label=self.storage.wanted_label,
975 custom_counter_handler=self.storage.counter_handler,
976 supplier_prefix=self.storage.supplier_prefix,
977 )
979 @staticmethod
980 def __get_part_of(br: BibliographicResource) -> list[BibliographicResource]:
981 """
982 Given a Bibliographic Resource (BR), walk the full 'part-of' chain.
984 :param br: a Bibliographic Resource (BR)
985 :return partofs: a list that contains the Bibliographic Resources (BRs) of the hierarchy
986 """
987 partofs = []
988 entity = br
989 ended = False
990 while not ended:
991 partof = entity.get_is_part_of()
992 if partof is not None:
993 partofs.append(partof)
994 entity = partof
995 else:
996 ended = True
997 return partofs
999 @staticmethod
1000 def __get_publisher(br: BibliographicResource) -> AgentRole | None:
1001 """Given a Bibliographic Resource (BR), return the Agent Role (AR) that is a publisher."""
1002 for ar in br.get_contributors():
1003 role = ar.get_role_type()
1004 if role == GraphEntity.iri_publisher:
1005 return ar
1006 return None
1008 def __get_association_ar_ra(self) -> dict[ResponsibleAgent, list[AgentRole]]:
1009 """
1010 Return all the ARs associated to the same RA.
1012 :return association: a dictionary having Responsible Agent (RA) as key, and a list of Agent Role (AR) as value
1013 """
1014 association: dict[ResponsibleAgent, list[AgentRole]] = {}
1015 for ar in self.graph_set.get_ar():
1016 responsible_agent = ar.get_is_held_by()
1017 if responsible_agent is not None:
1018 association.setdefault(responsible_agent, []).append(ar)
1019 return association
1021 def __debug(self, message: str, *args: object) -> None:
1022 if self.debug:
1023 LOGGER.debug(message, *args)
1025 def __as_responsible_agent(
1026 self,
1027 entity: ManualMergeEntity,
1028 ) -> ResponsibleAgent:
1029 return cast("ResponsibleAgent", entity)
1031 def __as_bibliographic_resource(
1032 self,
1033 entity: ManualMergeEntity,
1034 ) -> BibliographicResource:
1035 return cast("BibliographicResource", entity)
1037 def __as_identifier(
1038 self,
1039 entity: ManualMergeEntity,
1040 ) -> Identifier:
1041 return cast("Identifier", entity)
1044__all__ = [
1045 "GraphDeduplicator",
1046]