Skip to content

Commit 6251db0

Browse files
committed
fix: preserve distinct sub-unit affiliations sharing a ROR id (0.9.9)
ROR enrichment used to replace every affiliation string with the org's canonical display name, so two departments of one university (e.g. a Hamilton Glaucoma Center line and a Division of Ophthalmology Informatics line, both at UC San Diego) both became "University of California San Diego" and were then deduped to a single entry, losing the department. Replace the separate enrich + dedupe passes with one resolve_person_affiliations step: when several DISTINCT source names map to the same ROR id, keep each name with the shared identifier; a single source name still uses ROR's canonical name; true duplicates still collapse; unresolved affiliations dedupe by name.
1 parent f58c78c commit 6251db0

5 files changed

Lines changed: 151 additions & 204 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ 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.9] - 2026-06-08
9+
10+
### Changed
11+
12+
- **Affiliations sharing a ROR organization now preserve distinct sub-unit names instead of collapsing.** Previously ROR enrichment replaced every affiliation string with the org's canonical display name, so two departments of one university — e.g. `"Hamilton Glaucoma Center, … University of California San Diego"` and `"Division of Ophthalmology Informatics …, University of California San Diego"` — both became `"University of California San Diego"` and were then de-duplicated to a single entry, discarding the department detail. Affiliation resolution now keeps each **distinct** source name when several map to the same ROR id, attaching the shared identifier to each. A **single** source name still uses ROR's canonical display name, and true duplicates (the same name twice) still collapse to one. Unresolved affiliations are de-duplicated by normalized name. Internally the separate enrich + dedupe passes are replaced by one `resolve_person_affiliations` step (which subsumes the 0.9.7 dedupe).
13+
814
## [0.9.8] - 2026-06-08
915

1016
### Changed

poster2json/extract.py

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2255,32 +2255,25 @@ def _postprocess_json(
22552255
# Affiliation normalization: coerce to the schema's array form (the model
22562256
# sometimes emits a bare string or single object), drop any model-supplied
22572257
# identifiers (ROR IDs are resolved from the name, never trusted from what
2258-
# the model scraped off the poster), then ROR-enrich, then collapse
2259-
# duplicates. Coerce/strip/dedupe run unconditionally; only the network
2260-
# enrichment is gated on something actually being unresolved.
2261-
from .ror import (
2262-
coerce_person_affiliations,
2263-
dedupe_person_affiliations,
2264-
strip_extracted_affiliation_ids,
2265-
)
2258+
# the model scraped off the poster), then resolve against ROR. Resolution
2259+
# also collapses same-org duplicates while preserving distinct sub-unit
2260+
# names that share one ROR id. Coerce/strip run unconditionally; resolution
2261+
# is gated on something actually being unresolved.
2262+
from .ror import coerce_person_affiliations, strip_extracted_affiliation_ids
22662263
for _persons_key in ("creators", "contributors"):
22672264
if _persons_key in result:
22682265
result[_persons_key] = coerce_person_affiliations(result[_persons_key])
22692266
result[_persons_key] = strip_extracted_affiliation_ids(result[_persons_key])
22702267

2271-
# ROR enrichment -- skip if all affiliations already resolved
22722268
if (_needs_ror_enrichment(result.get("creators"))
22732269
or _needs_ror_enrichment(result.get("contributors"))):
2274-
from .ror import enrich_persons, get_default_client
2270+
from .ror import get_default_client, resolve_person_affiliations
22752271
ror = get_default_client()
2276-
if "creators" in result:
2277-
result["creators"] = enrich_persons(result["creators"], ror)
2278-
if "contributors" in result:
2279-
result["contributors"] = enrich_persons(result["contributors"], ror)
2280-
2281-
for _persons_key in ("creators", "contributors"):
2282-
if _persons_key in result:
2283-
result[_persons_key] = dedupe_person_affiliations(result[_persons_key])
2272+
for _persons_key in ("creators", "contributors"):
2273+
if _persons_key in result:
2274+
result[_persons_key] = resolve_person_affiliations(
2275+
result[_persons_key], ror
2276+
)
22842277

22852278
# Funder + award normalization, then ROR funder lookup
22862279
if "fundingReferences" in result:

poster2json/ror.py

Lines changed: 79 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -180,45 +180,90 @@ def lookup(self, name: str) -> Optional[dict]:
180180
return match
181181

182182

183-
def _enrich_affiliation_item(item, client: RorClient):
183+
def _affiliation_display_name(item) -> Optional[str]:
184+
"""Return the human-readable affiliation name (string, or dict ``name``)."""
184185
if isinstance(item, str):
185-
m = client.lookup(item)
186-
if m:
187-
return {
188-
"name": m["name"],
189-
"affiliationIdentifier": m["id"],
190-
"affiliationIdentifierScheme": "ROR",
191-
"schemeUri": "https://ror.org/",
192-
}
193-
return item
194-
if isinstance(item, dict):
195-
if item.get("affiliationIdentifier"):
196-
return item
197-
name = item.get("name")
198-
if not isinstance(name, str):
199-
return item
200-
m = client.lookup(name)
201-
if m:
202-
out = dict(item)
203-
out["name"] = m["name"]
204-
out["affiliationIdentifier"] = m["id"]
205-
out["affiliationIdentifierScheme"] = "ROR"
206-
out.setdefault("schemeUri", "https://ror.org/")
207-
return out
208-
return item
209-
return item
210-
211-
212-
def enrich_persons(persons: list, client: RorClient) -> list:
213-
"""Enrich affiliations on a creators or contributors list (in place)."""
186+
return item.strip() or None
187+
if isinstance(item, dict) and isinstance(item.get("name"), str):
188+
return item["name"].strip() or None
189+
return None
190+
191+
192+
def _norm_name(name: str) -> str:
193+
return unicodedata.normalize("NFKC", name).strip().casefold()
194+
195+
196+
def resolve_person_affiliations(persons: list, client: RorClient) -> list:
197+
"""Resolve affiliations against ROR and collapse same-org duplicates.
198+
199+
Operates in place on a creators/contributors list. Each affiliation (a
200+
string or a ``{"name": ...}`` object — model-supplied identifiers were
201+
already stripped) is looked up in ROR by name:
202+
203+
- **Unresolved** affiliations keep their original value and are
204+
de-duplicated by normalized name.
205+
- A ROR org reached by a **single** distinct source name is emitted once,
206+
using ROR's canonical display name plus the identifier.
207+
- When **several distinct** source names resolve to the **same** ROR org
208+
(e.g. two departments of one university), each distinct name is kept with
209+
the shared identifier, preserving the sub-unit detail instead of
210+
collapsing to one canonical entry.
211+
212+
True duplicates (the same name listed twice) always collapse to one entry.
213+
"""
214214
if not isinstance(persons, list):
215215
return persons
216216
for p in persons:
217217
if not isinstance(p, dict):
218218
continue
219219
affs = p.get("affiliation")
220-
if isinstance(affs, list):
221-
p["affiliation"] = [_enrich_affiliation_item(a, client) for a in affs]
220+
if not isinstance(affs, list):
221+
continue
222+
223+
# Look each item up once, remembering its original display name.
224+
records = [] # (display_name | None, ror_match | None, raw_item)
225+
for item in affs:
226+
name = _affiliation_display_name(item)
227+
ror = client.lookup(name) if name else None
228+
records.append((name, ror, item))
229+
230+
# How many distinct source names map to each resolved ROR id?
231+
names_per_id = {}
232+
for name, ror, _ in records:
233+
if ror and name:
234+
names_per_id.setdefault(ror["id"], set()).add(_norm_name(name))
235+
236+
out = []
237+
seen = set()
238+
for name, ror, raw in records:
239+
if ror and name:
240+
rid = ror["id"]
241+
if len(names_per_id.get(rid, ())) > 1:
242+
# Several distinct sub-units of one org: keep each name.
243+
key = ("id+name", rid, _norm_name(name))
244+
display = name
245+
else:
246+
# Single source name: use ROR's canonical display name.
247+
key = ("id", rid)
248+
display = ror["name"]
249+
if key in seen:
250+
continue
251+
seen.add(key)
252+
out.append({
253+
"name": display,
254+
"affiliationIdentifier": rid,
255+
"affiliationIdentifierScheme": "ROR",
256+
"schemeUri": "https://ror.org/",
257+
})
258+
else:
259+
# Unresolved: keep original value, de-dupe by normalized name.
260+
if name is not None:
261+
key = ("name", _norm_name(name))
262+
if key in seen:
263+
continue
264+
seen.add(key)
265+
out.append(raw)
266+
p["affiliation"] = out
222267
return persons
223268

224269

@@ -286,8 +331,8 @@ def strip_extracted_affiliation_ids(persons: list) -> list:
286331
annotations). The prompt does not request an identifier, so any
287332
``affiliationIdentifier`` / ``affiliationIdentifierScheme`` / ``schemeUri``
288333
present was scraped by the model; we remove it here, before enrichment, so
289-
``enrich_persons`` resolves each affiliation by name. Mirrors how
290-
``creators[].nameIdentifiers[]`` drop model-emitted scheme fields.
334+
``resolve_person_affiliations`` resolves each affiliation by name. Mirrors
335+
how ``creators[].nameIdentifiers[]`` drop model-emitted scheme fields.
291336
"""
292337
if not isinstance(persons, list):
293338
return persons
@@ -304,77 +349,6 @@ def strip_extracted_affiliation_ids(persons: list) -> list:
304349
return persons
305350

306351

307-
def _affiliation_name(item) -> Optional[str]:
308-
if isinstance(item, str):
309-
name = item
310-
elif isinstance(item, dict) and isinstance(item.get("name"), str):
311-
name = item["name"]
312-
else:
313-
return None
314-
name = unicodedata.normalize("NFKC", name).strip().casefold()
315-
return name or None
316-
317-
318-
def _affiliation_dedupe_key(item):
319-
if isinstance(item, dict):
320-
ident = item.get("affiliationIdentifier")
321-
if ident:
322-
return ("id", str(ident).strip().lower())
323-
name = _affiliation_name(item)
324-
if name is not None:
325-
return ("name", name)
326-
return ("obj", json.dumps(item, sort_keys=True, ensure_ascii=False))
327-
328-
329-
def _affiliation_richness(item) -> int:
330-
if isinstance(item, dict):
331-
return 2 if item.get("affiliationIdentifier") else 1
332-
return 0
333-
334-
335-
def dedupe_person_affiliations(persons: list) -> list:
336-
"""Collapse duplicate affiliation entries on every person (in place).
337-
338-
Entries are keyed on their ROR identifier when present, else on their
339-
normalized name, so the same organization listed twice (a recurring model
340-
artifact) collapses to one. When duplicates collide the richer entry (one
341-
carrying an identifier) is kept, and a bare-name entry is dropped when an
342-
identified entry already covers the same organization name.
343-
"""
344-
if not isinstance(persons, list):
345-
return persons
346-
for p in persons:
347-
if not isinstance(p, dict):
348-
continue
349-
affs = p.get("affiliation")
350-
if not isinstance(affs, list) or len(affs) < 2:
351-
continue
352-
order = []
353-
chosen = {}
354-
for item in affs:
355-
key = _affiliation_dedupe_key(item)
356-
if key not in chosen:
357-
chosen[key] = item
358-
order.append(key)
359-
elif _affiliation_richness(item) > _affiliation_richness(chosen[key]):
360-
chosen[key] = item
361-
# Drop bare-name entries already covered by an identified entry.
362-
identified_names = {
363-
_affiliation_name(it)
364-
for it in chosen.values()
365-
if isinstance(it, dict) and it.get("affiliationIdentifier")
366-
}
367-
identified_names.discard(None)
368-
result = []
369-
for key in order:
370-
it = chosen[key]
371-
if key[0] == "name" and key[1] in identified_names and _affiliation_richness(it) < 2:
372-
continue
373-
result.append(it)
374-
p["affiliation"] = result
375-
return persons
376-
377-
378352
_default_client: Optional[RorClient] = None
379353

380354

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.8"
4+
version = "0.9.9"
55
description = "Convert scientific posters (PDF/images) to structured JSON metadata using Large Language Models"
66

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

0 commit comments

Comments
 (0)