| """Re-extract the multi-panel IDA tables whose second axis was flattened away. |
| |
| The original ``build_records_ida`` keyed ``measure[stat]`` by stat name, so for |
| multi-panel tables (region t11, gender×leadership t17, management-span t14, |
| public position t27) each panel overwrote the previous — only the last panel's |
| numbers survived and the panel identity was lost. This script adds the lattice |
| analogue of the Djøf ``split_panels`` logic: it finds the repeated stat band, |
| splits columns into panels, reads each panel's segment title from the header |
| rows above, and emits one ``IdaSalaryRecord`` per (row × panel) with the typed |
| dimension recovered. |
| |
| Deterministic pdfplumber positional parsing (no OCR) — the IDA PDF is |
| digital-born; this keeps the 0-digit-error guarantee. Output: |
| ``data/processed/lonstatistik/ida_reextracted.jsonl`` (picked up by |
| ``build_ida_records.py``). |
| |
| Usage:: uv run python scripts/reextract_multipanel.py |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import re |
| import sys |
| from pathlib import Path |
|
|
| import pdfplumber |
|
|
| REPO = Path(__file__).resolve().parents[1] |
| sys.path.insert(0, str(REPO)) |
| sys.path.insert(0, str(REPO / "scripts")) |
|
|
| from extract_engine import ( |
| cluster_rows, get_rulings, is_number, norm, parse_number, xcenter, |
| ) |
|
|
| from src.backend.indexing.ida_transform import ( |
| POSITION_RANK, SPAN_BOUNDS, reliability_tier, |
| ) |
| from src.backend.schemas import IdaSalaryRecord, Measure |
|
|
| IDA_PDF = REPO / "data" / "raw" / "ida-loenstatistik-2025.pdf" |
| OUT = REPO / "data" / "processed" / "lonstatistik" / "ida_reextracted.jsonl" |
| SOURCE_DOC = "IDA Lønstatistik 2025" |
|
|
|
|
| def _stat_of(text: str) -> str | None: |
| """Map a stat-header token to a measure key (these tables carry count+mean).""" |
| t = norm(text).replace(" ", "") |
| if "antal" in t: |
| return "count" |
| if "bruttolon" in t or "gennemsnit" in t or "brutolon" in t: |
| return "mean" |
| return None |
|
|
|
|
| def extract_panels(pg) -> dict | None: |
| """Geometry pass (ruling-based): stat band → panels → titles → per-(row,panel) values. |
| |
| Uses the vertical rulings to define exact column boundaries (robust to |
| missing cells / dashes, which otherwise shift nearest-center assignment). |
| Only columns whose header carries a stat token (Antal/Bruttoløn/Gennemsnit) |
| are kept, which also drops spurious narrow rulings. A new panel begins at |
| each 'count'. Returns ``{panel_titles, rows:[{label, panels:[{stat:val}]}]}``. |
| """ |
| bounds = get_rulings(pg) |
| if len(bounds) < 4: |
| return None |
| ncol = len(bounds) - 1 |
|
|
| def colof(cx: float) -> int | None: |
| for i in range(ncol): |
| if bounds[i] - 1 <= cx <= bounds[i + 1] + 1: |
| return i |
| return None |
|
|
| words = pg.extract_words(keep_blank_chars=False) |
| rows = cluster_rows(words) |
|
|
| |
| best_i, best_n = None, 0 |
| for i, r in enumerate(rows): |
| n = sum(1 for w in r if _stat_of(w["text"])) |
| if n > best_n: |
| best_i, best_n = i, n |
| if best_i is None or best_n < 4: |
| return None |
|
|
| |
| col_stat: dict[int, str] = {} |
| for w in rows[best_i]: |
| s = _stat_of(w["text"]) |
| ci = colof(xcenter(w)) |
| if s and ci is not None and ci > 0: |
| col_stat[ci] = s |
| stat_cols = sorted(col_stat) |
| if len(stat_cols) < 2: |
| return None |
|
|
| |
| panels: list[list[int]] = [] |
| for ci in stat_cols: |
| if col_stat[ci] == "count" or not panels: |
| panels.append([ci]) |
| else: |
| panels[-1].append(ci) |
|
|
| |
| p_lo = [bounds[p[0]] for p in panels] |
| p_hi = [bounds[p[-1] + 1] for p in panels] |
| first_stat_x = bounds[stat_cols[0]] |
|
|
| |
| title_frags: list[list[str]] = [[] for _ in panels] |
| for ri in range(best_i): |
| for w in rows[ri]: |
| cx = xcenter(w) |
| if cx < first_stat_x - 4 or is_number(w["text"]): |
| continue |
| for pi in range(len(panels)): |
| if p_lo[pi] - 4 <= cx <= p_hi[pi] + 4: |
| title_frags[pi].append(w["text"]) |
| break |
| panel_titles = [re.sub(r"\s+", " ", " ".join(f)).strip() for f in title_frags] |
|
|
| |
| out_rows = [] |
| for ri in range(best_i + 1, len(rows)): |
| r = rows[ri] |
| nums = [w for w in r if is_number(w["text"])] |
| if len(nums) < 2: |
| continue |
| |
| |
| |
| label = re.sub(r"\s+", " ", " ".join( |
| w["text"] for w in sorted(r, key=lambda w: w["x0"]) |
| if colof(xcenter(w)) == 0)).strip() |
| if not label: |
| continue |
| cellvals: dict[int, float] = {} |
| for w in nums: |
| ci = colof(xcenter(w)) |
| if ci is None: |
| continue |
| v, _pct, missing = parse_number(w["text"]) |
| if not missing: |
| cellvals[ci] = v |
| panel_measures = [ |
| {col_stat[ci]: cellvals[ci] for ci in p if ci in cellvals} |
| for p in panels |
| ] |
| out_rows.append({"label": label, "panels": panel_measures}) |
|
|
| return {"panel_titles": panel_titles, "rows": out_rows} |
|
|
|
|
| |
| def parse_cohort(label: str) -> tuple[int | None, int | None] | None: |
| """Cohort label → (start, end) with the 'Før YYYY' half-open convention.""" |
| low = norm(label) |
| if low in ("alle", "ialt", "ialt"): |
| return (None, None) |
| m = re.search(r"(?:for)\s*(\d{4})", low) |
| if m: |
| return (None, int(m.group(1)) - 1) |
| m = re.search(r"(\d{4})(?:[-–](\d{2,4}))?", low) |
| if not m: |
| return None |
| y0 = int(m.group(1)) |
| y1 = m.group(2) |
| if y1: |
| y1 = int(y1) if len(y1) == 4 else int(str(y0)[:2] + y1) |
| else: |
| y1 = y0 |
| return (y0, y1) |
|
|
|
|
| def parse_span(label: str) -> str | None: |
| """Management-span row label → management_span_band enum value.""" |
| t = label.strip().replace("–", "-").replace(" ", "") |
| if t == "0": |
| return "none" |
| if t in SPAN_BOUNDS: |
| return t |
| m = re.match(r"^(\d+)\+$", t) |
| if m and f"{m.group(1)}+" in SPAN_BOUNDS: |
| return f"{m.group(1)}+" |
| return None |
|
|
|
|
| |
| _REGION = { |
| "hovedstaden": "hovedstaden", "sjælland": "sjaelland", "sjaelland": "sjaelland", |
| "syddanmark": "syddanmark", "midtjylland": "midtjylland", "nordjylland": "nordjylland", |
| } |
| _PUBLIC_POSITION = [ |
| ("specialkonsulent", "specialist"), |
| ("chefkonsulent", "chef_seniorkonsulent"), |
| ("menig", "ingenior_akademiker"), |
| ("almindelig", "ingenior_akademiker"), |
| ("chef", "afdelingschef"), |
| ] |
| _PRIVATE_POSITION = [ |
| ("topchef", "topchef"), ("funktionsdirekt", "topchef"), |
| ("afdelingschef", "afdelingschef"), ("projektleder", "projektleder"), |
| ] |
|
|
|
|
| def _is_alle(title: str) -> bool: |
| return norm(title).replace("*", "").strip() in ("alle", "alleledere", "alle*") |
|
|
|
|
| def _base(sector: str, page: int, rec_type: str = "salary_observation") -> dict: |
| return dict( |
| id="", source_doc=SOURCE_DOC, source_page=page, sector=sector, |
| record_type=rec_type, currency="DKK", union="IDA", |
| ) |
|
|
|
|
| def build_region(rt: dict, page: int) -> list[IdaSalaryRecord]: |
| """Tabel 11: private engineers by region × cohort (sector FIX: private).""" |
| out = [] |
| titles = rt["panel_titles"] |
| for row in rt["rows"]: |
| coh = parse_cohort(row["label"]) |
| if coh is None: |
| continue |
| for pi, m in enumerate(row["panels"]): |
| if "mean" not in m: |
| continue |
| region = _REGION.get(norm(titles[pi]).strip()) |
| if region is None: |
| continue |
| n = m.get("count") |
| out.append(IdaSalaryRecord( |
| **_base("private", page), table_id=11, table_title="REGION", |
| pay_concept="gross_monthly", data_period_month="2025-09", |
| region=region, row_dimension="region × kandidatår", |
| row_label=row["label"], segment_label=titles[pi], |
| graduation_year_start=coh[0], graduation_year_end=coh[1], |
| experience_years_min=2025 - coh[1] if coh[1] else None, |
| experience_years_max=2025 - coh[0] if coh[0] else None, |
| dimension_keys=["region"] + (["graduation_year"] if coh != (None, None) else []), |
| specificity=1 + (coh != (None, None)), |
| sample_size=n, reliability_tier=reliability_tier(n), |
| measure=Measure(count=n, mean=m.get("mean")), rag_text="", |
| )) |
| return out |
|
|
|
|
| def build_gender(rt: dict, page: int) -> list[IdaSalaryRecord]: |
| """Tabel 17: gender × leadership × cohort (the equal-pay comparison).""" |
| out = [] |
| titles = rt["panel_titles"] |
| |
| group_by_panel = {0: ("leader", "yes"), 1: ("leader", "yes"), |
| 2: ("leader", "no"), 3: ("leader", "no"), |
| 4: ("non_leader", "no"), 5: ("non_leader", "no")} |
| for row in rt["rows"]: |
| coh = parse_cohort(row["label"]) |
| if coh is None: |
| continue |
| for pi, m in enumerate(row["panels"]): |
| if "mean" not in m or pi not in group_by_panel: |
| continue |
| tnorm = norm(titles[pi]) |
| gender = "female" if "kvinde" in tnorm else ("male" if "mand" in tnorm else None) |
| if gender is None: |
| continue |
| is_leader, manages = group_by_panel[pi] |
| n = m.get("count") |
| dims = ["gender", "is_leader", "manages_people"] |
| if coh != (None, None): |
| dims.append("graduation_year") |
| out.append(IdaSalaryRecord( |
| **_base("private", page), table_id=17, |
| table_title="MÆND OG KVINDER, ledelse med/uden personaleansvar", |
| pay_concept="gross_monthly", data_period_month="2025-09", |
| gender=gender, is_leader=is_leader, manages_people=manages, |
| row_dimension="køn × ledelse × kandidatår", row_label=row["label"], |
| segment_label=titles[pi], |
| graduation_year_start=coh[0], graduation_year_end=coh[1], |
| experience_years_min=2025 - coh[1] if coh[1] else None, |
| experience_years_max=2025 - coh[0] if coh[0] else None, |
| dimension_keys=dims, specificity=len(dims), |
| sample_size=n, reliability_tier=reliability_tier(n), |
| measure=Measure(count=n, mean=m.get("mean")), rag_text="", |
| )) |
| return out |
|
|
|
|
| def build_public_position(rt: dict, page: int) -> list[IdaSalaryRecord]: |
| """Tabel 27: public-state position level × cohort.""" |
| out = [] |
| titles = rt["panel_titles"] |
| for row in rt["rows"]: |
| coh = parse_cohort(row["label"]) |
| if coh is None: |
| continue |
| for pi, m in enumerate(row["panels"]): |
| if "mean" not in m or _is_alle(titles[pi]): |
| continue |
| tnorm = norm(titles[pi]) |
| pos = next((p for kw, p in _PUBLIC_POSITION if kw in tnorm), None) |
| if pos is None: |
| continue |
| manages = "yes" if "personaleansvar" in tnorm else "all" |
| n = m.get("count") |
| dims = ["position_level"] + (["graduation_year"] if coh != (None, None) else []) |
| out.append(IdaSalaryRecord( |
| **_base("public_state", page), table_id=27, |
| table_title="STATEN: STILLINGSNIVEAU", |
| pay_concept="gross_monthly", data_period_month="2025-05", |
| position_level=pos, position_level_rank=POSITION_RANK.get(pos), |
| manages_people=manages, |
| row_dimension="stillingsniveau × kandidatår", row_label=row["label"], |
| segment_label=titles[pi], |
| graduation_year_start=coh[0], graduation_year_end=coh[1], |
| experience_years_min=2025 - coh[1] if coh[1] else None, |
| experience_years_max=2025 - coh[0] if coh[0] else None, |
| dimension_keys=dims, specificity=len(dims), |
| sample_size=n, reliability_tier=reliability_tier(n), |
| measure=Measure(count=n, mean=m.get("mean")), rag_text="", |
| )) |
| return out |
|
|
|
|
| def build_management(rt: dict, page: int) -> list[IdaSalaryRecord]: |
| """Tabel 14: position × management-span band (recovers management_span_band).""" |
| out = [] |
| titles = rt["panel_titles"] |
| for row in rt["rows"]: |
| band = parse_span(row["label"]) |
| if band is None: |
| continue |
| lo, hi = SPAN_BOUNDS[band] |
| for pi, m in enumerate(row["panels"]): |
| if "mean" not in m or _is_alle(titles[pi]): |
| continue |
| tnorm = norm(titles[pi]) |
| pos = next((p for kw, p in _PRIVATE_POSITION if kw in tnorm), None) |
| if pos is None: |
| continue |
| n = m.get("count") |
| dims = ["position_level", "management_span_band", "manages_people", "is_leader"] |
| out.append(IdaSalaryRecord( |
| **_base("private", page), table_id=14, table_title="PERSONALEANSVAR", |
| pay_concept="gross_monthly", data_period_month="2025-09", |
| position_level=pos, position_level_rank=POSITION_RANK.get(pos), |
| management_span_band=band, span_min=lo, span_max=hi, |
| manages_people="no" if band == "none" else "yes", |
| is_leader="leader", |
| row_dimension="stillingstype × personaleansvar", row_label=row["label"], |
| segment_label=titles[pi], |
| dimension_keys=dims, specificity=len(dims), |
| sample_size=n, reliability_tier=reliability_tier(n), |
| measure=Measure(count=n, mean=m.get("mean")), rag_text="", |
| )) |
| return out |
|
|
|
|
| |
| |
| |
| |
| |
| |
| _TABLES = [ |
| (18, build_region), (25, build_gender), (30, build_public_position), |
| ] |
|
|
|
|
| def main() -> None: |
| records: list[IdaSalaryRecord] = [] |
| with pdfplumber.open(IDA_PDF) as pdf: |
| for page, builder in _TABLES: |
| rt = extract_panels(pdf.pages[page - 1]) |
| if rt is None: |
| print(f" p{page}: no panel structure found — skipped") |
| continue |
| recs = builder(rt, page) |
| print(f" p{page} {builder.__name__}: {len(recs)} records " |
| f"({len(rt['panel_titles'])} panels: {rt['panel_titles']})") |
| records += recs |
|
|
| |
| for i, r in enumerate(records): |
| r.id = f"ida-reextract-{r.table_id}-{i:04d}" |
|
|
| OUT.write_text( |
| "\n".join(r.model_dump_json(exclude_none=True) for r in records) + "\n", |
| encoding="utf-8", |
| ) |
| print(f"OK — {len(records)} re-extracted records → {OUT}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|