Skip to content

Commit 334a7e0

Browse files
committed
fix: reassign affiliations from author superscript markers (0.9.10)
When a poster banner has a numbered affiliation list and authors carry superscript markers (e.g. "Limaye 1,3"), the model often over-assigns every institution to authors (typically the lead author absorbing all of them). A new strictly-gated postprocess step parses the numbered affiliation list and each author's markers from the raw text and reassigns each author exactly their own numbered affiliations, which then resolve through ROR. No-op unless a clean sequential 1..N list is found and every author marker resolves. On a real 7-author poster the lead author went from 5 affiliations to his correct 2.
1 parent 6251db0 commit 334a7e0

4 files changed

Lines changed: 167 additions & 1 deletion

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.10] - 2026-06-09
9+
10+
### Fixed
11+
12+
- **Authors no longer over-assigned every affiliation.** On posters whose banner has a numbered affiliation list and whose authors carry superscript markers (e.g. "Limaye 1,3"), the fine-tuned model frequently assigned every institution to authors — most often the lead author absorbing all of them — even when the raw text was unambiguous. A new strictly-gated postprocess step (`_correct_affiliations_from_superscripts`) parses the numbered affiliation list and each author's markers from the raw text and reassigns each author exactly their own numbered affiliations (which then resolve through ROR as usual). It is a no-op unless a clean sequential `1..N` list is found, every author has a marker, and every marker resolves to a parsed affiliation — so posters without numbered affiliations are left untouched, and a `_validation` info note is emitted when it fires. On a real 7-author poster this corrected the lead author from 5 affiliations (two of them other authors' institutions) down to his correct 2.
13+
814
## [0.9.9] - 2026-06-08
915

1016
### Changed

poster2json/extract.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1976,6 +1976,107 @@ def _is_section_label_only(text: str) -> bool:
19761976
return bool(toks) and all(w in _SECTION_LABELS for w in toks)
19771977

19781978

1979+
_AFFIL_LIST_SIGNATURE = re.compile(r";\s*\d{1,2}\s+[A-Z]")
1980+
_AFFIL_SEGMENT_RE = re.compile(r"^(\d{1,2})\s+([A-Z].{4,})$")
1981+
_AFFIL_FIRST_TAIL_RE = re.compile(r"(?<!\d)1\s+([A-Z].{4,})$")
1982+
1983+
1984+
def _parse_numbered_affiliations(banner: str):
1985+
"""Parse a poster banner's numbered affiliation list into ``{n: text}``.
1986+
1987+
Returns the map only when it is a clean, fully sequential ``1..N`` (N>=2)
1988+
list (affiliations are ``;``-delimited and digit-prefixed, with the first
1989+
entry trailing the author segment). Returns ``None`` otherwise, so callers
1990+
treat anything ambiguous as "do not touch".
1991+
"""
1992+
parts = re.split(r"\s*;\s*", banner.strip())
1993+
if len(parts) < 2:
1994+
return None
1995+
amap = {}
1996+
for seg in parts[1:]:
1997+
m = _AFFIL_SEGMENT_RE.match(seg.strip())
1998+
if not m:
1999+
return None
2000+
amap[int(m.group(1))] = m.group(2).strip()
2001+
m1 = _AFFIL_FIRST_TAIL_RE.search(parts[0])
2002+
if m1:
2003+
amap[1] = m1.group(1).strip()
2004+
if len(amap) < 2:
2005+
return None
2006+
hi = max(amap)
2007+
if set(amap) != set(range(1, hi + 1)):
2008+
return None
2009+
return amap
2010+
2011+
2012+
def _author_markers(banner: str, family: str):
2013+
"""Return the superscript marker numbers that follow ``family`` in the
2014+
banner (e.g. "Limaye 1,3" -> [1, 3]), or ``None`` if no marker is found."""
2015+
m = re.search(re.escape(family) + r"\s{0,3}(\d{1,2}(?:\s*,\s*\d{1,2})*)", banner)
2016+
if not m:
2017+
return None
2018+
return [int(x) for x in re.split(r"\s*,\s*", m.group(1))]
2019+
2020+
2021+
def _correct_affiliations_from_superscripts(result: dict, raw_text: str) -> None:
2022+
"""Rebuild each author's affiliation list from superscript markers in the
2023+
raw text when the poster banner carries a numbered affiliation list.
2024+
2025+
The fine-tuned model commonly over-assigns affiliations (e.g. the lead
2026+
author absorbs every institution) even when the raw text is unambiguous.
2027+
The markers in the raw text are authoritative, so when present they are
2028+
used to reassign affiliations deterministically. Strictly gated: a no-op
2029+
unless a clean numbered list is found, every creator has a marker, and
2030+
every marker resolves to a parsed affiliation — so posters without this
2031+
structure are left untouched.
2032+
"""
2033+
if not raw_text:
2034+
return
2035+
creators = result.get("creators")
2036+
if not isinstance(creators, list) or len(creators) < 2:
2037+
return
2038+
families = []
2039+
for c in creators:
2040+
if not isinstance(c, dict):
2041+
return
2042+
fam = c.get("familyName") or ""
2043+
if not fam:
2044+
nm = c.get("name", "")
2045+
fam = nm.split(",")[0].strip() if "," in nm else ""
2046+
if not fam:
2047+
return
2048+
families.append(fam)
2049+
2050+
banner = None
2051+
for line in raw_text.splitlines():
2052+
if families[0] in line and _AFFIL_LIST_SIGNATURE.search(line):
2053+
banner = line
2054+
break
2055+
if banner is None:
2056+
return
2057+
2058+
amap = _parse_numbered_affiliations(banner)
2059+
if not amap:
2060+
return
2061+
2062+
plan = []
2063+
for fam in families:
2064+
marks = _author_markers(banner, fam)
2065+
if not marks or any(n not in amap for n in marks):
2066+
return
2067+
plan.append(marks)
2068+
2069+
for c, marks in zip(creators, plan):
2070+
c["affiliation"] = [amap[n] for n in marks]
2071+
notes = result.setdefault("_validation", [])
2072+
if isinstance(notes, list):
2073+
notes.append({
2074+
"field": "creators",
2075+
"level": "info",
2076+
"message": "Affiliations reassigned from author superscript markers in the poster banner.",
2077+
})
2078+
2079+
19792080
def _postprocess_json(
19802081
data: dict, raw_text: str = "", extract_identifiers: bool = False
19812082
) -> dict:
@@ -2252,6 +2353,13 @@ def _postprocess_json(
22522353
# honest than guessing.
22532354
result["language"] = None
22542355

2356+
# Reassign affiliations from author superscript markers when the poster
2357+
# banner has a numbered affiliation list (the model commonly over-assigns,
2358+
# e.g. the lead author absorbing every institution). Strictly gated, so it
2359+
# is a no-op on posters without a numbered list. Runs before resolution so
2360+
# the reassigned name strings get ROR-resolved normally.
2361+
_correct_affiliations_from_superscripts(result, raw_text)
2362+
22552363
# Affiliation normalization: coerce to the schema's array form (the model
22562364
# sometimes emits a bare string or single object), drop any model-supplied
22572365
# identifiers (ROR IDs are resolved from the name, never trusted from what

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

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

tests/test_normalize.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,3 +285,55 @@ def test_postprocess_strips_surrogates_so_json_dumps_succeeds():
285285
data = {"titles": [{"title": "A\ud83dB"}], "subjects": [{"subject": "x\ud83d"}]}
286286
out = _postprocess_json(data, raw_text="")
287287
json.dumps(out, ensure_ascii=False).encode("utf-8") # must not raise
288+
289+
290+
def test_superscript_corrector_reassigns_from_markers():
291+
from poster2json.extract import _correct_affiliations_from_superscripts
292+
293+
raw = (
294+
"Abridged Retinal Fluorescence Lifetimes in Glaucoma\n"
295+
"Siddharth Limaye 1,3 Shahin Hallaj 1,2 , Maria Jessica Cruz 4 , "
296+
"Saron Tedla 5 , Jalil Jalili 1,2 , Mark Christopher 1,2 , Linda M. Zangwill 1,2 "
297+
"1 Hamilton Glaucoma Center, University of California, San Diego, CA, US; "
298+
"2 Division of Ophthalmology Informatics, University of California, San Diego, CA, USA; "
299+
"3 Carle Illinois College of Medicine, University of Illinois at Urbana-Champaign, IL, USA; "
300+
"4 University of California, Davis, School of Medicine, CA, USA; "
301+
"5 Oregon Health and Science University, School of Medicine, OR, USA\n"
302+
"## BACKGROUND\n"
303+
)
304+
# The model over-assigned every institution to every author.
305+
everything = ["Hamilton", "Division", "Carle", "Davis", "Oregon"]
306+
result = {"creators": [
307+
{"name": "Limaye, Siddharth", "familyName": "Limaye", "affiliation": list(everything)},
308+
{"name": "Hallaj, Shahin", "familyName": "Hallaj", "affiliation": list(everything)},
309+
{"name": "Cruz, Maria Jessica", "familyName": "Cruz", "affiliation": list(everything)},
310+
{"name": "Tedla, Saron", "familyName": "Tedla", "affiliation": list(everything)},
311+
{"name": "Jalili, Jalil", "familyName": "Jalili", "affiliation": list(everything)},
312+
{"name": "Christopher, Mark", "familyName": "Christopher", "affiliation": list(everything)},
313+
{"name": "Zangwill, Linda M.", "familyName": "Zangwill", "affiliation": list(everything)},
314+
]}
315+
_correct_affiliations_from_superscripts(result, raw)
316+
affs = [c["affiliation"] for c in result["creators"]]
317+
# Limaye 1,3 -> Hamilton (UCSD) + Carle (UIUC), not all 5
318+
assert len(affs[0]) == 2
319+
assert affs[0][0].startswith("Hamilton") and "Urbana-Champaign" in affs[0][1]
320+
# Cruz 4 -> only UC Davis; Tedla 5 -> only OHSU
321+
assert len(affs[2]) == 1 and "Davis" in affs[2][0]
322+
assert len(affs[3]) == 1 and "Oregon" in affs[3][0]
323+
# 1,2 authors -> both UCSD departments
324+
assert len(affs[1]) == 2
325+
assert any(n.get("level") == "info" for n in result.get("_validation", []))
326+
327+
328+
def test_superscript_corrector_noop_without_numbered_list():
329+
from poster2json.extract import _correct_affiliations_from_superscripts
330+
331+
raw = "Jane Doe, John Smith\nUniversity of Somewhere\n## INTRODUCTION\n"
332+
result = {"creators": [
333+
{"name": "Doe, Jane", "familyName": "Doe", "affiliation": ["University of Somewhere"]},
334+
{"name": "Smith, John", "familyName": "Smith", "affiliation": ["University of Somewhere"]},
335+
]}
336+
before = [list(c["affiliation"]) for c in result["creators"]]
337+
_correct_affiliations_from_superscripts(result, raw)
338+
assert [c["affiliation"] for c in result["creators"]] == before
339+
assert "_validation" not in result

0 commit comments

Comments
 (0)