| """Build a structured salary knowledge base from the IDA and Djøf 2025 |
| lønstatistik PDFs. |
| |
| Both PDFs are digital-born with reliable text layers, so we extract exact |
| digits via deterministic word-position parsing (extract_engine), not vision OCR. |
| |
| Output: one JSON record per (table-row x statistic-bearing context), normalized |
| to a unified RAG-friendly schema (see schema.json). We keep the percentile |
| fields the user asked for (p25 / median / p75) plus mean, p90, count, and rich |
| provenance + a generated natural-language `rag_text` per record. |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import re |
| import sys |
| from pathlib import Path |
|
|
| import pdfplumber |
|
|
| sys.path.insert(0, str(Path(__file__).parent)) |
| from extract_engine import ( |
| cluster_rows, dedupe_glyphs, extract_lattice_table, extract_page_table, |
| get_rulings, norm, |
| ) |
|
|
| RAW = Path("/home/simonl/Desktop/competiton/pay-equity-for-eu/data/raw") |
| OUT = Path("/home/simonl/Desktop/competiton/pay-equity-for-eu/data/processed/lonstatistik") |
|
|
| IDA_PDF = RAW / "ida-loenstatistik-2025.pdf" |
| DJOF_PDF = RAW / "Privatansatte lnstatistik 2025.pdf" |
|
|
| CURRENT_YEAR = 2025 |
|
|
| |
| |
| |
|
|
| SECTOR_HINTS = [ |
| (("staten", "ministerie"), "public_state"), |
| (("kommun",), "public_municipal"), |
| (("region",), "public_regional"), |
| (("offentlig",), "public"), |
| (("selvstænd", "selvstaend"), "self_employed"), |
| (("privat",), "private"), |
| ] |
|
|
|
|
| def guess_sector(text: str) -> str: |
| t = norm(text) |
| for keys, sector in SECTOR_HINTS: |
| if any(k in t for k in keys): |
| return sector |
| return "private" |
|
|
|
|
| PERIOD_RE = re.compile(r"(januar|februar|marts|april|maj|juni|juli|august|" |
| r"september|oktober|november|december)\s*20\d{2}", re.I) |
|
|
|
|
| def guess_period(text: str) -> str | None: |
| m = PERIOD_RE.search(text) |
| return m.group(0) if m else None |
|
|
|
|
| |
| DIM_RE = re.compile(r"(?:fordelt på|opdelt efter|opdelt i|ift\.?|i forhold til)\s+([^.]+)", re.I) |
|
|
|
|
| def guess_dimension(text: str) -> str | None: |
| m = DIM_RE.search(text) |
| if not m: |
| return None |
| d = re.sub(r"\s+", " ", m.group(1)).strip() |
| return d[:80] |
|
|
|
|
| TABEL_RE = re.compile(r"TABEL\s*(\d+)\s*:?\s*", re.I) |
|
|
|
|
| def dehyphenate(text: str) -> str: |
| """Join words split across line breaks: 'stil- lingstype' -> 'stillingstype'.""" |
| return re.sub(r"(\w)[\u00ad-]\s+(\w)", r"\1\2", text) |
|
|
|
|
| def ida_page_context(pg, rulings): |
| """IDA caption lives in the left margin (x < first ruling).""" |
| words = pg.extract_words(keep_blank_chars=False) |
| cap = [w for w in words if w["x1"] <= rulings[0] + 1] |
| cap.sort(key=lambda w: (round(w["top"]), w["x0"])) |
| text = re.sub(r"\s+", " ", " ".join(w["text"] for w in cap)).strip() |
| text = dehyphenate(text) |
| return parse_caption(text) |
|
|
|
|
| FORTSAT_RE = re.compile(r"\(?\s*fortsat\s*\)?", re.I) |
| HDR_TOKENS = ("antal", "gennemsnit", "median", "kvartil", "fraktil", "nedre", "ovre") |
|
|
|
|
| def _is_header_line(line: str) -> bool: |
| n = norm(line) |
| return sum(k in n for k in HDR_TOKENS) >= 2 |
|
|
|
|
| def djof_page_context(pg, prev_ctx=None): |
| """Djøf caption has a fixed top structure for the FIRST page of a table: |
| row0: TABEL N row1: title ('Månedsløn ift. branche') |
| row2: period row3: 'Opdelt efter DIMENSION' |
| row4: table-level group/subgroup (e.g. 'HD', 'Jurister', 'Alle kandidater') |
| |
| Continuation pages ('TABEL N (FORTSAT)') inherit title/period/dimension from |
| prev_ctx and only carry a subgroup line like 'Chefer (fortsat)'. |
| """ |
| rows = cluster_rows(pg.extract_words(keep_blank_chars=False)) |
| lines = [re.sub(r"\s+", " ", " ".join(dedupe_glyphs(w["text"]) for w in r)).strip() |
| for r in rows[:6]] |
| head = " ".join(lines[:1]) |
| is_fortsat = bool(FORTSAT_RE.search(head)) and "TABEL" in head.upper() |
|
|
| ctx = { |
| "table_ids": [], "table_title": None, "sector": "private", |
| "period": None, "dimension": None, "caption": " ".join(lines)[:400], |
| "table_group": None, "is_continuation": is_fortsat, |
| } |
| m = TABEL_RE.search(lines[0] if lines else "") |
| if m: |
| ctx["table_ids"] = [int(m.group(1))] |
|
|
| if is_fortsat and prev_ctx and prev_ctx.get("table_ids") == ctx["table_ids"]: |
| |
| ctx["table_title"] = prev_ctx.get("table_title") |
| ctx["period"] = prev_ctx.get("period") |
| ctx["dimension"] = prev_ctx.get("dimension") |
| |
| |
| |
| for ln in lines[1:]: |
| if not ln or "tabel" in norm(ln): |
| continue |
| if norm(ln) in ("stilling bruttolon", "bruttolon", "stilling"): |
| continue |
| |
| sub = re.split(r"(?i)\b(Antal|Gennemsnit|Nedre|Median|Øvre|90%)\b", ln)[0] |
| sub = FORTSAT_RE.sub("", sub).strip(" -()") |
| sub = re.sub(r"^Stilling\s+", "", sub, flags=re.I).strip() |
| if sub and not re.search(r"\d{3}", sub): |
| ctx["table_group"] = clean_label(sub) |
| break |
| return ctx |
|
|
| |
| if len(lines) > 1: |
| ctx["table_title"] = lines[1] |
| if len(lines) > 2: |
| ctx["period"] = guess_period(lines[2]) or (lines[2] if "20" in lines[2] else None) |
| if len(lines) > 3: |
| md = DIM_RE.search(lines[3]) |
| ctx["dimension"] = (re.sub(r"\s+", " ", md.group(1)).strip()[:60] if md |
| else (lines[3] if lines[3] else None)) |
| if len(lines) > 4: |
| cand = lines[4] |
| if cand and not _is_header_line(cand) and not any( |
| k in norm(cand) for k in ("bruttolon", "stilling")): |
| ctx["table_group"] = clean_label(cand) |
| return ctx |
|
|
|
|
| def parse_caption(text: str) -> dict: |
| table_ids = TABEL_RE.findall(text) |
| |
| title = None |
| m = TABEL_RE.search(text) |
| if m: |
| rest = text[m.end():] |
| rest = re.split(r"(Månedsløn|Bruttoløn fordelt|Brutoløn|Grundløn|September 20|Maj 20|Opdelt|\.)", rest)[0] |
| title = re.sub(r"\s+", " ", rest).strip(" .:-") |
| |
| |
| head = text |
| pm = PERIOD_RE.search(text) |
| if pm: |
| head = text[: pm.end() + 40] |
| return { |
| "table_ids": [int(t) for t in table_ids], |
| "table_title": title or None, |
| "sector": guess_sector(head), |
| "period": guess_period(text), |
| "dimension": guess_dimension(text), |
| "caption": text[:400], |
| } |
|
|
|
|
| |
| |
| |
|
|
| YEAR_RANGE_RE = re.compile(r"^(?:før\s*)?(\d{4})(?:\s*[-–]\s*(\d{2,4}))?$", re.I) |
| AGE_RE = re.compile(r"(\d{2})\s*[-–]\s*(\d{2})\s*år|(\d{2})\s*år", re.I) |
|
|
|
|
| def _clean(label: str) -> str: |
| """Light clean: soft-hyphen -> hyphen, collapse spaces (keeps Danish chars).""" |
| return re.sub(r"\s+", " ", label.replace("\u00ad", "-")).strip() |
|
|
|
|
| def parse_grad_year(label: str): |
| """Return (grad_year_start, grad_year_end) from a cohort label like |
| '1986-1987', 'Før 1986', '2024', '2012-14'.""" |
| l = norm(label).replace(" ", "") |
| before = l.startswith("for") |
| m = re.search(r"(\d{4})(?:[-–](\d{2,4}))?", l) |
| if not m: |
| return None, None, before |
| 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, before |
|
|
|
|
| def experience_from_grad(y0, y1, before): |
| """Years of experience from graduation cohort (proxy: years since graduation).""" |
| if y0 is None: |
| return None, None |
| if before: |
| |
| return CURRENT_YEAR - y0, None |
| exp_max = CURRENT_YEAR - y0 |
| exp_min = CURRENT_YEAR - y1 |
| return exp_min, exp_max |
|
|
|
|
| def parse_age(label: str): |
| m = AGE_RE.search(_clean(label)) |
| if not m: |
| return None, None |
| if m.group(1): |
| return int(m.group(1)), int(m.group(2)) |
| if m.group(3): |
| return int(m.group(3)), None |
| return None, None |
|
|
|
|
| |
| def label_kind(label: str, dimension: str | None): |
| c = _clean(label) |
| lc = c.lower() |
| |
| if re.search(r"\bår\b", lc) and re.search(r"\d", lc): |
| return "age" |
| if (re.search(r"\b(19|20)\d{2}\b", c) or lc.startswith("før ") |
| or YEAR_RANGE_RE.match(c.replace(" ", ""))): |
| return "graduation_year" |
| if lc in ("alle", "i alt", "ialt"): |
| return "total" |
| return "category" |
|
|
|
|
| |
| |
| |
|
|
| STAT_FIELDS = ["count", "mean", "p25", "median", "p75", "p90", |
| "base", "supplement", "pension", "bonus_avg", "bonus_pct"] |
|
|
|
|
| def clean_label(s: str) -> str: |
| s = s.replace("\xad", "-") |
| |
| s = re.sub(r"(\w)-\s+(\w)", r"\1\2", s) |
| s = re.sub(r"\s+", " ", s).strip() |
| |
| |
| s = re.sub(r"(\s*-\s*)+$", "", s).strip() |
| s = re.sub(r"^(\s*-\s*)+", "", s).strip() |
| return s |
|
|
|
|
| |
| _PANEL_CANON = [ |
| ("kandidat", "Kandidater (master's degree)"), |
| ("mellemudd", "Mid-level education (HA, HD, BA)"), |
| ("direktør", "Directors"), |
| ("ikke-chef", "Non-managers"), |
| ("chef", "Managers"), |
| ] |
|
|
|
|
| def clean_panel_title(title: str) -> str | None: |
| """Collapse a noisy panel title to a canonical segment label.""" |
| if not title: |
| return None |
| t = norm(title) |
| for key, canon in _PANEL_CANON: |
| if key in t: |
| return canon |
| return clean_label(title) |
|
|
|
|
| def build_records_ida(pg, pidx, ctx, table): |
| cols = table["columns"] |
| col_stat = {i: c["stat"] for i, c in enumerate(cols)} |
| col_hdr = {i: c.get("header", "") for i, c in enumerate(cols)} |
| |
| stat_cols = [i for i, s in col_stat.items() if s] |
| matrix = len(stat_cols) <= 1 and len(cols) > 3 |
| records = [] |
| dim = ctx.get("dimension") |
| for row in table["rows"]: |
| label = clean_label(row["label"]) |
| kind = label_kind(label, dim) |
| recs_for_row = [] |
| if matrix: |
| |
| for ci, val in row["cells"].items(): |
| seg = clean_label(col_hdr.get(ci, f"col{ci}")) |
| if not seg or norm(seg) in ("alle",): |
| seg_label = seg or None |
| else: |
| seg_label = seg |
| rec = base_record(ctx, pidx, "IDA") |
| fill_label_dims(rec, label, kind) |
| rec["measure"] = {"mean": val} |
| rec["segment_dimension"] = dim or "stillingstype" |
| rec["segment_label"] = seg_label |
| recs_for_row.append(rec) |
| else: |
| measure = {} |
| for ci, val in row["cells"].items(): |
| s = col_stat.get(ci) |
| if s: |
| measure[s] = val |
| if not measure: |
| continue |
| rec = base_record(ctx, pidx, "IDA") |
| fill_label_dims(rec, label, kind) |
| rec["measure"] = measure |
| rec["segment_dimension"] = dim |
| rec["segment_label"] = label if kind == "category" else None |
| recs_for_row.append(rec) |
| records.extend(recs_for_row) |
| return records |
|
|
|
|
| def build_records_djof(pg, pidx, ctx, table): |
| cols = table["columns"] |
| panels = table.get("panels", [{"col_range": [0, len(cols)], "title": ""}]) |
| records = [] |
| dim = ctx.get("dimension") |
| for row in table["rows"]: |
| label = clean_label(row["label"]) |
| kind = label_kind(label, dim) |
| for p in panels: |
| s, e = p["col_range"] |
| measure = {} |
| for ci, val in row["cells"].items(): |
| if s <= ci < e: |
| st = cols[ci]["stat"] |
| if st: |
| measure[st] = val |
| if not measure: |
| continue |
| rec = base_record(ctx, pidx, "Djøf") |
| fill_label_dims(rec, label, kind) |
| rec["measure"] = measure |
| rec["table_group"] = ctx.get("table_group") |
| seg = clean_panel_title(p.get("title", "")) |
| |
| if len(panels) > 1: |
| rec["segment_dimension"] = "education_or_position_group" |
| rec["segment_label"] = seg |
| else: |
| rec["segment_dimension"] = dim if kind == "category" else None |
| rec["segment_label"] = label if kind == "category" else None |
| records.append(rec) |
| return records |
|
|
|
|
| def base_record(ctx, pidx, union): |
| return { |
| "union": union, |
| "source_doc": "IDA Lønstatistik 2025" if union == "IDA" else "Djøf Privatansatte Lønstatistik 2025", |
| "source_page": pidx + 1, |
| "table_id": ctx.get("table_ids", [None])[0] if ctx.get("table_ids") else None, |
| "table_title": ctx.get("table_title"), |
| "sector": ctx.get("sector"), |
| "period": ctx.get("period"), |
| "currency": "DKK", |
| "pay_concept": None, |
| "row_dimension": ctx.get("dimension"), |
| "row_label": None, |
| "graduation_year_start": None, |
| "graduation_year_end": None, |
| "experience_years_min": None, |
| "experience_years_max": None, |
| "age_min": None, |
| "age_max": None, |
| "table_group": ctx.get("table_group"), |
| "segment_dimension": None, |
| "segment_label": None, |
| "measure": {}, |
| } |
|
|
|
|
| def fill_label_dims(rec, label, kind): |
| rec["row_label"] = label |
| if kind == "graduation_year": |
| y0, y1, before = parse_grad_year(label) |
| rec["graduation_year_start"] = y0 |
| rec["graduation_year_end"] = None if before else y1 |
| emin, emax = experience_from_grad(y0, y1, before) |
| rec["experience_years_min"] = emin |
| rec["experience_years_max"] = emax |
| elif kind == "age": |
| a0, a1 = parse_age(label) |
| rec["age_min"] = a0 |
| rec["age_max"] = a1 |
|
|
|
|
| NON_SALARY_TITLE_KW = ("lønudvikling", "udvikling", "fremskriv", "forventning", |
| "resultat", "omsætning", "omsaetning", "ejerandel", |
| "feriedage", "arbejdstid", "stigning") |
|
|
|
|
| def is_salary_record(rec): |
| """Heuristic gate: keep only records that represent actual salary levels. |
| |
| Drops percentage/growth tables, projections, and business-metric tables |
| (self-employed turnover/result), and rows whose monetary values are |
| implausible as salaries. |
| """ |
| title = norm(rec.get("table_title") or "") |
| if any(k in title for k in NON_SALARY_TITLE_KW): |
| |
| if "indkomst" not in title and "årsind" not in title and "arsind" not in title: |
| return False |
| m = rec["measure"] |
| money = [v for k, v in m.items() |
| if k in ("mean", "median", "p25", "p75", "p90", "base")] |
| if not money: |
| return False |
| |
| |
| rl = norm(rec.get("row_label") or "") |
| if rl not in ("alle", "i alt", "ialt") and m.get("count", 0) > 20000: |
| return False |
| pc = rec.get("pay_concept") |
| if pc == "annual_income": |
| return all(1000 <= v <= 100_000_000 for v in money) |
| |
| return all(5000 <= v <= 500_000 for v in money) |
|
|
|
|
| def infer_pay_concept(rec): |
| cap = norm((rec.get("table_title") or "") + " " + (rec.get("row_dimension") or "")) |
| m = rec["measure"] |
| if "base" in m or "supplement" in m: |
| return "gross_monthly_with_components" |
| if "selvstænd" in norm(rec.get("sector") or "") or rec.get("sector") == "self_employed": |
| return "annual_income" |
| if "nettolon" in cap: |
| return "net_monthly" |
| return "gross_monthly" |
|
|
|
|
| PERCENTILE_NAMES = {"p25": "25th percentile", "median": "median", |
| "p75": "75th percentile", "p90": "90th percentile", |
| "mean": "average", "base": "base salary", |
| "supplement": "supplement", "pension": "pension contribution", |
| "bonus_avg": "average annual bonus", "bonus_pct": "share receiving bonus"} |
|
|
|
|
| def make_rag_text(rec): |
| m = rec["measure"] |
| union = rec["union"] |
| parts = [f"{union} 2025 salary statistics"] |
| if rec.get("table_title"): |
| parts.append(f"({rec['table_title'].title()})") |
| seg = [] |
| if rec.get("segment_label"): |
| seg.append(rec["segment_label"]) |
| if rec.get("row_label") and rec["row_label"] != rec.get("segment_label"): |
| seg.append(rec["row_label"]) |
| sector = {"private": "private sector", "public_state": "state sector", |
| "public_municipal": "municipal sector", "public_regional": "regional sector", |
| "public": "public sector", "self_employed": "self-employed"}.get(rec.get("sector"), rec.get("sector")) |
| head = f"{parts[0]} {parts[1] if len(parts)>1 else ''}".strip() |
| body = f"For {', '.join(seg) if seg else 'all members'} in the {sector}" |
| if rec.get("experience_years_min") is not None: |
| if rec.get("experience_years_max") is not None: |
| body += f" (~{rec['experience_years_min']}-{rec['experience_years_max']} yrs experience)" |
| else: |
| body += f" (~{rec['experience_years_min']}+ yrs experience)" |
| elif rec.get("age_min") is not None: |
| body += f" (age {rec['age_min']}{'-'+str(rec['age_max']) if rec.get('age_max') and rec['age_max']!=rec['age_min'] else '+'})" |
| pieces = [] |
| cur = rec["currency"] |
| pay = rec.get("pay_concept", "gross_monthly") |
| unit = "DKK/month" if "monthly" in (pay or "") else ("DKK/year" if pay == "annual_income" else "DKK") |
| for k in ["mean", "p25", "median", "p75", "p90"]: |
| if k in m: |
| pieces.append(f"{PERCENTILE_NAMES[k]} {m[k]:,} {unit}") |
| extra = [] |
| if "base" in m: |
| extra.append(f"base {m['base']:,}") |
| if "supplement" in m: |
| extra.append(f"supplement {m['supplement']:,}") |
| if "pension" in m: |
| extra.append(f"pension {m['pension']:,}") |
| if "bonus_pct" in m: |
| extra.append(f"{m['bonus_pct']}% receive bonus") |
| if "bonus_avg" in m: |
| extra.append(f"avg bonus {m['bonus_avg']:,}/yr") |
| n = f" Based on {m['count']} respondents." if "count" in m else "" |
| sal = "; ".join(pieces) |
| ex = (" " + ", ".join(extra) + ".") if extra else "" |
| return f"{head}. {body}: {sal}.{ex}{n} Source: {rec['source_doc']}, p.{rec['source_page']}.".replace(" ", " ") |
|
|
|
|
| def process_pdf(path, union): |
| records = [] |
| prev_ctx = None |
| with pdfplumber.open(path) as pdf: |
| for pidx, pg in enumerate(pdf.pages): |
| rulings = get_rulings(pg) |
| if union == "IDA" and rulings: |
| ctx = ida_page_context(pg, rulings) |
| if ctx.get("table_ids"): |
| prev_ctx = ctx |
| table = extract_lattice_table(pg, rulings) |
| if table and table["rows"]: |
| records += build_records_ida(pg, pidx, ctx, table) |
| else: |
| if union == "Djøf": |
| ctx = djof_page_context(pg, prev_ctx) |
| |
| if not ctx.get("is_continuation") and ctx.get("table_ids"): |
| prev_ctx = ctx |
| else: |
| ctx = ida_page_context(pg, rulings or [40]) |
| table = extract_page_table(pg) |
| if table and table["rows"]: |
| records += build_records_djof(pg, pidx, ctx, table) |
| |
| kept = [] |
| dropped = 0 |
| for rec in records: |
| rec["pay_concept"] = infer_pay_concept(rec) |
| if not is_salary_record(rec): |
| dropped += 1 |
| continue |
| kept.append(rec) |
| for i, rec in enumerate(kept): |
| rec["rag_text"] = make_rag_text(rec) |
| rec["id"] = f"{union.lower().replace('ø','o')}-p{rec['source_page']:03d}-{i:04d}" |
| print(f" {union}: dropped {dropped} non-salary/implausible records") |
| return kept |
|
|
|
|
| def main(): |
| OUT.mkdir(parents=True, exist_ok=True) |
| all_recs = [] |
| for path, union in [(IDA_PDF, "IDA"), (DJOF_PDF, "Djøf")]: |
| recs = process_pdf(path, union) |
| print(f"{union}: {len(recs)} records") |
| all_recs += recs |
| (OUT / "salary_records.json").write_text( |
| json.dumps(all_recs, ensure_ascii=False, indent=2)) |
| with (OUT / "salary_records.jsonl").open("w") as f: |
| for r in all_recs: |
| f.write(json.dumps(r, ensure_ascii=False) + "\n") |
| print(f"TOTAL: {len(all_recs)} records -> {OUT}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|