Coverage for oc_meta / lib / agent_matching.py: 94%
105 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-07-25 10:39 +0000
« 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
5from __future__ import annotations
7import unicodedata
8from dataclasses import dataclass
10from rapidfuzz import fuzz
13@dataclass(frozen=True, slots=True)
14class PersonName:
15 name: str = ""
16 given: str = ""
17 family: str = ""
19 @property
20 def display(self) -> str:
21 return self.name or " ".join(part for part in (self.given, self.family) if part)
24@dataclass(frozen=True, slots=True)
25class AlignmentPair:
26 local_index: int
27 external_index: int
28 score: float
31@dataclass(frozen=True, slots=True)
32class AlignmentResult:
33 pairs: tuple[AlignmentPair, ...]
34 unmatched_local: tuple[int, ...]
35 unmatched_external: tuple[int, ...]
36 ambiguous: bool
39def normalize_name(value: str) -> str:
40 decomposed = unicodedata.normalize("NFKD", value).casefold()
41 characters = (
42 character for character in decomposed if unicodedata.category(character) != "Mn"
43 )
44 return " ".join(
45 "".join(
46 character if character.isalnum() else " " for character in characters
47 ).split()
48 )
51def script_family(value: str) -> str:
52 families = set()
53 for character in value:
54 if not character.isalpha():
55 continue
56 name = unicodedata.name(character, "")
57 if "CYRILLIC" in name:
58 families.add("cyrillic")
59 elif "GREEK" in name:
60 families.add("greek")
61 elif "LATIN" in name:
62 families.add("latin")
63 elif "CJK" in name or "HIRAGANA" in name or "KATAKANA" in name:
64 families.add("cjk")
65 else:
66 families.add("other")
67 return next(iter(families)) if len(families) == 1 else "mixed"
70def _initials_compatible(left: str, right: str) -> bool:
71 left_tokens = normalize_name(left).split()
72 right_tokens = normalize_name(right).split()
73 if not left_tokens or not right_tokens:
74 return False
75 limit = min(len(left_tokens), len(right_tokens))
76 return all(
77 left_tokens[index] == right_tokens[index]
78 or (
79 left_tokens[index][0] == right_tokens[index][0]
80 and (len(left_tokens[index]) == 1 or len(right_tokens[index]) == 1)
81 )
82 for index in range(limit)
83 )
86def name_score(left: PersonName, right: PersonName) -> float:
87 left_display = normalize_name(left.display)
88 right_display = normalize_name(right.display)
89 if not left_display or not right_display:
90 return 0.0
91 if left_display == right_display:
92 return 1.0
93 if set(left_display.split()) == set(right_display.split()):
94 return 0.95
96 left_family = normalize_name(left.family)
97 right_family = normalize_name(right.family)
98 if (
99 left_family
100 and right_family
101 and left_family == right_family
102 and _initials_compatible(left.given, right.given)
103 ):
104 return 0.9
106 left_script = script_family(left.display)
107 right_script = script_family(right.display)
108 if left_script != right_script or "mixed" in {left_script, right_script}:
109 return 0.0
110 return fuzz.ratio(left_display, right_display) / 100
113def align_names(
114 local: list[PersonName], external: list[PersonName], gap_penalty: float = 0.45
115) -> AlignmentResult:
116 rows = len(local) + 1
117 columns = len(external) + 1
118 scores = [[0.0] * columns for _ in range(rows)]
119 paths = [[1] * columns for _ in range(rows)]
120 moves = [[""] * columns for _ in range(rows)]
122 for row in range(1, rows):
123 scores[row][0] = scores[row - 1][0] - gap_penalty
124 moves[row][0] = "local"
125 for column in range(1, columns):
126 scores[0][column] = scores[0][column - 1] - gap_penalty
127 moves[0][column] = "external"
129 for row in range(1, rows):
130 for column in range(1, columns):
131 candidates = {
132 "match": scores[row - 1][column - 1]
133 + name_score(local[row - 1], external[column - 1]),
134 "local": scores[row - 1][column] - gap_penalty,
135 "external": scores[row][column - 1] - gap_penalty,
136 }
137 best = max(candidates.values())
138 best_moves = [
139 move for move, score in candidates.items() if abs(score - best) < 1e-9
140 ]
141 scores[row][column] = best
142 moves[row][column] = best_moves[0]
143 paths[row][column] = min(
144 2,
145 sum(
146 paths[row - 1][column - 1]
147 if move == "match"
148 else paths[row - 1][column]
149 if move == "local"
150 else paths[row][column - 1]
151 for move in best_moves
152 ),
153 )
155 pairs = []
156 unmatched_local = []
157 unmatched_external = []
158 row = len(local)
159 column = len(external)
160 while row or column:
161 move = moves[row][column]
162 if move == "match":
163 pairs.append(
164 AlignmentPair(
165 local_index=row - 1,
166 external_index=column - 1,
167 score=name_score(local[row - 1], external[column - 1]),
168 )
169 )
170 row -= 1
171 column -= 1
172 elif move == "local":
173 unmatched_local.append(row - 1)
174 row -= 1
175 else:
176 unmatched_external.append(column - 1)
177 column -= 1
179 return AlignmentResult(
180 pairs=tuple(reversed(pairs)),
181 unmatched_local=tuple(reversed(unmatched_local)),
182 unmatched_external=tuple(reversed(unmatched_external)),
183 ambiguous=paths[-1][-1] > 1,
184 )