Skip to content

Commit 6595b13

Browse files
committed
fix: generalize superscript affiliation corrector to real-world notations (0.9.11)
Extends the 0.9.10 corrector beyond semicolon-delimited ASCII lists to handle next-number-delimited lists, unicode superscript markers, multi-line banners, marker ranges (1-3), and role glyphs (* / dagger / envelope / +) which are ignored. Real affiliations are distinguished from author-name false positives by a multilingual institution-keyword filter, the marker search is anchored on each model-extracted author name, and the author/affiliation boundary is split so a trailing author marker is not mistaken for the first affiliation number. Validated on the 2025 corpus: 576 corrections across 4837 multi-author posters with zero anomalies.
1 parent 334a7e0 commit 6595b13

4 files changed

Lines changed: 188 additions & 53 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.11] - 2026-06-09
9+
10+
### Fixed
11+
12+
- **Superscript affiliation correction generalized to real-world notations.** 0.9.10's corrector only handled semicolon-delimited numbered lists with plain-digit markers. It now also handles numbered lists delimited by the next number (no semicolon), unicode superscript digit markers, multi-line banners (authors and affiliations on separate lines), marker ranges like "1-3", and role glyphs (asterisk, dagger, envelope, plus) which are ignored. Real affiliations are told apart from author-name false positives by a multilingual institution-keyword filter, the marker search is anchored on each model-extracted author name, and the author/affiliation boundary is split so a trailing author's marker is not mistaken for the first affiliation number. Validated on the 2025 corpus: fired on 576 of 4837 multi-author posters with zero anomalies (no author left empty or over-assigned), and verified semantically correct on real banners with up to 17 authors and complex multi-marker assignments.
13+
814
## [0.9.10] - 2026-06-09
915

1016
### Fixed

poster2json/extract.py

Lines changed: 117 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1976,60 +1976,127 @@ 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,})$")
1979+
# --- Author/affiliation superscript correction -------------------------------
1980+
# The fine-tuned model frequently over-assigns affiliations (most often the
1981+
# lead author absorbs every institution) even when the raw text is unambiguous.
1982+
# When a poster banner carries a numbered affiliation list, the superscript
1983+
# markers in the raw text are authoritative, so affiliations are reassigned
1984+
# deterministically. Strictly gated throughout: any ambiguity is a no-op that
1985+
# leaves the model's output untouched.
1986+
1987+
# unicode superscript digits -> ascii (the corrector sees the un-normalized text)
1988+
_SUP_TRANS = str.maketrans({
1989+
"⁰": "0", "¹": "1", "²": "2", "³": "3", "⁴": "4",
1990+
"⁵": "5", "⁶": "6", "⁷": "7", "⁸": "8", "⁹": "9",
1991+
})
19821992

1993+
# institution keyword (multilingual stems) — distinguishes a real numbered
1994+
# affiliation from a number that merely precedes the next author's name
1995+
_INSTITUTION_KW = re.compile(
1996+
r"(?i)(universi|institut|instituto|departa|depart|dipart|college|colleg|"
1997+
r"colegio|school|escuela|scuola|hospital|clinic|laborator|cent(?:er|re|ro)|"
1998+
r"zentrum|facult|academ|foundation|fundac|fondazione|stiftung|ministr|"
1999+
r"program|division|divisi|research|investigac|forschung|museum|museo|"
2000+
r"society|sociedad|council|agency|observator|engineering|technolog|"
2001+
r"hochschule|polytechni|gmbh)"
2002+
)
19832003

1984-
def _parse_numbered_affiliations(banner: str):
1985-
"""Parse a poster banner's numbered affiliation list into ``{n: text}``.
2004+
# a numbered affiliation marker: 1-2 digits at a boundary, then a capitalized
2005+
# institution name (optionally space/dot separated, including accented capitals)
2006+
_AFFIL_MARK = re.compile(r"(?:(?<=[\s;,(])|^)(\d{1,2})[.\s]{0,2}(?=[A-ZÀ-ɏ(])")
19862007

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:
2008+
2009+
def _parse_marker_run(run: str):
2010+
"""Parse an author marker run ("1,3" / "1-3,6" / "4 2") into a list of ints,
2011+
expanding ranges. Returns ``None`` on anything unexpected so the caller can
2012+
bail (keeping the corrector a strict no-op under ambiguity)."""
2013+
nums = []
2014+
for part in re.split(r"[,\s]+", run.strip()):
2015+
if not part:
2016+
continue
2017+
rng = re.match(r"^(\d{1,2})[–—-](\d{1,2})$", part)
2018+
if rng:
2019+
a, b = int(rng.group(1)), int(rng.group(2))
2020+
if not (a <= b and b - a <= 15):
2021+
return None
2022+
nums.extend(range(a, b + 1))
2023+
elif part.isdigit() and len(part) <= 2:
2024+
nums.append(int(part))
2025+
else:
19992026
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:
2027+
return nums or None
2028+
2029+
2030+
def _banner_region(raw_text: str, first_family: str):
2031+
"""The author/affiliation banner: the first line naming the lead author plus
2032+
following lines up to the next detected header, joined into one string."""
2033+
lines = raw_text.splitlines()
2034+
start = None
2035+
for i, ln in enumerate(lines):
2036+
if re.search(re.escape(first_family), ln, re.IGNORECASE):
2037+
start = i
2038+
break
2039+
if start is None:
2040+
return None
2041+
out = []
2042+
for off, ln in enumerate(lines[start:start + 12]):
2043+
s = ln.strip().lstrip("#").strip()
2044+
if off > 0 and (ln.lstrip().startswith("## ") or (s and _is_section_label_only(s))):
2045+
break
2046+
if s:
2047+
out.append(s)
2048+
return " ".join(out) if out else None
2049+
2050+
2051+
def _parse_affiliation_block(region: str):
2052+
"""Parse the numbered affiliation list from a (superscript-normalized) banner
2053+
region into ``({n: text}, block_start_index)``. Handles ``;``-, comma- and
2054+
next-number-delimited lists. Returns ``None`` unless a clean sequential
2055+
``1..N`` (N>=2) run of institution-bearing entries is found."""
2056+
cands = [(int(m.group(1)), m.start(1), m.end()) for m in _AFFIL_MARK.finditer(region)]
2057+
real = []
2058+
for i, (num, ms, me) in enumerate(cands):
2059+
seg_end = cands[i + 1][1] if i + 1 < len(cands) else len(region)
2060+
if _INSTITUTION_KW.search(region[me:seg_end][:280]):
2061+
real.append((num, ms, me))
2062+
if len(real) < 2:
20052063
return None
2006-
hi = max(amap)
2007-
if set(amap) != set(range(1, hi + 1)):
2064+
seq = []
2065+
expect = 1
2066+
for num, ms, me in real:
2067+
if num == expect:
2068+
seq.append((num, ms, me))
2069+
expect += 1
2070+
if len(seq) < 2 or seq[-1][0] != len(seq):
20082071
return None
2009-
return amap
2072+
amap = {}
2073+
for j, (num, ms, me) in enumerate(seq):
2074+
end = seq[j + 1][1] if j + 1 < len(seq) else len(region)
2075+
txt = region[me:end].strip().strip(",;").strip()
2076+
if not txt:
2077+
return None
2078+
amap[num] = txt
2079+
return amap, seq[0][1]
20102080

20112081

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)
2082+
def _author_marker_nums(author_region: str, family: str):
2083+
"""Marker numbers adjacent to ``family`` in the author byline (anchored on
2084+
the LLM-extracted author name). Role glyphs (``*``/``+``/contact symbols)
2085+
terminate the digit run and are ignored. ``None`` if no marker is found."""
2086+
m = re.search(re.escape(family) + r"[.\s]{0,4}(\d[\d,–—\-\s]*)",
2087+
author_region, re.IGNORECASE)
20162088
if not m:
20172089
return None
2018-
return [int(x) for x in re.split(r"\s*,\s*", m.group(1))]
2090+
return _parse_marker_run(m.group(1))
20192091

20202092

20212093
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-
"""
2094+
"""Reassign each author's affiliations from the poster banner's numbered list
2095+
using superscript markers in the (authoritative) raw text, anchoring the
2096+
marker search on each LLM-extracted author name. Strictly gated: a no-op
2097+
unless a clean sequential numbered list is found, every author has a marker,
2098+
and every marker resolves to a parsed affiliation — so posters without
2099+
numbered affiliations are left untouched."""
20332100
if not raw_text:
20342101
return
20352102
creators = result.get("creators")
@@ -2043,25 +2110,23 @@ def _correct_affiliations_from_superscripts(result: dict, raw_text: str) -> None
20432110
if not fam:
20442111
nm = c.get("name", "")
20452112
fam = nm.split(",")[0].strip() if "," in nm else ""
2046-
if not fam:
2113+
if not fam or len(fam) < 2:
20472114
return
20482115
families.append(fam)
20492116

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:
2117+
region = _banner_region(raw_text, families[0])
2118+
if not region:
20562119
return
2057-
2058-
amap = _parse_numbered_affiliations(banner)
2059-
if not amap:
2120+
region = region.translate(_SUP_TRANS)
2121+
parsed = _parse_affiliation_block(region)
2122+
if not parsed:
20602123
return
2124+
amap, block_start = parsed
2125+
author_region = region[:block_start]
20612126

20622127
plan = []
20632128
for fam in families:
2064-
marks = _author_markers(banner, fam)
2129+
marks = _author_marker_nums(author_region, fam)
20652130
if not marks or any(n not in amap for n in marks):
20662131
return
20672132
plan.append(marks)

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.10"
4+
version = "0.9.11"
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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,67 @@ def test_superscript_corrector_noop_without_numbered_list():
337337
_correct_affiliations_from_superscripts(result, raw)
338338
assert [c["affiliation"] for c in result["creators"]] == before
339339
assert "_validation" not in result
340+
341+
342+
def test_superscript_corrector_next_number_delimited_multiline():
343+
from poster2json.extract import _correct_affiliations_from_superscripts
344+
345+
# Real-corpus pattern: affiliations on their own line, delimited by the next
346+
# number (no ';'), authors on the line above.
347+
raw = (
348+
"Evolution Poster\n"
349+
"Noelle M. Mason 1 , Chris X. McDaniels 1 , John P. Korbin 1,2 & Lisa N. Barrow 1\n"
350+
"1 Museum of Southwestern Biology, University of New Mexico, 2 Sandia National Laboratories\n"
351+
"## Background\n"
352+
)
353+
result = {"creators": [
354+
{"name": "Mason, Noelle M.", "familyName": "Mason", "affiliation": ["X", "Y"]},
355+
{"name": "McDaniels, Chris X.", "familyName": "McDaniels", "affiliation": ["X", "Y"]},
356+
{"name": "Korbin, John P.", "familyName": "Korbin", "affiliation": ["X", "Y"]},
357+
{"name": "Barrow, Lisa N.", "familyName": "Barrow", "affiliation": ["X", "Y"]},
358+
]}
359+
_correct_affiliations_from_superscripts(result, raw)
360+
affs = [c["affiliation"] for c in result["creators"]]
361+
assert len(affs[0]) == 1 and "Museum" in affs[0][0] # Mason 1
362+
assert len(affs[2]) == 2 and any("Sandia" in a for a in affs[2]) # Korbin 1,2
363+
assert len(affs[3]) == 1 and "Museum" in affs[3][0] # Barrow 1 (not the affil "1")
364+
365+
366+
def test_superscript_corrector_unicode_superscripts():
367+
from poster2json.extract import _correct_affiliations_from_superscripts
368+
369+
raw = (
370+
"Marine Poster\n"
371+
"Eva Troianou ¹, Evi Abatzidou ¹, Ioannis Tzovenis ¹,²\n"
372+
"¹ Institute of Marine Biology, Lixouri, Greece "
373+
"² Microphykos Research Centre, Athens, Greece\n"
374+
"## Abstract\n"
375+
)
376+
result = {"creators": [
377+
{"name": "Troianou, Eva", "familyName": "Troianou", "affiliation": ["x"]},
378+
{"name": "Abatzidou, Evi", "familyName": "Abatzidou", "affiliation": ["x"]},
379+
{"name": "Tzovenis, Ioannis", "familyName": "Tzovenis", "affiliation": ["x"]},
380+
]}
381+
_correct_affiliations_from_superscripts(result, raw)
382+
affs = [c["affiliation"] for c in result["creators"]]
383+
assert len(affs[0]) == 1 and "Marine" in affs[0][0] # Troianou superscript-1
384+
assert len(affs[2]) == 2 # Tzovenis 1,2
385+
386+
387+
def test_superscript_corrector_expands_ranges():
388+
from poster2json.extract import _correct_affiliations_from_superscripts
389+
390+
raw = (
391+
"Title\n"
392+
"Alice Smith 1-3, Bob Jones 2\n"
393+
"1 Alpha University, 2 Beta Institute, 3 Gamma College\n"
394+
"## Intro\n"
395+
)
396+
result = {"creators": [
397+
{"name": "Smith, Alice", "familyName": "Smith", "affiliation": ["x"]},
398+
{"name": "Jones, Bob", "familyName": "Jones", "affiliation": ["x"]},
399+
]}
400+
_correct_affiliations_from_superscripts(result, raw)
401+
affs = [c["affiliation"] for c in result["creators"]]
402+
assert len(affs[0]) == 3 # Smith 1-3 -> all three
403+
assert len(affs[1]) == 1 and "Beta" in affs[1][0] # Jones 2 -> Beta

0 commit comments

Comments
 (0)