Skip to content

Commit 6146be0

Browse files
committed
fix: coerce affiliations to arrays and dedupe duplicates (0.9.7)
creators[].affiliation and contributors[].affiliation are now always emitted in the schema's array form. The model sometimes returned a bare string or a single object, which violated the schema (affiliation must be an array) and skipped ROR enrichment. A coercion step runs before enrichment: string -> [string], object -> [object], blank/null/junk drops the optional key, and blank list items are filtered out. Duplicate affiliations are also collapsed (e.g. the same ROR object listed twice). Entries are keyed on ROR identifier when present, else on normalized name; the identifier-bearing entry wins on collision. Dedupe runs unconditionally after enrichment, so already-resolved posters are cleaned too.
1 parent 679ba95 commit 6146be0

5 files changed

Lines changed: 218 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.9.7] - 2026-06-08
9+
10+
### Fixed
11+
12+
- **`creators[].affiliation` / `contributors[].affiliation` always emitted as the schema's array form.** The model occasionally returned a bare string (e.g. `"affiliation": "Oregon Health & Science University, ..."`) or a single object, which violated the schema (`affiliation` must be an array) and skipped ROR enrichment entirely. A new coercion step now runs before enrichment: a bare string becomes `[string]`, a single object becomes `[object]`, blank/`null`/junk values drop the (optional) key, and blank items inside a list are filtered out. Because the value is now a proper list, string affiliations also become eligible for ROR resolution again.
13+
- **Duplicate affiliations are collapsed.** The same organization listed twice on a creator (a recurring model artifact, e.g. the identical ROR object repeated) is now deduplicated. Entries are keyed on their ROR identifier when present, else on their normalized name; when duplicates collide the entry carrying an identifier is kept, and a bare-name entry is dropped when an identified entry already covers the same organization. Runs unconditionally, after enrichment, so it also cleans posters whose affiliations were already fully resolved.
14+
815
## [0.9.6] - 2026-06-08
916

1017
### Changed

poster2json/extract.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2252,6 +2252,15 @@ def _postprocess_json(
22522252
# honest than guessing.
22532253
result["language"] = None
22542254

2255+
# Affiliation normalization: coerce to the schema's array form (the model
2256+
# sometimes emits a bare string or single object), then ROR-enrich, then
2257+
# collapse duplicates. Coerce and dedupe run unconditionally; only the
2258+
# network enrichment is gated on something actually being unresolved.
2259+
from .ror import coerce_person_affiliations, dedupe_person_affiliations
2260+
for _persons_key in ("creators", "contributors"):
2261+
if _persons_key in result:
2262+
result[_persons_key] = coerce_person_affiliations(result[_persons_key])
2263+
22552264
# ROR enrichment -- skip if all affiliations already resolved
22562265
if (_needs_ror_enrichment(result.get("creators"))
22572266
or _needs_ror_enrichment(result.get("contributors"))):
@@ -2262,6 +2271,10 @@ def _postprocess_json(
22622271
if "contributors" in result:
22632272
result["contributors"] = enrich_persons(result["contributors"], ror)
22642273

2274+
for _persons_key in ("creators", "contributors"):
2275+
if _persons_key in result:
2276+
result[_persons_key] = dedupe_person_affiliations(result[_persons_key])
2277+
22652278
# Funder + award normalization, then ROR funder lookup
22662279
if "fundingReferences" in result:
22672280
from .normalize import normalize_funding_references

poster2json/ror.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,125 @@ def enrich_persons(persons: list, client: RorClient) -> list:
222222
return persons
223223

224224

225+
def _coerce_affiliation_value(aff):
226+
"""Coerce one person's ``affiliation`` into the schema's array form.
227+
228+
The schema requires ``affiliation`` to be an array of strings/objects, but
229+
the model sometimes emits a bare string or a single object. Returns a
230+
non-empty list, or ``None`` to signal the key should be dropped (it was
231+
null, empty, or contained only junk — ``affiliation`` is optional).
232+
"""
233+
if isinstance(aff, str):
234+
s = aff.strip()
235+
return [s] if s else None
236+
if isinstance(aff, dict):
237+
return [aff] if (aff.get("name") or aff.get("affiliationIdentifier")) else None
238+
if isinstance(aff, list):
239+
out = []
240+
for item in aff:
241+
if isinstance(item, str):
242+
s = item.strip()
243+
if s:
244+
out.append(s)
245+
elif isinstance(item, dict):
246+
if item.get("name") or item.get("affiliationIdentifier"):
247+
out.append(item)
248+
# drop anything else (numbers, None, nested lists)
249+
return out or None
250+
return None
251+
252+
253+
def coerce_person_affiliations(persons: list) -> list:
254+
"""Normalize ``affiliation`` on every person to a list (in place).
255+
256+
A bare string becomes ``[string]``, a single object becomes ``[object]``,
257+
and null/empty values drop the key. Runs before ROR enrichment so the
258+
enrichment and the schema both see a proper array.
259+
"""
260+
if not isinstance(persons, list):
261+
return persons
262+
for p in persons:
263+
if not isinstance(p, dict) or "affiliation" not in p:
264+
continue
265+
coerced = _coerce_affiliation_value(p["affiliation"])
266+
if coerced is None:
267+
p.pop("affiliation", None)
268+
else:
269+
p["affiliation"] = coerced
270+
return persons
271+
272+
273+
def _affiliation_name(item) -> Optional[str]:
274+
if isinstance(item, str):
275+
name = item
276+
elif isinstance(item, dict) and isinstance(item.get("name"), str):
277+
name = item["name"]
278+
else:
279+
return None
280+
name = unicodedata.normalize("NFKC", name).strip().casefold()
281+
return name or None
282+
283+
284+
def _affiliation_dedupe_key(item):
285+
if isinstance(item, dict):
286+
ident = item.get("affiliationIdentifier")
287+
if ident:
288+
return ("id", str(ident).strip().lower())
289+
name = _affiliation_name(item)
290+
if name is not None:
291+
return ("name", name)
292+
return ("obj", json.dumps(item, sort_keys=True, ensure_ascii=False))
293+
294+
295+
def _affiliation_richness(item) -> int:
296+
if isinstance(item, dict):
297+
return 2 if item.get("affiliationIdentifier") else 1
298+
return 0
299+
300+
301+
def dedupe_person_affiliations(persons: list) -> list:
302+
"""Collapse duplicate affiliation entries on every person (in place).
303+
304+
Entries are keyed on their ROR identifier when present, else on their
305+
normalized name, so the same organization listed twice (a recurring model
306+
artifact) collapses to one. When duplicates collide the richer entry (one
307+
carrying an identifier) is kept, and a bare-name entry is dropped when an
308+
identified entry already covers the same organization name.
309+
"""
310+
if not isinstance(persons, list):
311+
return persons
312+
for p in persons:
313+
if not isinstance(p, dict):
314+
continue
315+
affs = p.get("affiliation")
316+
if not isinstance(affs, list) or len(affs) < 2:
317+
continue
318+
order = []
319+
chosen = {}
320+
for item in affs:
321+
key = _affiliation_dedupe_key(item)
322+
if key not in chosen:
323+
chosen[key] = item
324+
order.append(key)
325+
elif _affiliation_richness(item) > _affiliation_richness(chosen[key]):
326+
chosen[key] = item
327+
# Drop bare-name entries already covered by an identified entry.
328+
identified_names = {
329+
_affiliation_name(it)
330+
for it in chosen.values()
331+
if isinstance(it, dict) and it.get("affiliationIdentifier")
332+
}
333+
identified_names.discard(None)
334+
result = []
335+
for key in order:
336+
it = chosen[key]
337+
if key[0] == "name" and key[1] in identified_names and _affiliation_richness(it) < 2:
338+
continue
339+
result.append(it)
340+
p["affiliation"] = result
341+
return persons
342+
343+
225344
_default_client: Optional[RorClient] = None
226345

227346

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[tool.poetry]
22

33
name = "poster2json"
4-
version = "0.9.6"
4+
version = "0.9.7"
55
description = "Convert scientific posters (PDF/images) to structured JSON metadata using Large Language Models"
66

77
packages = [{ include = "poster2json" }]

tests/test_ror.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from poster2json.ror import (
99
RorClient,
1010
_enrich_affiliation_item,
11+
coerce_person_affiliations,
12+
dedupe_person_affiliations,
1113
enrich_persons,
1214
)
1315

@@ -94,6 +96,82 @@ def test_cache_records_negative_lookups(tmp_path, monkeypatch):
9496
assert client.lookup("Unknown Inst") is None
9597

9698

99+
def test_coerce_bare_string_affiliation_to_list():
100+
# The schema-violating shape reported in the field: affiliation is a string.
101+
persons = [{
102+
"name": "Tedla, Saron",
103+
"affiliation": "Oregon Health & Science University, School of Medicine, Portland, OR, USA",
104+
}]
105+
out = coerce_person_affiliations(persons)
106+
assert out[0]["affiliation"] == [
107+
"Oregon Health & Science University, School of Medicine, Portland, OR, USA"
108+
]
109+
110+
111+
def test_coerce_single_object_to_list():
112+
persons = [{"name": "A", "affiliation": {"name": "MIT"}}]
113+
out = coerce_person_affiliations(persons)
114+
assert out[0]["affiliation"] == [{"name": "MIT"}]
115+
116+
117+
def test_coerce_drops_null_empty_and_junk():
118+
persons = [
119+
{"name": "A", "affiliation": None},
120+
{"name": "B", "affiliation": ""},
121+
{"name": "C", "affiliation": []},
122+
{"name": "D", "affiliation": [" ", None, 5, {"foo": "bar"}]},
123+
{"name": "E"},
124+
]
125+
out = coerce_person_affiliations(persons)
126+
assert "affiliation" not in out[0]
127+
assert "affiliation" not in out[1]
128+
assert "affiliation" not in out[2]
129+
assert "affiliation" not in out[3] # all items were junk
130+
assert "affiliation" not in out[4]
131+
132+
133+
def test_coerce_filters_blank_list_items_but_keeps_valid():
134+
persons = [{"name": "A", "affiliation": ["Stanford", " ", {"name": "MIT"}, {}]}]
135+
out = coerce_person_affiliations(persons)
136+
assert out[0]["affiliation"] == ["Stanford", {"name": "MIT"}]
137+
138+
139+
def test_dedupe_identical_objects():
140+
# The other reported shape: same ROR object listed twice (Jalili / UCSD).
141+
ucsd = {
142+
"name": "University of California San Diego",
143+
"schemeUri": "https://ror.org/",
144+
"affiliationIdentifier": "https://ror.org/0168r3w48",
145+
"affiliationIdentifierScheme": "ROR",
146+
}
147+
persons = [{"name": "Jalili, Jalil", "affiliation": [dict(ucsd), dict(ucsd)]}]
148+
out = dedupe_person_affiliations(persons)
149+
assert out[0]["affiliation"] == [ucsd]
150+
151+
152+
def test_dedupe_identical_strings():
153+
persons = [{"name": "A", "affiliation": ["MIT", "mit", "MIT "]}]
154+
out = dedupe_person_affiliations(persons)
155+
assert out[0]["affiliation"] == ["MIT"]
156+
157+
158+
def test_dedupe_prefers_identified_entry_over_bare_name():
159+
persons = [{"name": "A", "affiliation": [
160+
"Stanford University",
161+
{"name": "Stanford University", "affiliationIdentifier": "https://ror.org/00f54p054"},
162+
]}]
163+
out = dedupe_person_affiliations(persons)
164+
assert out[0]["affiliation"] == [
165+
{"name": "Stanford University", "affiliationIdentifier": "https://ror.org/00f54p054"}
166+
]
167+
168+
169+
def test_dedupe_keeps_distinct_affiliations_and_order():
170+
persons = [{"name": "A", "affiliation": ["MIT", "Stanford", "MIT"]}]
171+
out = dedupe_person_affiliations(persons)
172+
assert out[0]["affiliation"] == ["MIT", "Stanford"]
173+
174+
97175
def test_strip_trailing_country():
98176
from poster2json.ror import _strip_trailing_country
99177

0 commit comments

Comments
 (0)