Franskaman110 commited on
Commit
b36b826
·
1 Parent(s): 70b1f2f

NEw metadata

Browse files
data/processed/lonstatistik/curated_growth_forecast.jsonl ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ {"id":"IDA-curated-forecast-private","union":"IDA","source_doc":"IDA Lønstatistik 2025","source_page":2,"table_id":2,"table_title":"LØNUDVIKLING/PROGNOSE","record_type":"salary_forecast","sector":"private","data_period_month":"2025-09","currency":"DKK","pay_concept":"gross_monthly","education_type":"all","region":"all","position_level":"all","gender":"all","is_leader":"all","manages_people":"all","management_span_band":"all","stem_count_band":"all","specialization":"all","work_area":"all","branche":"all","jobloen":"all","cohort_growth_pct":3.35,"dimension_keys":[],"specificity":0,"reliability_tier":"full","measure":{"mean":3.35},"rag_text":"IDA Lønstatistik 2025 — IDA members, private sector. 2026 salary forecast 3.35% expected pay increase (private sector). Full reliability (n≥50). Source: IDA Lønstatistik 2025, tabel 2, p.2."}
2
+ {"id":"IDA-curated-growth-private","union":"IDA","source_doc":"IDA Lønstatistik 2025","source_page":1,"table_id":1,"table_title":"LØNUDVIKLING","record_type":"salary_growth_rate","sector":"private","data_period_month":"2025-09","currency":"DKK","pay_concept":"gross_monthly","education_type":"all","region":"all","position_level":"all","gender":"all","is_leader":"all","manages_people":"all","management_span_band":"all","stem_count_band":"all","specialization":"all","work_area":"all","branche":"all","jobloen":"all","cohort_growth_pct":3.35,"dimension_keys":[],"specificity":0,"reliability_tier":"full","measure":{"mean":3.35},"rag_text":"IDA Lønstatistik 2025 — IDA members, private sector. 3-year gliding annual salary growth 3.35% (private sector). Full reliability (n≥50). Source: IDA Lønstatistik 2025, tabel 1, p.1."}
data/processed/lonstatistik/ida_records.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
data/processed/lonstatistik/ida_salary_record.schema.json ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://pay-equity-eu/ida-salary-record.schema.json",
4
+ "title": "IDA Lønstatistik 2025 salary record (extended for RAG filtering)",
5
+ "description": "One normalized salary-statistics observation extracted from the IDA 2025 Danish union salary booklet, extended with structured, hard-filterable dimensions so a user profile can be matched to the correct cell(s) and fused across tables. SCOPE DECISION (v1, schedule-driven): this schema covers ONLY the IDA salary corpus. The EU Pay Transparency Directive corpus is intentionally NOT structured for v1 and is retrieved via plain embedding/semantic search; revisit once the salary path is shipped. WILDCARD CONTRACT: every filterable dimension below is a required string enum that includes the sentinel \"all\". \"all\" means the table aggregates over that dimension and is the ONLY wildcard. A chunk matches a profile iff, for every dimension, chunk.value == profile.value OR chunk.value == \"all\". Nulls must never appear on a dimension field; backfill \"all\" explicitly at ingest (validation will fail otherwise).",
6
+ "type": "object",
7
+ "required": [
8
+ "id",
9
+ "union",
10
+ "source_doc",
11
+ "source_page",
12
+ "sector",
13
+ "currency",
14
+ "pay_concept",
15
+ "record_type",
16
+ "education_type",
17
+ "region",
18
+ "position_level",
19
+ "gender",
20
+ "is_leader",
21
+ "manages_people",
22
+ "management_span_band",
23
+ "stem_count_band",
24
+ "specialization",
25
+ "work_area",
26
+ "branche",
27
+ "jobloen",
28
+ "reliability_tier",
29
+ "specificity",
30
+ "dimension_keys",
31
+ "measure",
32
+ "rag_text"
33
+ ],
34
+ "additionalProperties": false,
35
+ "properties": {
36
+ "id": {"type": "string", "description": "Stable record id: <union>-p<page>-<seq>."},
37
+ "union": {"type": "string", "enum": ["IDA"], "description": "Source union. Locked to IDA for v1 (engineers/scientists). Re-add Djoef when that corpus is onboarded."},
38
+ "source_doc": {"type": "string", "description": "Human-readable source document title."},
39
+ "source_page": {"type": "integer", "minimum": 1, "description": "1-based page number in the source PDF."},
40
+ "table_id": {"type": ["integer", "null"], "description": "TABEL number printed in the booklet (e.g. 4 for 'TABEL 4'). Null for non-table figures/text."},
41
+ "table_title": {"type": ["string", "null"], "description": "Table caption / title."},
42
+ "table_group": {"type": ["string", "null"], "description": "Raw table-level subgroup label as printed (provenance only; the typed dimension fields below are what you filter on)."},
43
+ "record_type": {
44
+ "type": "string",
45
+ "enum": [
46
+ "salary_observation",
47
+ "salary_growth_rate",
48
+ "salary_forecast",
49
+ "minimum_wage_recommendation",
50
+ "benefit_prevalence",
51
+ "terms_stat",
52
+ "self_employed_income"
53
+ ],
54
+ "description": "Primary router. Filter on this BEFORE any other field. salary_observation = a pay cell (tabel 3-17, 27-41). salary_growth_rate = tabel 1. salary_forecast = tabel 2. minimum_wage_recommendation = p6 dimittend floors. benefit_prevalence = tabel 18. terms_stat = tabel 19-25 (vacation, clauses, jobchange, etc.). self_employed_income = tabel 42-51. NOTE: directive_provision is intentionally absent (directive uses plain embedding in v1)."
55
+ },
56
+ "sector": {
57
+ "type": "string",
58
+ "enum": ["private", "public_state", "public_municipal", "public_regional", "self_employed", "all"],
59
+ "description": "Employment sector. Switches between the private (tabel 3-25) and public (tabel 27-41) corpora; also gates directive direct-effect framing downstream."
60
+ },
61
+ "period": {"type": ["string", "null"], "description": "Measurement period as printed, e.g. 'september 2025' or 'maj 2025'."},
62
+ "data_period_month": {"type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}$", "description": "ISO YYYY-MM of the snapshot ('2025-09' private, '2025-05' public). Use for deterministic fremskrivning to 2026."},
63
+ "currency": {"type": "string", "enum": ["DKK"], "description": "Currency of all monetary values."},
64
+ "pay_concept": {
65
+ "type": "string",
66
+ "enum": ["gross_monthly", "gross_monthly_with_components", "net_monthly", "annual_income"],
67
+ "description": "What the monetary values represent. Bruttoloen=gross_monthly; public tables with base/supplement/pension breakdown=gross_monthly_with_components; self-employed=annual_income."
68
+ },
69
+ "includes_executives": {"type": ["boolean", "null"], "description": "True = row is inkl. topchefer, False = ekskl. topchefer. Means are NOT comparable across this flag; the dashboard must read it before differencing a user's salary against the cell. Null only if the source is genuinely ambiguous."},
70
+ "education_type": {
71
+ "type": "string",
72
+ "enum": ["civilingenior", "akademiingenior", "diplom_eksportingenior", "teknikumingenior", "cand_scient", "all"],
73
+ "description": "Filterable dimension. Derive from table_id: 4->civilingenior, 3->akademiingenior, 5->teknikumingenior, 6->diplom_eksportingenior, 7->cand_scient. 'all' for tabel 8-25 and public tables that mix educations."
74
+ },
75
+ "region": {
76
+ "type": "string",
77
+ "enum": ["hovedstaden", "sjaelland", "syddanmark", "midtjylland", "nordjylland", "all"],
78
+ "description": "Filterable dimension. From tabel 11 columns; 'all' elsewhere."
79
+ },
80
+ "position_level": {
81
+ "type": "string",
82
+ "enum": ["topchef", "funktionsdirektor", "afdelingschef", "projektleder", "chef_seniorkonsulent", "specialist", "ingenior_akademiker", "anden", "trainee", "all"],
83
+ "description": "Filterable dimension. Same enum whether the position appears as a row (tabel 12/13) or a column/segment (tabel 14/15)."
84
+ },
85
+ "position_level_rank": {"type": ["integer", "null"], "minimum": 1, "maximum": 9, "description": "Ordinal helper (trainee=1 ... topchef=9), null when position_level='all'. NOT a wildcard filter; use for 'next level up' framing and range queries."},
86
+ "gender": {
87
+ "type": "string",
88
+ "enum": ["male", "female", "all"],
89
+ "description": "Filterable dimension. From tabel 17 (and 24/25). Basis of the equal-pay comparison."
90
+ },
91
+ "is_leader": {
92
+ "type": "string",
93
+ "enum": ["leader", "non_leader", "all"],
94
+ "description": "Filterable dimension. Leader/non-leader grouping in tabel 16/17/23. (Boolean concept encoded as enum so 'all' remains the single wildcard.)"
95
+ },
96
+ "manages_people": {
97
+ "type": "string",
98
+ "enum": ["yes", "no", "all"],
99
+ "description": "Filterable dimension. Tabel 14."
100
+ },
101
+ "management_span_band": {
102
+ "type": "string",
103
+ "enum": ["none", "1-4", "5-10", "11-19", "20-29", "30-50", "51-100", "101-200", "201-500", "501-1000", "1001+", "all"],
104
+ "description": "Filterable dimension. Tabel 14. For the onboarding bucket '20+', match against span_min/span_max range rather than exact band."
105
+ },
106
+ "span_min": {"type": ["integer", "null"], "description": "Lower bound of management_span_band as an int (e.g. 5 for '5-10'), for range-contains matching. Null when band is 'all' or 'none'."},
107
+ "span_max": {"type": ["integer", "null"], "description": "Upper bound of management_span_band (e.g. 10 for '5-10'; null for open-ended '1001+')."},
108
+ "stem_count_band": {
109
+ "type": "string",
110
+ "enum": ["1-9", "10-24", "25-99", "100-249", "250+", "all"],
111
+ "description": "Filterable dimension. IDA company-size axis (number of engineers/STEM in the company, tabel 15). KEEP SEPARATE from the EU total-headcount band, which is not stored here in v1 (directive = embedding search)."
112
+ },
113
+ "specialization": {
114
+ "type": "string",
115
+ "enum": ["byggeri_anlaeg", "datalogi_it_software", "elektronik", "kemi_biologi_biotek", "maskin_industri_robot", "energi_miljo", "eksport_ledelse_forretning", "levnedsmiddel", "matematik_fysik_geologi", "all"],
116
+ "description": "Filterable dimension from tabel 8 (uddannelsesretning). Not in v1 onboarding; populate anyway ('all' off-table) so the wildcard logic holds and so it is a free upgrade later."
117
+ },
118
+ "work_area": {
119
+ "type": "string",
120
+ "enum": ["forskning_innovation_undervisning", "byggeri_anlaeg", "industri_produktion_kemi", "elektroteknik_tele", "it_programmering", "energi_forsyning_miljo", "transport_infrastruktur", "naturvidenskab_medicin", "administration_ledelse_service", "all"],
121
+ "description": "Filterable dimension from tabel 9 (arbejdsomraade). Not in v1 onboarding; populate anyway."
122
+ },
123
+ "branche": {
124
+ "type": "string",
125
+ "enum": ["raastofindvinding", "forsyningsvirksomhed", "foedevare_kemi_plast_trae", "medicinalindustri", "maskin_jern_metal", "elektronisk_industri", "raadgivende_ingenior", "entreprenor_bygge_anlaeg", "it_telekommunikation", "forskning_udvikling_finans", "handel_transport", "organisationer", "all"],
126
+ "description": "Filterable dimension from tabel 10 (branche). Not in v1 onboarding; populate anyway."
127
+ },
128
+ "jobloen": {
129
+ "type": "string",
130
+ "enum": ["yes", "no", "all"],
131
+ "description": "Filterable dimension. Tabel 16 (fixed salary, no right to overtime pay/afspadsering)."
132
+ },
133
+ "row_dimension": {"type": ["string", "null"], "description": "Raw dimension the table rows break down by (provenance), e.g. 'kandidataargang', 'stilling', 'branche', 'alder', 'region'."},
134
+ "row_label": {"type": ["string", "null"], "description": "Raw row label as printed, e.g. '1986-1987', 'Projektleder', '25-29 aar', 'Alle'. Display/provenance only."},
135
+ "segment_dimension": {"type": ["string", "null"], "description": "Raw dimension of the segment_label (provenance only)."},
136
+ "segment_label": {"type": ["string", "null"], "description": "Raw specific segment label (provenance only); the typed dimension fields are what you filter on."},
137
+ "graduation_year_start": {"type": ["integer", "null"], "description": "Earliest graduation year of the cohort; null = unbounded below ('Foer 1986' -> start null, end 1985)."},
138
+ "graduation_year_end": {"type": ["integer", "null"], "description": "Latest graduation year of the cohort; null = unbounded above (open-ended recent). Match rule: (start is null OR profile_year >= start) AND (end is null OR profile_year <= end)."},
139
+ "experience_years_min": {"type": ["integer", "null"], "description": "Derived min experience = 2025 - graduation_year_end (proxy)."},
140
+ "experience_years_max": {"type": ["integer", "null"], "description": "Derived max experience = 2025 - graduation_year_start; null when open-ended."},
141
+ "age_min": {"type": ["integer", "null"], "description": "Minimum age (age-breakdown rows)."},
142
+ "age_max": {"type": ["integer", "null"], "description": "Maximum age; null when open-ended."},
143
+ "cohort_growth_pct": {"type": ["number", "null"], "description": "3-yr gliding annual growth for this cohort+sector (tabel 1), if denormalized onto the observation. Recommended alternative: keep tabel 1 as separate salary_growth_rate records keyed by (graduation cohort, sector) and join at query time."},
144
+ "dimension_keys": {
145
+ "type": "array",
146
+ "items": {
147
+ "type": "string",
148
+ "enum": ["sector", "education_type", "region", "position_level", "gender", "is_leader", "manages_people", "management_span_band", "stem_count_band", "specialization", "work_area", "branche", "jobloen", "graduation_year"]
149
+ },
150
+ "uniqueItems": true,
151
+ "description": "The dimensions that are SPECIFIC (non-'all', or a bounded cohort for graduation_year) in this chunk = the table's breakdown axes. Drives specificity and the 'most relevant comparison' label."
152
+ },
153
+ "specificity": {"type": "integer", "minimum": 0, "description": "len(dimension_keys). Sort key: prefer the most specific cell consistent with the profile, falling back to broader cells when the specific one is suppressed."},
154
+ "sample_size": {"type": ["number", "null"], "description": "Antal / number of respondents (mirror of measure.count). Drives reliability_tier."},
155
+ "reliability_tier": {
156
+ "type": "string",
157
+ "enum": ["suppressed", "mean_only", "median_ok", "quartiles_ok", "full"],
158
+ "description": "Derived from sample_size per KRL suppression rules: n<5 -> suppressed (no mean); 5-9 -> mean_only; 10-19 -> median_ok (no quartiles); 20-49 -> quartiles_ok (no p90); n>=50 -> full. The dashboard reads this to decide which measure.* fields are safe to render."
159
+ },
160
+ "measure": {
161
+ "type": "object",
162
+ "description": "Statistics reported for this row/segment. All monetary values are DKK; bonus_pct is a percentage.",
163
+ "additionalProperties": false,
164
+ "minProperties": 1,
165
+ "properties": {
166
+ "count": {"type": "number", "description": "Antal - number of respondents (sample size)."},
167
+ "mean": {"type": "number", "description": "Gennemsnit / Bruttoloen - average."},
168
+ "p25": {"type": "number", "description": "Nedre kvartil / 25% fraktil."},
169
+ "median": {"type": "number", "description": "Median / 50% fraktil."},
170
+ "p75": {"type": "number", "description": "OEvre kvartil / 75% fraktil."},
171
+ "p90": {"type": "number", "description": "90% fraktil."},
172
+ "base": {"type": "number", "description": "Basisloen / Grundloen (public sector component)."},
173
+ "supplement": {"type": "number", "description": "Tillaeg (public sector component)."},
174
+ "pension": {"type": "number", "description": "Pensionsbidrag (public sector component)."},
175
+ "bonus_avg": {"type": "number", "description": "Average annual bonus (for those who receive bonus)."},
176
+ "bonus_pct": {"type": "number", "description": "Percent of the group receiving a bonus."}
177
+ }
178
+ },
179
+ "rag_text": {"type": "string", "description": "Natural-language summary for embedding/retrieval. MUST textually contain the human-readable values of every dimension_keys entry plus the reliability caveat, so vector recall backstops a too-narrow metadata filter. Include a source citation (union, table, page)."}
180
+ }
181
+ }
pyproject.toml CHANGED
@@ -22,7 +22,11 @@ dependencies = [
22
  # Dev-only. Never installed on the Space (its runtime only *loads* the committed
23
  # index). `modal` powers scripts/build_index_modal.py — borrow a GPU to embed the
24
  # corpus, then assemble the FAISS index locally. Install with `uv sync --group dev`.
25
- dev = ["modal>=0.70"]
 
 
 
 
26
 
27
  [tool.uv]
28
  extra-index-url = ["https://download.pytorch.org/whl/cu118"]
 
22
  # Dev-only. Never installed on the Space (its runtime only *loads* the committed
23
  # index). `modal` powers scripts/build_index_modal.py — borrow a GPU to embed the
24
  # corpus, then assemble the FAISS index locally. Install with `uv sync --group dev`.
25
+ dev = [
26
+ "modal>=0.70",
27
+ "pdfplumber", # deterministic PDF table re-extraction (tabel 11/17/27)
28
+ "jsonschema", # validate records against ida_salary_record.schema.json
29
+ ]
30
 
31
  [tool.uv]
32
  extra-index-url = ["https://download.pytorch.org/whl/cu118"]
scripts/build_ida_records.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Assemble the structured IDA corpus → ``ida_records.jsonl`` (CPU-only).
2
+
3
+ Sources, in order:
4
+ 1. Transform of the verified flat ``salary_records.jsonl`` (recoverable tables).
5
+ 2. Re-extracted multi-axis tables (region/gender/leadership/public-position),
6
+ if ``ida_reextracted.jsonl`` exists (produced by scripts/extract_engine.py).
7
+ 3. Curated ``salary_growth_rate`` / ``salary_forecast`` constants (tabel 1/2).
8
+
9
+ Every record gets a contract-compliant ``rag_text`` and is validated against
10
+ ``ida_salary_record.schema.json``; the build FAILS on any violation. Output is
11
+ plain JSON (no embedding here) — embedding is a separate Modal step.
12
+
13
+ Usage:: uv run python scripts/build_ida_records.py
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from jsonschema import Draft202012Validator
23
+
24
+ REPO = Path(__file__).resolve().parents[1]
25
+ sys.path.insert(0, str(REPO))
26
+
27
+ from src.backend.indexing.ida_transform import ( # noqa: E402
28
+ make_rag_text,
29
+ transform_corpus,
30
+ )
31
+ from src.backend.schemas import IdaSalaryRecord, Measure # noqa: E402
32
+
33
+ LON = REPO / "data" / "processed" / "lonstatistik"
34
+ SOURCE_JSONL = LON / "salary_records.jsonl"
35
+ REEXTRACT_JSONL = LON / "ida_reextracted.jsonl" # optional (task: re-extraction)
36
+ CURATED_JSONL = LON / "curated_growth_forecast.jsonl"
37
+ SCHEMA = LON / "ida_salary_record.schema.json"
38
+ OUT = LON / "ida_records.jsonl"
39
+
40
+
41
+ def build_curated() -> list[IdaSalaryRecord]:
42
+ """Curated growth/forecast records (Q4 decision: documented constants).
43
+
44
+ Convention for non-observation record_types: the annual percentage lives in
45
+ ``cohort_growth_pct`` (fusion reads it); the required salary-shaped
46
+ ``measure`` carries the same number under ``mean`` purely to satisfy the
47
+ contract (it is a percentage, not DKK, and never rendered as a salary).
48
+ Real per-cohort tabel-1 growth is a v1.1 upgrade.
49
+ """
50
+ forecast_mid = 3.35 # private 2026 band 3.2–3.5%
51
+ recs = [
52
+ IdaSalaryRecord(
53
+ id="IDA-curated-forecast-private",
54
+ source_doc="IDA Lønstatistik 2025",
55
+ source_page=2, table_id=2, table_title="LØNUDVIKLING/PROGNOSE",
56
+ record_type="salary_forecast", sector="private",
57
+ data_period_month="2025-09", pay_concept="gross_monthly",
58
+ cohort_growth_pct=forecast_mid, reliability_tier="full",
59
+ measure=Measure(mean=forecast_mid), rag_text="",
60
+ ),
61
+ IdaSalaryRecord(
62
+ id="IDA-curated-growth-private",
63
+ source_doc="IDA Lønstatistik 2025",
64
+ source_page=1, table_id=1, table_title="LØNUDVIKLING",
65
+ record_type="salary_growth_rate", sector="private",
66
+ data_period_month="2025-09", pay_concept="gross_monthly",
67
+ cohort_growth_pct=forecast_mid, reliability_tier="full",
68
+ measure=Measure(mean=forecast_mid), rag_text="",
69
+ ),
70
+ ]
71
+ return recs
72
+
73
+
74
+ def load_reextracted() -> list[IdaSalaryRecord]:
75
+ if not REEXTRACT_JSONL.exists():
76
+ return []
77
+ out = []
78
+ for line in REEXTRACT_JSONL.read_text(encoding="utf-8").splitlines():
79
+ if line.strip():
80
+ out.append(IdaSalaryRecord(**json.loads(line)))
81
+ return out
82
+
83
+
84
+ def main() -> None:
85
+ old = [json.loads(l) for l in SOURCE_JSONL.read_text(encoding="utf-8").splitlines() if l.strip()]
86
+ records = transform_corpus(old)
87
+ n_transform = len(records)
88
+
89
+ reextracted = load_reextracted()
90
+ records += reextracted
91
+
92
+ curated = build_curated()
93
+ records += curated
94
+
95
+ # rag_text for every record (contract: names each dimension_keys value + caveat + cite).
96
+ for r in records:
97
+ r.rag_text = make_rag_text(r)
98
+
99
+ # Persist curated separately for transparency (Q4 artifact).
100
+ CURATED_JSONL.write_text(
101
+ "\n".join(r.model_dump_json(exclude_none=True) for r in curated) + "\n",
102
+ encoding="utf-8",
103
+ )
104
+
105
+ # Validate EVERY record against the authoritative schema; fail on any error.
106
+ validator = Draft202012Validator(json.loads(SCHEMA.read_text(encoding="utf-8")))
107
+ errors = []
108
+ seen_ids = set()
109
+ for r in records:
110
+ d = r.model_dump(exclude_none=True)
111
+ if r.id in seen_ids:
112
+ errors.append((r.id, "duplicate id"))
113
+ seen_ids.add(r.id)
114
+ for e in validator.iter_errors(d):
115
+ errors.append((r.id, f"{list(e.path)}: {e.message}"))
116
+ break
117
+ if errors:
118
+ print(f"VALIDATION FAILED: {len(errors)} error(s)")
119
+ for eid, msg in errors[:20]:
120
+ print(" ", eid, msg)
121
+ raise SystemExit(1)
122
+
123
+ OUT.write_text(
124
+ "\n".join(r.model_dump_json(exclude_none=True) for r in records) + "\n",
125
+ encoding="utf-8",
126
+ )
127
+
128
+ import collections
129
+ by_type = collections.Counter(r.record_type for r in records)
130
+ by_sector = collections.Counter(r.sector for r in records)
131
+ print(f"OK — {len(records)} records → {OUT}")
132
+ print(f" transform={n_transform} reextracted={len(reextracted)} curated={len(curated)}")
133
+ print(f" by record_type: {dict(by_type)}")
134
+ print(f" by sector: {dict(by_sector)}")
135
+ if not reextracted:
136
+ print(" NOTE: no ida_reextracted.jsonl — region/gender cells absent until re-extraction runs.")
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
scripts/eval/results/benchmark_20260615_163150.json ADDED
@@ -0,0 +1,540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "meta": {
3
+ "benchmark": "Pay Equity RAG Benchmark v1",
4
+ "timestamp": "20260615_163150",
5
+ "num_questions": 15,
6
+ "k": 5,
7
+ "run_generation": false
8
+ },
9
+ "aggregate": {
10
+ "retrieval": {
11
+ "mrr": 0.7022222222222222,
12
+ "ndcg@1": 0.6,
13
+ "ndcg@3": 0.7174573004761944,
14
+ "ndcg@5": 0.7432474876251638,
15
+ "recall@1": 0.6,
16
+ "recall@3": 0.8,
17
+ "recall@5": 0.8666666666666667
18
+ },
19
+ "generation": {
20
+ "answer_length_chars": 0.0,
21
+ "answer_length_words": 0.0,
22
+ "citations_count": 0.0,
23
+ "fact_coverage": 0.0,
24
+ "ground_truth_citations_present": 0.0
25
+ }
26
+ },
27
+ "per_question": [
28
+ {
29
+ "id": "Q01",
30
+ "type": "directive",
31
+ "question": "Can a Danish employer ask me about my previous salary during a job interview?",
32
+ "ground_truth_chunk_ids": [
33
+ "dir-art-5"
34
+ ],
35
+ "retrieved_chunk_ids": [
36
+ "dir-art-5",
37
+ "ida-p023-1594",
38
+ "ida-p023-1592",
39
+ "ida-p023-1611",
40
+ "ida-p023-1603"
41
+ ],
42
+ "retrieved_scores": [
43
+ 0.46843454241752625,
44
+ 0.39175179600715637,
45
+ 0.39157167077064514,
46
+ 0.38680705428123474,
47
+ 0.3867814242839813
48
+ ],
49
+ "retrieval_time_s": 0.849,
50
+ "generation_time_s": 0.0,
51
+ "retrieval_metrics": {
52
+ "recall@1": 1.0,
53
+ "recall@3": 1.0,
54
+ "recall@5": 1.0,
55
+ "mrr": 1.0,
56
+ "ndcg@1": 1.0,
57
+ "ndcg@3": 1.0,
58
+ "ndcg@5": 1.0
59
+ },
60
+ "generation_metrics": null
61
+ },
62
+ {
63
+ "id": "Q02",
64
+ "type": "directive",
65
+ "question": "What are my rights as a worker to see what my colleagues earn?",
66
+ "ground_truth_chunk_ids": [
67
+ "dir-art-7"
68
+ ],
69
+ "retrieved_chunk_ids": [
70
+ "dir-art-7",
71
+ "dir-art-12",
72
+ "dir-art-6",
73
+ "dir-art-5",
74
+ "dir-art-9"
75
+ ],
76
+ "retrieved_scores": [
77
+ 0.4882346987724304,
78
+ 0.38052114844322205,
79
+ 0.37416872382164,
80
+ 0.37270212173461914,
81
+ 0.3478201925754547
82
+ ],
83
+ "retrieval_time_s": 0.528,
84
+ "generation_time_s": 0.0,
85
+ "retrieval_metrics": {
86
+ "recall@1": 1.0,
87
+ "recall@3": 1.0,
88
+ "recall@5": 1.0,
89
+ "mrr": 1.0,
90
+ "ndcg@1": 1.0,
91
+ "ndcg@3": 1.0,
92
+ "ndcg@5": 1.0
93
+ },
94
+ "generation_metrics": null
95
+ },
96
+ {
97
+ "id": "Q03",
98
+ "type": "directive",
99
+ "question": "At what company size does mandatory pay gap reporting kick in under the new EU directive?",
100
+ "ground_truth_chunk_ids": [
101
+ "dir-art-9"
102
+ ],
103
+ "retrieved_chunk_ids": [
104
+ "dir-art-9",
105
+ "dir-art-31",
106
+ "dir-art-29",
107
+ "dir-art-35",
108
+ "dir-art-10"
109
+ ],
110
+ "retrieved_scores": [
111
+ 0.5659891963005066,
112
+ 0.4674082398414612,
113
+ 0.4606870710849762,
114
+ 0.4587571620941162,
115
+ 0.4516479969024658
116
+ ],
117
+ "retrieval_time_s": 0.654,
118
+ "generation_time_s": 0.0,
119
+ "retrieval_metrics": {
120
+ "recall@1": 1.0,
121
+ "recall@3": 1.0,
122
+ "recall@5": 1.0,
123
+ "mrr": 1.0,
124
+ "ndcg@1": 1.0,
125
+ "ndcg@3": 1.0,
126
+ "ndcg@5": 1.0
127
+ },
128
+ "generation_metrics": null
129
+ },
130
+ {
131
+ "id": "Q04",
132
+ "type": "directive",
133
+ "question": "What happens if a company's pay report shows women earn less than men — does the directive require specific action?",
134
+ "ground_truth_chunk_ids": [
135
+ "dir-art-10"
136
+ ],
137
+ "retrieved_chunk_ids": [
138
+ "dir-art-9",
139
+ "dir-art-10",
140
+ "dir-art-29",
141
+ "dir-art-3",
142
+ "dir-art-24"
143
+ ],
144
+ "retrieved_scores": [
145
+ 0.48109012842178345,
146
+ 0.4684622585773468,
147
+ 0.4062488377094269,
148
+ 0.4044463336467743,
149
+ 0.3909009099006653
150
+ ],
151
+ "retrieval_time_s": 0.754,
152
+ "generation_time_s": 0.0,
153
+ "retrieval_metrics": {
154
+ "recall@1": 0.0,
155
+ "recall@3": 1.0,
156
+ "recall@5": 1.0,
157
+ "mrr": 0.5,
158
+ "ndcg@1": 0.0,
159
+ "ndcg@3": 0.6309297535714575,
160
+ "ndcg@5": 0.6309297535714575
161
+ },
162
+ "generation_metrics": null
163
+ },
164
+ {
165
+ "id": "Q05",
166
+ "type": "directive",
167
+ "question": "If I sue my employer for pay discrimination and they didn't follow the transparency rules, who has to prove what?",
168
+ "ground_truth_chunk_ids": [
169
+ "dir-art-18"
170
+ ],
171
+ "retrieved_chunk_ids": [
172
+ "dir-art-18",
173
+ "dir-art-6",
174
+ "dir-art-5",
175
+ "dir-art-9",
176
+ "dir-art-19"
177
+ ],
178
+ "retrieved_scores": [
179
+ 0.45788896083831787,
180
+ 0.37829428911209106,
181
+ 0.34863778948783875,
182
+ 0.34772422909736633,
183
+ 0.3451765179634094
184
+ ],
185
+ "retrieval_time_s": 0.793,
186
+ "generation_time_s": 0.0,
187
+ "retrieval_metrics": {
188
+ "recall@1": 1.0,
189
+ "recall@3": 1.0,
190
+ "recall@5": 1.0,
191
+ "mrr": 1.0,
192
+ "ndcg@1": 1.0,
193
+ "ndcg@3": 1.0,
194
+ "ndcg@5": 1.0
195
+ },
196
+ "generation_metrics": null
197
+ },
198
+ {
199
+ "id": "Q06",
200
+ "type": "directive",
201
+ "question": "How long do I have to bring an equal pay claim under the EU Pay Transparency Directive?",
202
+ "ground_truth_chunk_ids": [
203
+ "dir-art-21"
204
+ ],
205
+ "retrieved_chunk_ids": [
206
+ "dir-art-21",
207
+ "dir-art-29",
208
+ "dir-art-9",
209
+ "dir-art-7",
210
+ "dir-art-18"
211
+ ],
212
+ "retrieved_scores": [
213
+ 0.5816149711608887,
214
+ 0.5352213382720947,
215
+ 0.511902928352356,
216
+ 0.48460856080055237,
217
+ 0.4799960255622864
218
+ ],
219
+ "retrieval_time_s": 0.646,
220
+ "generation_time_s": 0.0,
221
+ "retrieval_metrics": {
222
+ "recall@1": 1.0,
223
+ "recall@3": 1.0,
224
+ "recall@5": 1.0,
225
+ "mrr": 1.0,
226
+ "ndcg@1": 1.0,
227
+ "ndcg@3": 1.0,
228
+ "ndcg@5": 1.0
229
+ },
230
+ "generation_metrics": null
231
+ },
232
+ {
233
+ "id": "Q07",
234
+ "type": "directive",
235
+ "question": "What counts as 'pay' under the directive — just salary or also bonuses and other benefits?",
236
+ "ground_truth_chunk_ids": [
237
+ "dir-art-3"
238
+ ],
239
+ "retrieved_chunk_ids": [
240
+ "dir-art-3",
241
+ "dir-art-4",
242
+ "dir-art-6",
243
+ "dir-art-1",
244
+ "dir-art-10"
245
+ ],
246
+ "retrieved_scores": [
247
+ 0.44907885789871216,
248
+ 0.37218886613845825,
249
+ 0.3629388213157654,
250
+ 0.3602444529533386,
251
+ 0.3525366187095642
252
+ ],
253
+ "retrieval_time_s": 0.665,
254
+ "generation_time_s": 0.0,
255
+ "retrieval_metrics": {
256
+ "recall@1": 1.0,
257
+ "recall@3": 1.0,
258
+ "recall@5": 1.0,
259
+ "mrr": 1.0,
260
+ "ndcg@1": 1.0,
261
+ "ndcg@3": 1.0,
262
+ "ndcg@5": 1.0
263
+ },
264
+ "generation_metrics": null
265
+ },
266
+ {
267
+ "id": "Q08",
268
+ "type": "directive",
269
+ "question": "What is the deadline for EU member states to implement the Pay Transparency Directive into national law?",
270
+ "ground_truth_chunk_ids": [
271
+ "dir-art-34"
272
+ ],
273
+ "retrieved_chunk_ids": [
274
+ "dir-art-29",
275
+ "dir-art-9",
276
+ "dir-art-31",
277
+ "dir-art-35",
278
+ "dir-art-34"
279
+ ],
280
+ "retrieved_scores": [
281
+ 0.5351299047470093,
282
+ 0.5249780416488647,
283
+ 0.5037232637405396,
284
+ 0.5034652948379517,
285
+ 0.5011686682701111
286
+ ],
287
+ "retrieval_time_s": 0.675,
288
+ "generation_time_s": 0.0,
289
+ "retrieval_metrics": {
290
+ "recall@1": 0.0,
291
+ "recall@3": 0.0,
292
+ "recall@5": 1.0,
293
+ "mrr": 0.2,
294
+ "ndcg@1": 0.0,
295
+ "ndcg@3": 0.0,
296
+ "ndcg@5": 0.38685280723454163
297
+ },
298
+ "generation_metrics": null
299
+ },
300
+ {
301
+ "id": "Q09",
302
+ "type": "directive",
303
+ "question": "What penalties can be imposed on employers who violate the equal pay principle under the directive?",
304
+ "ground_truth_chunk_ids": [
305
+ "dir-art-23"
306
+ ],
307
+ "retrieved_chunk_ids": [
308
+ "dir-art-23",
309
+ "dir-art-24",
310
+ "dir-art-17",
311
+ "dir-art-1",
312
+ "dir-art-18"
313
+ ],
314
+ "retrieved_scores": [
315
+ 0.6221146583557129,
316
+ 0.5374859571456909,
317
+ 0.5216008424758911,
318
+ 0.5104919672012329,
319
+ 0.48372018337249756
320
+ ],
321
+ "retrieval_time_s": 0.615,
322
+ "generation_time_s": 0.0,
323
+ "retrieval_metrics": {
324
+ "recall@1": 1.0,
325
+ "recall@3": 1.0,
326
+ "recall@5": 1.0,
327
+ "mrr": 1.0,
328
+ "ndcg@1": 1.0,
329
+ "ndcg@3": 1.0,
330
+ "ndcg@5": 1.0
331
+ },
332
+ "generation_metrics": null
333
+ },
334
+ {
335
+ "id": "Q10",
336
+ "type": "salary",
337
+ "question": "Hvad er medianlønnen for en civilingeniør i det private med 10 års erfaring ifølge IDAs lønstatistik for 2025?",
338
+ "ground_truth_chunk_ids": [
339
+ "ida-p011-0035"
340
+ ],
341
+ "retrieved_chunk_ids": [
342
+ "ida-p011-0035",
343
+ "ida-p011-0030",
344
+ "ida-p011-0045",
345
+ "ida-p021-1496",
346
+ "ida-p011-0044"
347
+ ],
348
+ "retrieved_scores": [
349
+ 0.5657927989959717,
350
+ 0.561237096786499,
351
+ 0.5611233115196228,
352
+ 0.5604856610298157,
353
+ 0.5585825443267822
354
+ ],
355
+ "retrieval_time_s": 1.204,
356
+ "generation_time_s": 0.0,
357
+ "retrieval_metrics": {
358
+ "recall@1": 1.0,
359
+ "recall@3": 1.0,
360
+ "recall@5": 1.0,
361
+ "mrr": 1.0,
362
+ "ndcg@1": 1.0,
363
+ "ndcg@3": 1.0,
364
+ "ndcg@5": 1.0
365
+ },
366
+ "generation_metrics": null
367
+ },
368
+ {
369
+ "id": "Q11",
370
+ "type": "salary",
371
+ "question": "What is the median salary for a civilingeniør (MSc engineer) with 5 years of experience in the Danish private sector? How many respondents and what percentage receive a bonus?",
372
+ "ground_truth_chunk_ids": [
373
+ "ida-p011-0040"
374
+ ],
375
+ "retrieved_chunk_ids": [
376
+ "ida-p011-0025",
377
+ "ida-p011-0029",
378
+ "ida-p011-0028",
379
+ "ida-p011-0030",
380
+ "ida-p011-0027"
381
+ ],
382
+ "retrieved_scores": [
383
+ 0.6918340921401978,
384
+ 0.6845650672912598,
385
+ 0.6832934617996216,
386
+ 0.6826284527778625,
387
+ 0.6825269460678101
388
+ ],
389
+ "retrieval_time_s": 1.181,
390
+ "generation_time_s": 0.0,
391
+ "retrieval_metrics": {
392
+ "recall@1": 0.0,
393
+ "recall@3": 0.0,
394
+ "recall@5": 0.0,
395
+ "mrr": 0.0,
396
+ "ndcg@1": 0.0,
397
+ "ndcg@3": 0.0,
398
+ "ndcg@5": 0.0
399
+ },
400
+ "generation_metrics": null
401
+ },
402
+ {
403
+ "id": "Q12",
404
+ "type": "salary",
405
+ "question": "What is the average starting salary for a newly graduated civilingeniør (2025 graduate with 0 years experience) in the Danish private sector?",
406
+ "ground_truth_chunk_ids": [
407
+ "ida-p011-0045"
408
+ ],
409
+ "retrieved_chunk_ids": [
410
+ "ida-p011-0044",
411
+ "ida-p011-0045",
412
+ "ida-p011-0040",
413
+ "ida-p011-0038",
414
+ "ida-p011-0039"
415
+ ],
416
+ "retrieved_scores": [
417
+ 0.6287952661514282,
418
+ 0.6287572979927063,
419
+ 0.6224535703659058,
420
+ 0.6201796531677246,
421
+ 0.6197202205657959
422
+ ],
423
+ "retrieval_time_s": 0.969,
424
+ "generation_time_s": 0.0,
425
+ "retrieval_metrics": {
426
+ "recall@1": 0.0,
427
+ "recall@3": 1.0,
428
+ "recall@5": 1.0,
429
+ "mrr": 0.5,
430
+ "ndcg@1": 0.0,
431
+ "ndcg@3": 0.6309297535714575,
432
+ "ndcg@5": 0.6309297535714575
433
+ },
434
+ "generation_metrics": null
435
+ },
436
+ {
437
+ "id": "Q13",
438
+ "type": "salary",
439
+ "question": "What is the average salary for a topchef/funktionsdirektør (C-level executive) with 25 years of experience according to IDA 2025 salary statistics?",
440
+ "ground_truth_chunk_ids": [
441
+ "ida-p021-1371"
442
+ ],
443
+ "retrieved_chunk_ids": [
444
+ "ida-p021-1371",
445
+ "ida-p021-1491",
446
+ "ida-p021-1523",
447
+ "ida-p021-1515",
448
+ "ida-p021-1531"
449
+ ],
450
+ "retrieved_scores": [
451
+ 0.6942191123962402,
452
+ 0.6869297027587891,
453
+ 0.6857338547706604,
454
+ 0.6853389739990234,
455
+ 0.684471607208252
456
+ ],
457
+ "retrieval_time_s": 1.151,
458
+ "generation_time_s": 0.0,
459
+ "retrieval_metrics": {
460
+ "recall@1": 1.0,
461
+ "recall@3": 1.0,
462
+ "recall@5": 1.0,
463
+ "mrr": 1.0,
464
+ "ndcg@1": 1.0,
465
+ "ndcg@3": 1.0,
466
+ "ndcg@5": 1.0
467
+ },
468
+ "generation_metrics": null
469
+ },
470
+ {
471
+ "id": "Q14",
472
+ "type": "salary",
473
+ "question": "What is the average salary in the pharmaceutical industry (medicinalindustri) for IDA engineers with 5 years of experience?",
474
+ "ground_truth_chunk_ids": [
475
+ "ida-p017-1176"
476
+ ],
477
+ "retrieved_chunk_ids": [
478
+ "ida-p017-1011",
479
+ "ida-p017-1033",
480
+ "ida-p017-1176",
481
+ "ida-p017-1121",
482
+ "ida-p017-1044"
483
+ ],
484
+ "retrieved_scores": [
485
+ 0.6024791598320007,
486
+ 0.5985130071640015,
487
+ 0.5924195051193237,
488
+ 0.5912789106369019,
489
+ 0.5893527865409851
490
+ ],
491
+ "retrieval_time_s": 0.906,
492
+ "generation_time_s": 0.0,
493
+ "retrieval_metrics": {
494
+ "recall@1": 0.0,
495
+ "recall@3": 1.0,
496
+ "recall@5": 1.0,
497
+ "mrr": 0.3333333333333333,
498
+ "ndcg@1": 0.0,
499
+ "ndcg@3": 0.5,
500
+ "ndcg@5": 0.5
501
+ },
502
+ "generation_metrics": null
503
+ },
504
+ {
505
+ "id": "Q15",
506
+ "type": "salary",
507
+ "question": "What is the average salary for an IDA engineer working in the state sector (staten) with around 20 years of experience?",
508
+ "ground_truth_chunk_ids": [
509
+ "ida-p030-1698"
510
+ ],
511
+ "retrieved_chunk_ids": [
512
+ "ida-p030-1713",
513
+ "ida-p030-1701",
514
+ "ida-p030-1699",
515
+ "ida-p030-1693",
516
+ "ida-p030-1703"
517
+ ],
518
+ "retrieved_scores": [
519
+ 0.5219017267227173,
520
+ 0.5170749425888062,
521
+ 0.5162566900253296,
522
+ 0.5155266523361206,
523
+ 0.5143060684204102
524
+ ],
525
+ "retrieval_time_s": 0.915,
526
+ "generation_time_s": 0.0,
527
+ "retrieval_metrics": {
528
+ "recall@1": 0.0,
529
+ "recall@3": 0.0,
530
+ "recall@5": 0.0,
531
+ "mrr": 0.0,
532
+ "ndcg@1": 0.0,
533
+ "ndcg@3": 0.0,
534
+ "ndcg@5": 0.0
535
+ },
536
+ "generation_metrics": null
537
+ }
538
+ ],
539
+ "summary_text": "\n======================================================================\n Benchmark Results Summary\n======================================================================\n\n Retrieval:\n Recall@1: 0.600 (9/15 questions with ≥1 hit)\n Recall@3: 0.800\n Recall@5: 0.867\n MRR: 0.7022\n NDCG@5: 0.7432\n\n Directive questions (9):\n Recall@1: 0.778 MRR: 0.8556\n Salary questions (6):\n Recall@1: 0.333 MRR: 0.4722"
540
+ }
src/backend/fusion.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Profile-driven dashboard fusion (v1, salary path).
2
+
3
+ A single profile matches one cell from several IDA tables (education×cohort,
4
+ position×cohort, region×cohort, gender×leadership, …). This module fuses them:
5
+ for each breakdown axis it picks the most-specific *non-suppressed* cell
6
+ consistent with the profile (falling back to a broader cell when the specific
7
+ one is suppressed), joins the curated growth/forecast records, projects the
8
+ headline figure to 2026, and computes the user's percentile position + gap.
9
+
10
+ Reliability gating and matching live in ``retrieval``; this layer composes them
11
+ into the ``Dashboard`` the synthesis/UI layer renders.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .retrieval import _profile_constraints, gated_measure, load_records, retrieve
17
+ from .schemas import Dashboard, IdaSalaryRecord, Profile, RecommendationCell
18
+
19
+ # Readable values for the enum dimensions that can appear in a comparison label.
20
+ _DIM_DISPLAY = {
21
+ "position_level": {
22
+ "topchef": "top executive", "funktionsdirektor": "functional director",
23
+ "afdelingschef": "department head", "projektleder": "project manager",
24
+ "chef_seniorkonsulent": "chief/senior consultant", "specialist": "specialist",
25
+ "ingenior_akademiker": "engineer/academic", "anden": "other role", "trainee": "trainee",
26
+ },
27
+ "region": {
28
+ "hovedstaden": "Capital Region", "sjaelland": "Region Zealand",
29
+ "syddanmark": "Southern Denmark", "midtjylland": "Central Denmark",
30
+ "nordjylland": "North Denmark",
31
+ },
32
+ "gender": {"male": "men", "female": "women"},
33
+ "is_leader": {"leader": "with staff responsibility", "non_leader": "without staff responsibility"},
34
+ "manages_people": {"yes": "managing people", "no": "not managing people"},
35
+ "jobloen": {"yes": "on fixed salary (jobløn)", "no": "not on jobløn"},
36
+ }
37
+
38
+ # Human label for each breakdown axis (the non-cohort dimension(s) of a cell).
39
+ _AXIS_LABEL = {
40
+ frozenset(): "Overall (your cohort)",
41
+ frozenset({"education_type"}): "By education",
42
+ frozenset({"position_level"}): "By position level",
43
+ frozenset({"region"}): "By region",
44
+ frozenset({"gender"}): "By gender",
45
+ frozenset({"gender", "is_leader"}): "By gender & leadership",
46
+ frozenset({"is_leader"}): "By leadership",
47
+ frozenset({"specialization"}): "By specialization",
48
+ frozenset({"work_area"}): "By work area",
49
+ frozenset({"branche"}): "By industry",
50
+ frozenset({"jobloen"}): "Fixed-salary (jobløn)",
51
+ }
52
+
53
+ _EDU_LABEL = {
54
+ "civilingenior": "Civil engineers", "akademiingenior": "Academy engineers",
55
+ "teknikumingenior": "Technical engineers", "diplom_eksportingenior": "Diploma/export engineers",
56
+ "cand_scient": "Science graduates",
57
+ }
58
+
59
+
60
+ def _axis_key(rec: IdaSalaryRecord) -> frozenset[str]:
61
+ return frozenset(d for d in rec.dimension_keys if d != "graduation_year")
62
+
63
+
64
+ def _cohort_label(rec: IdaSalaryRecord) -> str:
65
+ s, e = rec.graduation_year_start, rec.graduation_year_end
66
+ if s is None and e is None:
67
+ return "all cohorts"
68
+ if s is None:
69
+ return f"graduated ≤{e}"
70
+ if e is None:
71
+ return f"graduated ≥{s}"
72
+ return f"cohort {s}–{e}" if s != e else f"graduated {s}"
73
+
74
+
75
+ def _comparison_label(rec: IdaSalaryRecord) -> str:
76
+ bits: list[str] = []
77
+ if rec.education_type != "all":
78
+ bits.append(_EDU_LABEL.get(rec.education_type, rec.education_type))
79
+ for dim in ("position_level", "region", "gender", "is_leader",
80
+ "specialization", "work_area", "branche", "jobloen"):
81
+ val = getattr(rec, dim)
82
+ if val != "all":
83
+ if dim in ("specialization", "work_area", "branche"):
84
+ bits.append(rec.segment_label or str(val))
85
+ else:
86
+ bits.append(_DIM_DISPLAY.get(dim, {}).get(val, str(val)))
87
+ bits.append(_cohort_label(rec))
88
+ bits.append(rec.sector.replace("_", " "))
89
+ return ", ".join(bits)
90
+
91
+
92
+ def _to_cell(rec: IdaSalaryRecord, growth_pct: float | None) -> RecommendationCell:
93
+ m = gated_measure(rec)
94
+ central = m.median if m.median is not None else m.mean
95
+ projected = (
96
+ round(central * (1 + growth_pct / 100)) if central is not None and growth_pct else None
97
+ )
98
+ return RecommendationCell(
99
+ comparison=_comparison_label(rec),
100
+ citation=f"{rec.source_doc}, tabel {rec.table_id}, p.{rec.source_page}"
101
+ if rec.table_id else f"{rec.source_doc}, p.{rec.source_page}",
102
+ record_id=rec.id,
103
+ dimension_keys=rec.dimension_keys,
104
+ specificity=rec.specificity,
105
+ sample_size=rec.sample_size,
106
+ reliability_tier=rec.reliability_tier,
107
+ includes_executives=rec.includes_executives,
108
+ measure=m,
109
+ projected_median_2026=projected,
110
+ growth_pct_applied=growth_pct if projected is not None else None,
111
+ )
112
+
113
+
114
+ def _best_per_axis(cands: list[IdaSalaryRecord]) -> dict[frozenset, IdaSalaryRecord]:
115
+ """Per axis, the most-specific non-suppressed cell (fallback: broadest non-suppressed)."""
116
+ by_axis: dict[frozenset, list[IdaSalaryRecord]] = {}
117
+ for r in cands:
118
+ by_axis.setdefault(_axis_key(r), []).append(r)
119
+ chosen: dict[frozenset, IdaSalaryRecord] = {}
120
+ for axis, recs in by_axis.items():
121
+ ok = [r for r in recs if r.reliability_tier != "suppressed"]
122
+ if not ok:
123
+ continue # whole axis suppressed for this profile → omit
124
+ # recs already globally sorted most-specific first; ok preserves that.
125
+ chosen[axis] = ok[0]
126
+ return chosen
127
+
128
+
129
+ def _forecast_pct(profile: Profile) -> float | None:
130
+ for r in retrieve(profile, record_type="salary_forecast"):
131
+ if r.cohort_growth_pct is not None:
132
+ return r.cohort_growth_pct
133
+ return None
134
+
135
+
136
+ def _estimate_percentile(value: float, m) -> float | None:
137
+ """Approximate percentile of ``value`` within a cell's quantiles."""
138
+ pts = sorted(
139
+ (p, getattr(m, k))
140
+ for p, k in ((25, "p25"), (50, "median"), (75, "p75"), (90, "p90"))
141
+ if getattr(m, k) is not None
142
+ )
143
+ if not pts:
144
+ return None
145
+ if value <= pts[0][1]:
146
+ return round(pts[0][0] * value / pts[0][1], 1) if pts[0][1] else 0.0
147
+ if value >= pts[-1][1]:
148
+ lo_p, lo_v = pts[-1]
149
+ return round(min(100.0, lo_p + (100 - lo_p) * (value - lo_v) / max(lo_v, 1)), 1)
150
+ for (p0, v0), (p1, v1) in zip(pts, pts[1:]):
151
+ if v0 <= value <= v1:
152
+ frac = (value - v0) / (v1 - v0) if v1 != v0 else 0
153
+ return round(p0 + frac * (p1 - p0), 1)
154
+ return None
155
+
156
+
157
+ def _known_dims(profile: Profile) -> set[str]:
158
+ """Dimensions the profile actually pins — so a cell genuinely describes the user.
159
+
160
+ Cells that split on a dimension the profile didn't provide (e.g. an arbitrary
161
+ specialization) must not surface as "your" comparison. graduation_year and
162
+ education_type are always known; leadership is known when management is asked.
163
+ """
164
+ known = {"graduation_year"} | set(_profile_constraints(profile))
165
+ if profile.manages_people is not None:
166
+ known |= {"is_leader", "manages_people"}
167
+ return known
168
+
169
+
170
+ def build_dashboard(profile: Profile) -> Dashboard:
171
+ """Fuse the matched IDA cells into a recommendation dashboard."""
172
+ load_records()
173
+ growth_pct = _forecast_pct(profile)
174
+ known = _known_dims(profile)
175
+ # Only cells whose breakdown axes are all known to the profile describe the
176
+ # user (the brief: unasked dimensions are free upgrades when onboarding grows).
177
+ matched = [
178
+ r for r in retrieve(profile, record_type="salary_observation")
179
+ if set(r.dimension_keys) <= known
180
+ ]
181
+
182
+ chosen = _best_per_axis(matched)
183
+ # Stable, useful axis order for display.
184
+ order = [
185
+ frozenset({"education_type"}), frozenset({"position_level"}),
186
+ frozenset({"gender", "is_leader"}), frozenset({"gender"}),
187
+ frozenset({"region"}), frozenset({"specialization"}),
188
+ frozenset({"work_area"}), frozenset({"branche"}),
189
+ frozenset({"jobloen"}), frozenset(),
190
+ ]
191
+ axes = [a for a in order if a in chosen] + [a for a in chosen if a not in order]
192
+ cells = [_to_cell(chosen[a], growth_pct) for a in axes]
193
+
194
+ notes: list[str] = []
195
+ # Headline = most specific non-suppressed cell that has a usable central figure.
196
+ headline = next(
197
+ (r for r in matched
198
+ if r.reliability_tier != "suppressed"
199
+ and (r.measure.median is not None or r.measure.mean is not None)),
200
+ None,
201
+ )
202
+ user_pct = gap = projected_target = None
203
+ forecast_range = (3.2, 3.5) if growth_pct else None
204
+
205
+ if headline is not None:
206
+ hm = gated_measure(headline)
207
+ central = hm.median if hm.median is not None else hm.mean
208
+ user_gross = profile.current_monthly_gross
209
+ if user_gross is not None and profile.current_monthly_gross_max:
210
+ user_gross = (user_gross + profile.current_monthly_gross_max) // 2
211
+ if user_gross is not None and central is not None:
212
+ user_pct = _estimate_percentile(user_gross, hm)
213
+ gap = user_gross - central
214
+ ask_base = hm.p75 if hm.p75 is not None else central
215
+ if ask_base is not None and growth_pct:
216
+ projected_target = round(ask_base * (1 + growth_pct / 100))
217
+ if headline.includes_executives is False:
218
+ notes.append("Benchmark excludes top executives (ekskl. topchefer); "
219
+ "means are not comparable across this flag.")
220
+ notes.append(
221
+ f"Most relevant comparison: {_comparison_label(headline)} "
222
+ f"(n={int(headline.sample_size) if headline.sample_size else 'n/a'}, "
223
+ f"{headline.reliability_tier})."
224
+ )
225
+
226
+ if growth_pct:
227
+ notes.append(
228
+ f"Figures projected to 2026 using the {forecast_range[0]}–{forecast_range[1]}% "
229
+ f"private forecast band (~{growth_pct}% applied)."
230
+ )
231
+ if not cells:
232
+ notes.append("No reliable salary cells matched this profile "
233
+ "(samples may be suppressed; broaden the profile).")
234
+
235
+ return Dashboard(
236
+ profile=profile,
237
+ cells=cells,
238
+ forecast_pct_range=forecast_range,
239
+ user_percentile=user_pct,
240
+ gap_to_median=gap,
241
+ projected_negotiation_target=projected_target,
242
+ notes=notes,
243
+ )
src/backend/indexing/ida_transform.py ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Transform the verified flat ``salary_records.jsonl`` into the structured
2
+ ``IdaSalaryRecord`` schema (the recoverable tables).
3
+
4
+ The original extraction (deterministic ``pdfplumber``, 0/7,000 digit mismatches)
5
+ captured each cell's numbers, cohort, sector and the raw ``segment_label``. For
6
+ the tables where the second breakdown axis survived in ``segment_label``
7
+ (education in the title, specialization t8, work_area t9, branche t10, position
8
+ t13, jobløn t16), we can map straight into the typed wildcard schema here — no
9
+ re-parsing. The tables whose second axis was flattened away (region t11, gender×
10
+ leadership t17, public position t27, management t14) are handled separately by
11
+ re-extraction and are skipped here.
12
+
13
+ WILDCARD CONTRACT: every filterable dimension is set to a specific enum value or
14
+ the sentinel ``"all"`` (aggregate) — never null.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+
21
+ from ..schemas import IdaSalaryRecord, Measure
22
+
23
+ # Tables re-extracted from the PDF (their discriminating axis was lost in the
24
+ # flat extraction) — skip them here to avoid emitting wrong/aggregate-only cells.
25
+ REEXTRACT_TABLES = {11, 14, 17, 27}
26
+
27
+ # table_id → education_type (tables 3–7 break down a single education by cohort).
28
+ EDUCATION_BY_TABLE = {
29
+ 3: "akademiingenior",
30
+ 4: "civilingenior",
31
+ 5: "teknikumingenior",
32
+ 6: "diplom_eksportingenior",
33
+ 7: "cand_scient",
34
+ }
35
+
36
+ # Ordinal rank for "next level up" framing; null when position_level == "all".
37
+ POSITION_RANK = {
38
+ "trainee": 1, "anden": 2, "ingenior_akademiker": 3, "specialist": 4,
39
+ "chef_seniorkonsulent": 5, "projektleder": 6, "afdelingschef": 7,
40
+ "funktionsdirektor": 8, "topchef": 9,
41
+ }
42
+
43
+ # management_span_band → (span_min, span_max). Used only by re-extraction (t14);
44
+ # included for completeness so the transform stays the single source of truth.
45
+ SPAN_BOUNDS = {
46
+ "none": (None, None), "1-4": (1, 4), "5-10": (5, 10), "11-19": (11, 19),
47
+ "20-29": (20, 29), "30-50": (30, 50), "51-100": (51, 100),
48
+ "101-200": (101, 200), "201-500": (201, 500), "501-1000": (501, 1000),
49
+ "1001+": (1001, None), "all": (None, None),
50
+ }
51
+
52
+ # data_period_month by sector (private snapshot Sept 2025, public May 2025).
53
+ PERIOD_MONTH = {
54
+ "private": "2025-09", "public_state": "2025-05",
55
+ "public_municipal": "2025-05", "public_regional": "2025-05",
56
+ "self_employed": None,
57
+ }
58
+
59
+
60
+ def _fold(s: str) -> str:
61
+ """Normalize a Danish label for matching: strip count prefix, fold æøå, keep alnum."""
62
+ s = re.sub(r"^\d+\s+", "", s.strip()) # '21 Alle' → 'Alle', '17 Org…' → 'Org…'
63
+ s = s.lower().replace("æ", "ae").replace("ø", "oe").replace("å", "aa")
64
+ return re.sub(r"[^a-z0-9]", "", s)
65
+
66
+
67
+ def _is_all(label: str | None) -> bool:
68
+ """True when the label is the aggregate ('Alle' / 'All' / empty)."""
69
+ return label is None or _fold(label) in {"alle", "all", ""}
70
+
71
+
72
+ # Normalized-label → enum maps for the recoverable segment dimensions.
73
+ _SPECIALIZATION = {
74
+ "byggerianlaeg": "byggeri_anlaeg",
75
+ "datalogiitsoftware": "datalogi_it_software",
76
+ "elektroniksvagstaerkstroem": "elektronik",
77
+ "kemibiologibiotek": "kemi_biologi_biotek",
78
+ "maskinindustriproduktionrobotmv": "maskin_industri_robot",
79
+ "energimiljoe": "energi_miljo",
80
+ "eksportledelseforretningsudvikling": "eksport_ledelse_forretning",
81
+ "levnedsmiddel": "levnedsmiddel",
82
+ "matematikfysikgeologi": "matematik_fysik_geologi",
83
+ }
84
+ _WORK_AREA = {
85
+ "forskninginnovationundervisning": "forskning_innovation_undervisning",
86
+ "byggerianlaeg": "byggeri_anlaeg",
87
+ "industriproduktionkemi": "industri_produktion_kemi",
88
+ "elektroteknikogtele": "elektroteknik_tele",
89
+ "itogprogrammering": "it_programmering",
90
+ "energiforsyningmiljoe": "energi_forsyning_miljo",
91
+ "transportinfrastruktur": "transport_infrastruktur",
92
+ "naturvidenskabmedicin": "naturvidenskab_medicin",
93
+ "administrationledelseservice": "administration_ledelse_service",
94
+ }
95
+ _BRANCHE = {
96
+ "raastofindvindingmv": "raastofindvinding",
97
+ "forsyningsvirksomhed": "forsyningsvirksomhed",
98
+ "foedevarekemiplasttraeindustri": "foedevare_kemi_plast_trae",
99
+ "medicinalindustri": "medicinalindustri",
100
+ "maskinjernogmetalindustri": "maskin_jern_metal",
101
+ "denelektroniskeindustri": "elektronisk_industri",
102
+ "raadgivendeingenioervirksomhed": "raadgivende_ingenior",
103
+ "entreprenoervirksomhedbyggeoganlaeg": "entreprenor_bygge_anlaeg",
104
+ "itogtelekommunikationsvirksomhed": "it_telekommunikation",
105
+ "forskningogudviklingfinansmv": "forskning_udvikling_finans",
106
+ "handeltransport": "handel_transport",
107
+ "organisationermv": "organisationer",
108
+ }
109
+
110
+
111
+ def _lookup(label: str, table: dict[str, str], dim: str) -> str:
112
+ """Map a raw segment label to its enum value; fail loud on vocabulary drift."""
113
+ if _is_all(label):
114
+ return "all"
115
+ key = _fold(label)
116
+ if key not in table:
117
+ raise ValueError(f"Unmapped {dim} label {label!r} (folded {key!r})")
118
+ return table[key]
119
+
120
+
121
+ def _position_to_enum(label: str) -> str:
122
+ """Map a tabel-13 position label to the position_level enum (keyword-ordered)."""
123
+ if _is_all(label):
124
+ return "all"
125
+ low = label.lower()
126
+ if "topchef" in low or "funktionsdirekt" in low:
127
+ return "topchef"
128
+ if "afdelingschef" in low:
129
+ return "afdelingschef"
130
+ if "projektleder" in low:
131
+ return "projektleder"
132
+ if "seniorkonsulent" in low or low.startswith("chef"):
133
+ return "chef_seniorkonsulent"
134
+ if "specialist" in low:
135
+ return "specialist"
136
+ if "trainee" in low or "erhvervsph" in low:
137
+ return "trainee"
138
+ if "anden" in low:
139
+ return "anden"
140
+ if "ingeniør" in low or "akademisk" in low or "konsulent" in low:
141
+ return "ingenior_akademiker"
142
+ raise ValueError(f"Unmapped position label {label!r}")
143
+
144
+
145
+ def reliability_tier(n: float | None) -> str:
146
+ """KRL suppression rule on sample size."""
147
+ if n is None or n < 5:
148
+ return "suppressed"
149
+ if n < 10:
150
+ return "mean_only"
151
+ if n < 20:
152
+ return "median_ok"
153
+ if n < 50:
154
+ return "quartiles_ok"
155
+ return "full"
156
+
157
+
158
+ def _includes_executives(title: str | None) -> bool | None:
159
+ """Read inkl./ekskl. topchefer from the caption; None when not stated."""
160
+ if not title:
161
+ return None
162
+ low = title.lower()
163
+ if "topchef" not in low:
164
+ return None
165
+ if "ekskl" in low or "uden topchef" in low:
166
+ return False
167
+ if "inkl" in low or "med topchef" in low:
168
+ return True
169
+ return None
170
+
171
+
172
+ def _corrected_cohort(rec: dict) -> tuple[int | None, int | None]:
173
+ """Return (graduation_year_start, end) with the 'Før YYYY' convention fixed.
174
+
175
+ The source extraction stored open-ended-below cohorts backwards (e.g.
176
+ 'Før 1985' → start=1985, end=None). The contract is half-open the other way:
177
+ 'Før YYYY' = graduated in or before YYYY-1, i.e. start=None, end=YYYY-1.
178
+ Bounded cohorts ('1986-1987') and 'Alle' (None/None) are already correct.
179
+ """
180
+ label = (rec.get("row_label") or "").lower()
181
+ m = re.search(r"(?:før|foer)\s*(\d{4})", label)
182
+ if m:
183
+ return None, int(m.group(1)) - 1
184
+ return rec.get("graduation_year_start"), rec.get("graduation_year_end")
185
+
186
+
187
+ def transform_record(rec: dict) -> IdaSalaryRecord | None:
188
+ """Map one flat record into an ``IdaSalaryRecord``; None if it should be skipped."""
189
+ tid = rec.get("table_id")
190
+ if tid in REEXTRACT_TABLES or tid is None:
191
+ return None # handled by re-extraction (or an orphan fragment)
192
+
193
+ sector = rec["sector"]
194
+ seg = rec.get("segment_label")
195
+ dims: list[str] = []
196
+
197
+ education_type = "all"
198
+ position_level = "all"
199
+ specialization = "all"
200
+ work_area = "all"
201
+ branche = "all"
202
+ jobloen = "all"
203
+
204
+ if tid in EDUCATION_BY_TABLE:
205
+ education_type = EDUCATION_BY_TABLE[tid]
206
+ dims.append("education_type")
207
+ elif tid == 8:
208
+ specialization = _lookup(seg, _SPECIALIZATION, "specialization")
209
+ if specialization != "all":
210
+ dims.append("specialization")
211
+ elif tid == 9:
212
+ work_area = _lookup(seg, _WORK_AREA, "work_area")
213
+ if work_area != "all":
214
+ dims.append("work_area")
215
+ elif tid == 10:
216
+ branche = _lookup(seg, _BRANCHE, "branche")
217
+ if branche != "all":
218
+ dims.append("branche")
219
+ elif tid == 13:
220
+ position_level = _position_to_enum(seg)
221
+ if position_level != "all":
222
+ dims.append("position_level")
223
+ elif tid == 16:
224
+ jobloen = "yes" # the whole table is jobløn-paid engineers
225
+ dims.append("jobloen")
226
+ # else: public cohort/løntrin tables (26,28,29,32,33,34,35,38,39) and
227
+ # self-employed (42) — sector is their only typed axis (+ cohort below).
228
+
229
+ gy_start, gy_end = _corrected_cohort(rec)
230
+ if gy_start is not None or gy_end is not None:
231
+ dims.append("graduation_year")
232
+ # Recompute experience from corrected bounds (2025 snapshot proxy).
233
+ exp_min = 2025 - gy_end if gy_end is not None else None
234
+ exp_max = 2025 - gy_start if gy_start is not None else None
235
+
236
+ n = rec.get("measure", {}).get("count")
237
+ record_type = "self_employed_income" if sector == "self_employed" else "salary_observation"
238
+
239
+ out = IdaSalaryRecord(
240
+ id=rec["id"],
241
+ source_doc=rec["source_doc"],
242
+ source_page=rec["source_page"],
243
+ table_id=tid,
244
+ table_title=rec.get("table_title"),
245
+ table_group=rec.get("table_group"),
246
+ record_type=record_type,
247
+ sector=sector,
248
+ period=rec.get("period"),
249
+ data_period_month=PERIOD_MONTH.get(sector),
250
+ pay_concept=rec["pay_concept"],
251
+ includes_executives=_includes_executives(rec.get("table_title")),
252
+ education_type=education_type,
253
+ position_level=position_level,
254
+ position_level_rank=POSITION_RANK.get(position_level),
255
+ specialization=specialization,
256
+ work_area=work_area,
257
+ branche=branche,
258
+ jobloen=jobloen,
259
+ row_dimension=rec.get("row_dimension"),
260
+ row_label=rec.get("row_label"),
261
+ segment_dimension=rec.get("segment_dimension"),
262
+ segment_label=seg,
263
+ graduation_year_start=gy_start,
264
+ graduation_year_end=gy_end,
265
+ experience_years_min=exp_min,
266
+ experience_years_max=exp_max,
267
+ age_min=rec.get("age_min"),
268
+ age_max=rec.get("age_max"),
269
+ dimension_keys=dims,
270
+ specificity=len(dims),
271
+ sample_size=n,
272
+ reliability_tier=reliability_tier(n),
273
+ measure=Measure(**rec.get("measure", {})),
274
+ rag_text="", # filled by the orchestrator's make_rag_text()
275
+ )
276
+ return out
277
+
278
+
279
+ def transform_corpus(records: list[dict]) -> list[IdaSalaryRecord]:
280
+ """Transform all recoverable IDA records (skips re-extracted tables)."""
281
+ out: list[IdaSalaryRecord] = []
282
+ for rec in records:
283
+ if rec.get("union") != "IDA":
284
+ continue
285
+ t = transform_record(rec)
286
+ if t is not None:
287
+ out.append(t)
288
+ return out
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # rag_text generation (English; embedded for vector recall). Per the contract,
293
+ # rag_text MUST name the human-readable value of every dimension_keys entry,
294
+ # carry the reliability caveat, and cite the source.
295
+ # ---------------------------------------------------------------------------
296
+
297
+ _DISPLAY: dict[str, dict[str, str]] = {
298
+ "education_type": {
299
+ "civilingenior": "Civil engineers (civilingeniører)",
300
+ "akademiingenior": "Academy engineers (akademiingeniører)",
301
+ "teknikumingenior": "Technical engineers (teknikumingeniører)",
302
+ "diplom_eksportingenior": "Diploma/export engineers",
303
+ "cand_scient": "Science graduates (cand.scient.)",
304
+ },
305
+ "sector": {
306
+ "private": "private sector", "public_state": "state sector",
307
+ "public_municipal": "municipal sector", "public_regional": "regional sector",
308
+ "self_employed": "self-employed",
309
+ },
310
+ "region": {
311
+ "hovedstaden": "Capital Region", "sjaelland": "Region Zealand",
312
+ "syddanmark": "Region of Southern Denmark", "midtjylland": "Central Denmark Region",
313
+ "nordjylland": "North Denmark Region",
314
+ },
315
+ "position_level": {
316
+ "topchef": "top executives", "funktionsdirektor": "functional directors",
317
+ "afdelingschef": "department heads/team leaders", "projektleder": "project managers",
318
+ "chef_seniorkonsulent": "chief/senior consultants", "specialist": "specialists",
319
+ "ingenior_akademiker": "engineers/academic staff", "anden": "other roles",
320
+ "trainee": "trainees",
321
+ },
322
+ "gender": {"male": "men", "female": "women"},
323
+ "is_leader": {"leader": "with staff responsibility", "non_leader": "without staff responsibility"},
324
+ "manages_people": {"yes": "managing people", "no": "not managing people"},
325
+ "jobloen": {"yes": "on fixed salary (jobløn)", "no": "not on jobløn"},
326
+ }
327
+
328
+ _TIER_CAVEAT = {
329
+ "suppressed": "Sample too small to report figures (n<5).",
330
+ "mean_only": "Small sample (n=5–9): average only.",
331
+ "median_ok": "Limited sample (n=10–19): median shown, no quartiles.",
332
+ "quartiles_ok": "Sample n=20–49: quartiles shown, no 90th percentile.",
333
+ "full": "Full reliability (n≥50).",
334
+ }
335
+
336
+
337
+ def _cohort_phrase(rec: IdaSalaryRecord) -> str:
338
+ s, e = rec.graduation_year_start, rec.graduation_year_end
339
+ if s is None and e is None:
340
+ return ""
341
+ if s is None:
342
+ return f"graduated in or before {e}"
343
+ if e is None:
344
+ return f"graduated {s} or later"
345
+ return f"graduation cohort {s}–{e}" if s != e else f"graduated {s}"
346
+
347
+
348
+ def _dim_phrases(rec: IdaSalaryRecord) -> list[str]:
349
+ """Human-readable phrase for each specific dimension key."""
350
+ out: list[str] = []
351
+ for key in rec.dimension_keys:
352
+ if key == "graduation_year":
353
+ p = _cohort_phrase(rec)
354
+ if p:
355
+ out.append(p)
356
+ continue
357
+ val = getattr(rec, key, "all")
358
+ disp = _DISPLAY.get(key, {}).get(val)
359
+ # specialization/work_area/branche keep their raw Danish label (readable).
360
+ if disp is None:
361
+ disp = (rec.segment_label or val) if key in (
362
+ "specialization", "work_area", "branche") else val
363
+ out.append(str(disp))
364
+ return out
365
+
366
+
367
+ def _stats_phrase(rec: IdaSalaryRecord) -> str:
368
+ """Figures permitted by the reliability tier, in DKK/month."""
369
+ m, tier = rec.measure, rec.reliability_tier
370
+ if tier == "suppressed":
371
+ return ""
372
+ parts: list[str] = []
373
+ if m.mean is not None:
374
+ parts.append(f"average {int(m.mean):,} DKK/month")
375
+ if tier in ("median_ok", "quartiles_ok", "full") and m.median is not None:
376
+ parts.append(f"median {int(m.median):,}")
377
+ if tier in ("quartiles_ok", "full") and m.p25 is not None and m.p75 is not None:
378
+ parts.append(f"quartiles {int(m.p25):,}–{int(m.p75):,}")
379
+ if tier == "full" and m.p90 is not None:
380
+ parts.append(f"90th pct {int(m.p90):,}")
381
+ return ", ".join(parts).replace(",", ".") # Danish thousands separator
382
+
383
+
384
+ def make_rag_text(rec: IdaSalaryRecord) -> str:
385
+ """Build the embedding text for a record (English, dimension-complete)."""
386
+ edu = _DISPLAY["education_type"].get(rec.education_type, "IDA engineers")
387
+ head = edu if rec.education_type != "all" else "IDA members"
388
+ sector = _DISPLAY["sector"].get(rec.sector, rec.sector)
389
+ dims = [d for d in _dim_phrases(rec) if d and d != head]
390
+ seg = f" — {', '.join(dims)}" if dims else ""
391
+
392
+ if rec.record_type == "salary_growth_rate":
393
+ body = f"3-year gliding annual salary growth {rec.cohort_growth_pct}% ({sector})."
394
+ elif rec.record_type == "salary_forecast":
395
+ body = f"2026 salary forecast {rec.cohort_growth_pct}% expected pay increase ({sector})."
396
+ else:
397
+ stats = _stats_phrase(rec)
398
+ n = int(rec.sample_size) if rec.sample_size is not None else "n/a"
399
+ body = (f"{stats}. Based on {n} respondents."
400
+ if stats else f"Based on {n} respondents.")
401
+
402
+ caveat = _TIER_CAVEAT.get(rec.reliability_tier, "")
403
+ tbl = f", tabel {rec.table_id}" if rec.table_id else ""
404
+ cite = f"Source: {rec.source_doc}{tbl}, p.{rec.source_page}."
405
+ return f"IDA Lønstatistik 2025 — {head}, {sector}{seg}. {body} {caveat} {cite}".strip()
src/backend/retrieval.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Profile → IDA cell matching, ranking, and reliability gating (v1).
2
+
3
+ The brief's pipeline, in order:
4
+ 1. wildcard pre-filter (record_type → sector → remaining dimensions),
5
+ 2. (optional) vector search within the candidate set,
6
+ 3. specificity ranking (prefer the most specific cell consistent with the
7
+ profile; fall back to broader cells when the specific one is suppressed),
8
+ 4. reliability gating (only render the measure.* a cell's tier permits).
9
+
10
+ This module is deterministic and CPU-only — it operates on the structured
11
+ records loaded from ``ida_records.jsonl``. Vector search is an optional recall
12
+ backstop for free-text queries; the profile-driven dashboard does not need it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from pathlib import Path
19
+
20
+ from .config import REPO_ROOT
21
+ from .schemas import IdaSalaryRecord, Measure, Profile
22
+
23
+ _RECORDS_PATH = REPO_ROOT / "data" / "processed" / "lonstatistik" / "ida_records.jsonl"
24
+ _records: list[IdaSalaryRecord] | None = None
25
+
26
+ # Dimensions the onboarding profile actually constrains. specialization /
27
+ # work_area / branche are not asked (so never constrained → match any cell);
28
+ # stem_count_band and management_span are captured but DO NOT filter in v1.
29
+ _PROFILE_FILTER_DIMS = ("education_type", "region", "position_level", "gender",
30
+ "manages_people")
31
+
32
+
33
+ def load_records(path: Path | None = None) -> list[IdaSalaryRecord]:
34
+ """Load the structured IDA corpus once (lazy singleton)."""
35
+ global _records
36
+ if _records is None or path is not None:
37
+ p = path or _RECORDS_PATH
38
+ recs = [
39
+ IdaSalaryRecord(**json.loads(line))
40
+ for line in p.read_text(encoding="utf-8").splitlines()
41
+ if line.strip()
42
+ ]
43
+ if path is not None:
44
+ return recs
45
+ _records = recs
46
+ return _records
47
+
48
+
49
+ def _profile_constraints(profile: Profile) -> dict[str, str]:
50
+ """The dimensions the profile pins to a specific value (drops 'all'/None)."""
51
+ raw = {
52
+ "education_type": profile.education_type,
53
+ "region": profile.region,
54
+ "position_level": profile.position_level,
55
+ "gender": profile.gender,
56
+ "manages_people": profile.manages_people,
57
+ }
58
+ return {
59
+ d: v for d, v in raw.items()
60
+ if d in _PROFILE_FILTER_DIMS and v is not None and v != "all"
61
+ }
62
+
63
+
64
+ def matches(rec: IdaSalaryRecord, profile: Profile) -> bool:
65
+ """Wildcard match on the remaining dimensions + graduation-year interval.
66
+
67
+ (record_type and sector are pre-filtered by the caller.) A dimension matches
68
+ iff the record's value equals the profile's OR is the ``"all"`` aggregate.
69
+ Dimensions the profile leaves open are not constrained.
70
+ """
71
+ for dim, pval in _profile_constraints(profile).items():
72
+ rval = getattr(rec, dim)
73
+ if rval != pval and rval != "all":
74
+ return False
75
+ # graduation_year by interval (half-open; null = unbounded on that side).
76
+ y = profile.graduation_year
77
+ s, e = rec.graduation_year_start, rec.graduation_year_end
78
+ if (s is not None and y < s) or (e is not None and y > e):
79
+ return False
80
+ return True
81
+
82
+
83
+ def _renderable_fields(tier: str) -> set[str]:
84
+ """Which measure.* fields a reliability tier permits rendering."""
85
+ if tier == "suppressed":
86
+ return set()
87
+ fields = {"mean", "base", "supplement", "pension", "bonus_avg", "bonus_pct", "count"}
88
+ if tier in ("median_ok", "quartiles_ok", "full"):
89
+ fields.add("median")
90
+ if tier in ("quartiles_ok", "full"):
91
+ fields |= {"p25", "p75"}
92
+ if tier == "full":
93
+ fields.add("p90")
94
+ return fields
95
+
96
+
97
+ def gated_measure(rec: IdaSalaryRecord) -> Measure:
98
+ """Return a Measure with only the fields the record's tier permits."""
99
+ allowed = _renderable_fields(rec.reliability_tier)
100
+ kept = {
101
+ f: getattr(rec.measure, f)
102
+ for f in allowed
103
+ if getattr(rec.measure, f) is not None
104
+ }
105
+ return Measure(**kept)
106
+
107
+
108
+ def _rank_key(rec: IdaSalaryRecord) -> tuple:
109
+ """Most specific first; among equals prefer larger, more reliable samples."""
110
+ return (rec.specificity, rec.sample_size or 0)
111
+
112
+
113
+ def retrieve(
114
+ profile: Profile,
115
+ record_type: str = "salary_observation",
116
+ sectors: tuple[str, ...] | None = None,
117
+ ) -> list[IdaSalaryRecord]:
118
+ """Hard pre-filter (record_type → sector → dims) then specificity ranking.
119
+
120
+ Returns all matching records, most-specific first. Suppressed cells are
121
+ retained so the fusion layer can fall back to broader cells when a specific
122
+ one is suppressed.
123
+ """
124
+ allow_sectors = sectors or (profile.sector, "all")
125
+ out = [
126
+ r for r in load_records()
127
+ if r.record_type == record_type
128
+ and r.sector in allow_sectors
129
+ and matches(r, profile)
130
+ ]
131
+ out.sort(key=_rank_key, reverse=True)
132
+ return out
src/backend/schemas.py CHANGED
@@ -7,6 +7,8 @@ implementations have a stable contract to build against.
7
 
8
  from __future__ import annotations
9
 
 
 
10
  from pydantic import BaseModel, Field
11
 
12
 
@@ -74,3 +76,209 @@ class ChatResponse(BaseModel):
74
  "This is general information, not legal advice. "
75
  "Consult a qualified professional for your situation."
76
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  from __future__ import annotations
9
 
10
+ from typing import Literal, Optional
11
+
12
  from pydantic import BaseModel, Field
13
 
14
 
 
76
  "This is general information, not legal advice. "
77
  "Consult a qualified professional for your situation."
78
  )
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # IDA salary corpus (v1) — structured, profile-matchable records.
83
+ #
84
+ # These types mirror data/processed/lonstatistik/ida_salary_record.schema.json,
85
+ # the authoritative contract. That JSON Schema is the source of truth and every
86
+ # built record is validated against it; these Literals give the transform/match
87
+ # code type safety for the dimensions we actually filter on. WILDCARD CONTRACT:
88
+ # every filterable dimension is a required enum that includes "all" (the only
89
+ # wildcard). Profiles never carry "all" — a skipped answer simply drops the
90
+ # constraint (which then matches "all" cells and any specific cell).
91
+ # ---------------------------------------------------------------------------
92
+
93
+ RecordType = Literal[
94
+ "salary_observation", "salary_growth_rate", "salary_forecast",
95
+ "minimum_wage_recommendation", "benefit_prevalence", "terms_stat",
96
+ "self_employed_income",
97
+ ]
98
+ Sector = Literal[
99
+ "private", "public_state", "public_municipal", "public_regional",
100
+ "self_employed", "all",
101
+ ]
102
+ EducationType = Literal[
103
+ "civilingenior", "akademiingenior", "diplom_eksportingenior",
104
+ "teknikumingenior", "cand_scient", "all",
105
+ ]
106
+ Region = Literal[
107
+ "hovedstaden", "sjaelland", "syddanmark", "midtjylland", "nordjylland", "all",
108
+ ]
109
+ PositionLevel = Literal[
110
+ "topchef", "funktionsdirektor", "afdelingschef", "projektleder",
111
+ "chef_seniorkonsulent", "specialist", "ingenior_akademiker", "anden",
112
+ "trainee", "all",
113
+ ]
114
+ Gender = Literal["male", "female", "all"]
115
+ IsLeader = Literal["leader", "non_leader", "all"]
116
+ YesNoAll = Literal["yes", "no", "all"]
117
+ ManagementSpanBand = Literal[
118
+ "none", "1-4", "5-10", "11-19", "20-29", "30-50", "51-100", "101-200",
119
+ "201-500", "501-1000", "1001+", "all",
120
+ ]
121
+ StemCountBand = Literal["1-9", "10-24", "25-99", "100-249", "250+", "all"]
122
+ ReliabilityTier = Literal[
123
+ "suppressed", "mean_only", "median_ok", "quartiles_ok", "full",
124
+ ]
125
+ DimensionKey = Literal[
126
+ "sector", "education_type", "region", "position_level", "gender",
127
+ "is_leader", "manages_people", "management_span_band", "stem_count_band",
128
+ "specialization", "work_area", "branche", "jobloen", "graduation_year",
129
+ ]
130
+
131
+ # The filterable dimension fields (in the brief's pre-filter order: record_type
132
+ # and sector are applied first, separately). Used by the matcher/transform.
133
+ FILTER_DIMENSIONS: tuple[str, ...] = (
134
+ "education_type", "region", "position_level", "gender", "is_leader",
135
+ "manages_people", "stem_count_band", "specialization", "work_area",
136
+ "branche", "jobloen",
137
+ )
138
+
139
+
140
+ class Measure(BaseModel):
141
+ """Reported statistics. All monetary values DKK; bonus_pct a percentage."""
142
+
143
+ count: float | None = None
144
+ mean: float | None = None
145
+ p25: float | None = None
146
+ median: float | None = None
147
+ p75: float | None = None
148
+ p90: float | None = None
149
+ base: float | None = None
150
+ supplement: float | None = None
151
+ pension: float | None = None
152
+ bonus_avg: float | None = None
153
+ bonus_pct: float | None = None
154
+
155
+
156
+ class IdaSalaryRecord(BaseModel):
157
+ """One structured IDA observation (mirrors the JSON Schema contract).
158
+
159
+ Built by the transform/re-extraction, validated against the JSON Schema,
160
+ then embedded. Every filterable dimension defaults to ``"all"`` so a missed
161
+ backfill is a visible aggregation, never a hidden null.
162
+ """
163
+
164
+ id: str
165
+ union: Literal["IDA"] = "IDA"
166
+ source_doc: str
167
+ source_page: int
168
+ table_id: int | None = None
169
+ table_title: str | None = None
170
+ table_group: str | None = None
171
+ record_type: RecordType = "salary_observation"
172
+ sector: Sector
173
+ period: str | None = None
174
+ data_period_month: str | None = None
175
+ currency: Literal["DKK"] = "DKK"
176
+ pay_concept: Literal[
177
+ "gross_monthly", "gross_monthly_with_components", "net_monthly",
178
+ "annual_income",
179
+ ]
180
+ includes_executives: bool | None = None
181
+ # Filterable dimensions (required, "all" = aggregate / wildcard).
182
+ education_type: EducationType = "all"
183
+ region: Region = "all"
184
+ position_level: PositionLevel = "all"
185
+ position_level_rank: int | None = None
186
+ gender: Gender = "all"
187
+ is_leader: IsLeader = "all"
188
+ manages_people: YesNoAll = "all"
189
+ management_span_band: ManagementSpanBand = "all"
190
+ span_min: int | None = None
191
+ span_max: int | None = None
192
+ stem_count_band: StemCountBand = "all"
193
+ specialization: str = "all"
194
+ work_area: str = "all"
195
+ branche: str = "all"
196
+ jobloen: YesNoAll = "all"
197
+ # Provenance (display only).
198
+ row_dimension: str | None = None
199
+ row_label: str | None = None
200
+ segment_dimension: str | None = None
201
+ segment_label: str | None = None
202
+ graduation_year_start: int | None = None
203
+ graduation_year_end: int | None = None
204
+ experience_years_min: int | None = None
205
+ experience_years_max: int | None = None
206
+ age_min: int | None = None
207
+ age_max: int | None = None
208
+ cohort_growth_pct: float | None = None
209
+ # Retrieval helpers.
210
+ dimension_keys: list[DimensionKey] = Field(default_factory=list)
211
+ specificity: int = 0
212
+ sample_size: float | None = None
213
+ reliability_tier: ReliabilityTier
214
+ measure: Measure
215
+ rag_text: str
216
+
217
+
218
+ class Profile(BaseModel):
219
+ """User profile built from the 9 onboarding answers.
220
+
221
+ Maps to hard filters except where noted. ``graduation_year`` matches cohort
222
+ intervals (not equality). ``gender`` is skippable: ``None`` drops the
223
+ constraint. ``current_monthly_gross`` is *input*, not a filter — normalised
224
+ to IDA bruttoløn (base + permanent/temporary tillæg + fritvalg + taxable
225
+ perks + own and employer pension incl. ATP). ``stem_count_band`` /
226
+ ``management_span_band`` are captured but do not filter in v1 (no IDA data);
227
+ ``total_headcount_band`` is captured and unused.
228
+ """
229
+
230
+ education_type: EducationType # Q1
231
+ graduation_year: int # Q2 (→ cohort interval; experience = 2025 − year)
232
+ sector: Sector # Q3
233
+ gender: Optional[Literal["male", "female"]] = None # Q4 (skippable)
234
+ current_monthly_gross: int | None = None # Q5 (input, not a filter)
235
+ current_monthly_gross_max: int | None = None # Q5 upper bound if a range
236
+ position_level: PositionLevel = "all" # Q6
237
+ manages_people: Literal["yes", "no"] | None = None # Q7
238
+ management_span_band: ManagementSpanBand | None = None # Q7 (captured, unused v1)
239
+ stem_count_band: StemCountBand | None = None # Q8a (captured, unused v1)
240
+ region: Region = "all" # Q8 (region)
241
+ total_headcount_band: str | None = None # Q8b (captured, unused v1)
242
+
243
+ @property
244
+ def experience_years(self) -> int:
245
+ """Seniority proxy used for display (snapshot year is 2025)."""
246
+ return 2025 - self.graduation_year
247
+
248
+
249
+ class RecommendationCell(BaseModel):
250
+ """One fused comparison cell rendered to the dashboard.
251
+
252
+ Only the ``measure`` fields permitted by ``reliability_tier`` are populated;
253
+ forbidden statistics are left ``None`` (never rendered).
254
+ """
255
+
256
+ comparison: str # human label, e.g. "Civilingeniører, your cohort, private"
257
+ citation: str # e.g. "IDA Lønstatistik 2025, tabel 4, p.11"
258
+ record_id: str
259
+ dimension_keys: list[DimensionKey] = Field(default_factory=list)
260
+ specificity: int = 0
261
+ sample_size: float | None = None
262
+ reliability_tier: ReliabilityTier = "suppressed"
263
+ includes_executives: bool | None = None
264
+ measure: Measure = Field(default_factory=Measure)
265
+ # 2026 projection of the cell's central figure (median or mean), if computed.
266
+ projected_median_2026: float | None = None
267
+ growth_pct_applied: float | None = None
268
+
269
+
270
+ class Dashboard(BaseModel):
271
+ """Profile-driven recommendation dashboard (v1, salary path)."""
272
+
273
+ profile: Profile
274
+ cells: list[RecommendationCell] = Field(default_factory=list)
275
+ forecast_pct_range: tuple[float, float] | None = None # e.g. (3.2, 3.5)
276
+ user_percentile: float | None = None # position within the best cell
277
+ gap_to_median: float | None = None # user gross − cell median (DKK)
278
+ projected_negotiation_target: float | None = None # 2026-projected ask
279
+ notes: list[str] = Field(default_factory=list)
280
+ disclaimer: str = (
281
+ "Salary figures are statistical benchmarks, not guarantees. "
282
+ "Means are not comparable across inkl./ekskl. topchefer. "
283
+ "This is general information, not legal or financial advice."
284
+ )
uv.lock CHANGED
@@ -335,6 +335,63 @@ wheels = [
335
  { url = "https://download.pytorch.org/whl/certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18", upload-time = "2023-10-03T17:33:19Z" },
336
  ]
337
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  [[package]]
339
  name = "charset-normalizer"
340
  version = "2.1.1"
@@ -363,6 +420,56 @@ wheels = [
363
  { url = "https://download.pytorch.org/whl/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", upload-time = "2023-10-05T23:50:34Z" },
364
  ]
365
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  [[package]]
367
  name = "faiss-cpu"
368
  version = "1.14.2"
@@ -743,6 +850,33 @@ wheels = [
743
  { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
744
  ]
745
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
746
  [[package]]
747
  name = "markdown-it-py"
748
  version = "4.2.0"
@@ -1229,7 +1363,9 @@ dependencies = [
1229
 
1230
  [package.dev-dependencies]
1231
  dev = [
 
1232
  { name = "modal" },
 
1233
  ]
1234
 
1235
  [package.metadata]
@@ -1248,7 +1384,38 @@ requires-dist = [
1248
  ]
1249
 
1250
  [package.metadata.requires-dev]
1251
- dev = [{ name = "modal", specifier = ">=0.70" }]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1252
 
1253
  [[package]]
1254
  name = "pillow"
@@ -1456,6 +1623,15 @@ wheels = [
1456
  { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
1457
  ]
1458
 
 
 
 
 
 
 
 
 
 
1459
  [[package]]
1460
  name = "pydantic"
1461
  version = "2.13.4"
@@ -1564,6 +1740,35 @@ wheels = [
1564
  { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
1565
  ]
1566
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1567
  [[package]]
1568
  name = "python-dateutil"
1569
  version = "2.9.0.post0"
@@ -1640,6 +1845,20 @@ wheels = [
1640
  { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
1641
  ]
1642
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1643
  [[package]]
1644
  name = "regex"
1645
  version = "2026.5.9"
@@ -1755,6 +1974,116 @@ wheels = [
1755
  { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
1756
  ]
1757
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1758
  [[package]]
1759
  name = "safehttpx"
1760
  version = "0.1.7"
 
335
  { url = "https://download.pytorch.org/whl/certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18", upload-time = "2023-10-03T17:33:19Z" },
336
  ]
337
 
338
+ [[package]]
339
+ name = "cffi"
340
+ version = "2.0.0"
341
+ source = { registry = "https://pypi.org/simple" }
342
+ dependencies = [
343
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
344
+ ]
345
+ sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
346
+ wheels = [
347
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
348
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
349
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
350
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
351
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
352
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
353
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
354
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
355
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
356
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
357
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
358
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
359
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
360
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
361
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
362
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
363
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
364
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
365
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
366
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
367
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
368
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
369
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
370
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
371
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
372
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
373
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
374
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
375
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
376
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
377
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
378
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
379
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
380
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
381
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
382
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
383
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
384
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
385
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
386
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
387
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
388
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
389
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
390
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
391
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
392
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
393
+ ]
394
+
395
  [[package]]
396
  name = "charset-normalizer"
397
  version = "2.1.1"
 
420
  { url = "https://download.pytorch.org/whl/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", upload-time = "2023-10-05T23:50:34Z" },
421
  ]
422
 
423
+ [[package]]
424
+ name = "cryptography"
425
+ version = "49.0.0"
426
+ source = { registry = "https://pypi.org/simple" }
427
+ dependencies = [
428
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
429
+ ]
430
+ sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
431
+ wheels = [
432
+ { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
433
+ { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
434
+ { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
435
+ { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
436
+ { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
437
+ { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
438
+ { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
439
+ { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
440
+ { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
441
+ { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
442
+ { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
443
+ { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
444
+ { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
445
+ { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
446
+ { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
447
+ { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
448
+ { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
449
+ { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
450
+ { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
451
+ { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
452
+ { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
453
+ { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
454
+ { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
455
+ { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
456
+ { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
457
+ { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
458
+ { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
459
+ { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
460
+ { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
461
+ { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
462
+ { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
463
+ { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
464
+ { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
465
+ { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
466
+ { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
467
+ { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
468
+ { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
469
+ { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
470
+ { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
471
+ ]
472
+
473
  [[package]]
474
  name = "faiss-cpu"
475
  version = "1.14.2"
 
850
  { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
851
  ]
852
 
853
+ [[package]]
854
+ name = "jsonschema"
855
+ version = "4.26.0"
856
+ source = { registry = "https://pypi.org/simple" }
857
+ dependencies = [
858
+ { name = "attrs" },
859
+ { name = "jsonschema-specifications" },
860
+ { name = "referencing" },
861
+ { name = "rpds-py" },
862
+ ]
863
+ sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
864
+ wheels = [
865
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
866
+ ]
867
+
868
+ [[package]]
869
+ name = "jsonschema-specifications"
870
+ version = "2025.9.1"
871
+ source = { registry = "https://pypi.org/simple" }
872
+ dependencies = [
873
+ { name = "referencing" },
874
+ ]
875
+ sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
876
+ wheels = [
877
+ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
878
+ ]
879
+
880
  [[package]]
881
  name = "markdown-it-py"
882
  version = "4.2.0"
 
1363
 
1364
  [package.dev-dependencies]
1365
  dev = [
1366
+ { name = "jsonschema" },
1367
  { name = "modal" },
1368
+ { name = "pdfplumber" },
1369
  ]
1370
 
1371
  [package.metadata]
 
1384
  ]
1385
 
1386
  [package.metadata.requires-dev]
1387
+ dev = [
1388
+ { name = "jsonschema" },
1389
+ { name = "modal", specifier = ">=0.70" },
1390
+ { name = "pdfplumber" },
1391
+ ]
1392
+
1393
+ [[package]]
1394
+ name = "pdfminer-six"
1395
+ version = "20260107"
1396
+ source = { registry = "https://pypi.org/simple" }
1397
+ dependencies = [
1398
+ { name = "charset-normalizer" },
1399
+ { name = "cryptography" },
1400
+ ]
1401
+ sdist = { url = "https://files.pythonhosted.org/packages/34/a4/5cec1112009f0439a5ca6afa8ace321f0ab2f48da3255b7a1c8953014670/pdfminer_six-20260107.tar.gz", hash = "sha256:96bfd431e3577a55a0efd25676968ca4ce8fd5b53f14565f85716ff363889602", size = 8512094, upload-time = "2026-01-07T13:29:12.937Z" }
1402
+ wheels = [
1403
+ { url = "https://files.pythonhosted.org/packages/20/8b/28c4eaec9d6b036a52cb44720408f26b1a143ca9bce76cc19e8f5de00ab4/pdfminer_six-20260107-py3-none-any.whl", hash = "sha256:366585ba97e80dffa8f00cebe303d2f381884d8637af4ce422f1df3ef38111a9", size = 6592252, upload-time = "2026-01-07T13:29:10.742Z" },
1404
+ ]
1405
+
1406
+ [[package]]
1407
+ name = "pdfplumber"
1408
+ version = "0.11.10"
1409
+ source = { registry = "https://pypi.org/simple" }
1410
+ dependencies = [
1411
+ { name = "pdfminer-six" },
1412
+ { name = "pillow" },
1413
+ { name = "pypdfium2" },
1414
+ ]
1415
+ sdist = { url = "https://files.pythonhosted.org/packages/05/56/6f450312ba05a27d7713b73857c1a25100dbda04fbc1331b13fb227a607d/pdfplumber-0.11.10.tar.gz", hash = "sha256:b95b2d28c66efb0a794a83b88c6c6aea5987532a445d20a1cbcfa657022e6e57", size = 102892, upload-time = "2026-06-15T03:31:31.035Z" }
1416
+ wheels = [
1417
+ { url = "https://files.pythonhosted.org/packages/a2/9a/07d658e1e7fad860f1c541ab941348125dbdab773be3a0afaf32361866c7/pdfplumber-0.11.10-py3-none-any.whl", hash = "sha256:7741ea81bf165b474b153e6789d10d18e06b6ddcf3ec84289c3ef2fed6802580", size = 60047, upload-time = "2026-06-15T03:31:29.702Z" },
1418
+ ]
1419
 
1420
  [[package]]
1421
  name = "pillow"
 
1623
  { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
1624
  ]
1625
 
1626
+ [[package]]
1627
+ name = "pycparser"
1628
+ version = "3.0"
1629
+ source = { registry = "https://pypi.org/simple" }
1630
+ sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
1631
+ wheels = [
1632
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
1633
+ ]
1634
+
1635
  [[package]]
1636
  name = "pydantic"
1637
  version = "2.13.4"
 
1740
  { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
1741
  ]
1742
 
1743
+ [[package]]
1744
+ name = "pypdfium2"
1745
+ version = "5.10.1"
1746
+ source = { registry = "https://pypi.org/simple" }
1747
+ sdist = { url = "https://files.pythonhosted.org/packages/1d/78/d9b45abb97a3686643f7c6472a5f7688f2013a373226121dc76b9debbacf/pypdfium2-5.10.1.tar.gz", hash = "sha256:f257d2011eb43c846b7e9f5a802e28646b29732763e4a35dd6ca76f9be580538", size = 272963, upload-time = "2026-06-15T10:09:16.179Z" }
1748
+ wheels = [
1749
+ { url = "https://files.pythonhosted.org/packages/98/49/0b85fcc0d236582143a25cf275b0b4f5d786f51eb07a89ec9c79d43efe18/pypdfium2-5.10.1-py3-none-android_23_arm64_v8a.whl", hash = "sha256:13abf7a9f5e0ddebc8bbcccea5f13ae5abe8a298ea219e125b0fc24c1d2171b4", size = 3409176, upload-time = "2026-06-15T10:08:36.017Z" },
1750
+ { url = "https://files.pythonhosted.org/packages/20/d2/2f522c5b2ad5166edf256bb4dbab97de5d07b2573f8a5630ddfcd1f8d4ee/pypdfium2-5.10.1-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:a0dc52b56631e2f7edcdb22bae3b155aa840bec32c5bd05781e90baaecee88e7", size = 2866175, upload-time = "2026-06-15T10:08:37.955Z" },
1751
+ { url = "https://files.pythonhosted.org/packages/a7/be/477548c026c2badfdbf4afc3358b7135121fb5bec2e2effcb67e3a674d0b/pypdfium2-5.10.1-py3-none-macosx_12_0_arm64.whl", hash = "sha256:ebb9e63f92d15fc41b359fe7a187233dfae37548800e1fa09cb2fc466ac89951", size = 3621427, upload-time = "2026-06-15T10:08:39.969Z" },
1752
+ { url = "https://files.pythonhosted.org/packages/8c/9b/0131c7f711b62c6edd6b200e9eb6340be6de4f6dc5baae625e3394d8d5fb/pypdfium2-5.10.1-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:d04f2050b6b32bb18624688b600543342a4ab3aacf64bf66521a5af72bbc7de1", size = 3682825, upload-time = "2026-06-15T10:08:42.181Z" },
1753
+ { url = "https://files.pythonhosted.org/packages/9d/3d/bddfceb6e67e54d6dd1ab6c0f1feff796a89596e40f6345a3b4cc6a3d408/pypdfium2-5.10.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0de53d2710ca9509fc2812340acc57c3f043697609068592d87de654f8cabf44", size = 3682206, upload-time = "2026-06-15T10:08:44.087Z" },
1754
+ { url = "https://files.pythonhosted.org/packages/58/08/dedeb25c6645fc8a5eda24f54e0b1d083b7334ababf5f518bb939d729cc0/pypdfium2-5.10.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10a26ce04795f8ec079e81c707fcb5737061e8a78025babc3d6e36642e9c903a", size = 3413720, upload-time = "2026-06-15T10:08:45.739Z" },
1755
+ { url = "https://files.pythonhosted.org/packages/cc/48/69e1fc8b1216005243c6415183bbf6de1cda3f5a06758b3fa4a26a7385c6/pypdfium2-5.10.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be6d2a8d1bcfd777188e7aa55a25c83f34d54e8350ad8810fb32017787d9b0d9", size = 3812913, upload-time = "2026-06-15T10:08:47.45Z" },
1756
+ { url = "https://files.pythonhosted.org/packages/72/7f/132455a58ad736d76815c6cd1307532c3f433299d945b9d2f8cc2387c309/pypdfium2-5.10.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf4d2527f79f31c550490cc74c9f32e19385a944630a3ef4cd4d9b6f961fbf77", size = 4223220, upload-time = "2026-06-15T10:08:49.099Z" },
1757
+ { url = "https://files.pythonhosted.org/packages/bf/8c/4d5804eca598bbe894e0a9a510807e221c1623e7276ce6b68fa2660dc933/pypdfium2-5.10.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ba127750bc3f4161461538d532d74491cd976f584f1753a6cee9cb821338ec1", size = 3738950, upload-time = "2026-06-15T10:08:50.821Z" },
1758
+ { url = "https://files.pythonhosted.org/packages/d1/7f/baac59bf14ff914d97789ee0368c22ae233aa857aa9c0726bcb515dbc4d7/pypdfium2-5.10.1-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:80f30517ee089dfbbc6e9de6da365b6e8c0ce8f80c40b7d4025a3b1d3bbd8a70", size = 4029869, upload-time = "2026-06-15T10:08:52.494Z" },
1759
+ { url = "https://files.pythonhosted.org/packages/d0/d7/a5d58a0bcba31a0e37ed636a76ef3d2d215733f28af61637287d061b0c54/pypdfium2-5.10.1-py3-none-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ad5f5de15febb788c6eb3853e58aceda9cd8c5187c92472abaeecf9558deb0cf", size = 3990927, upload-time = "2026-06-15T10:08:54.555Z" },
1760
+ { url = "https://files.pythonhosted.org/packages/da/1a/98eebd14b36812176297cf765d504ebeebc982f895c8a3a9fbd2717797de/pypdfium2-5.10.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8947fa3cd808da33960bdf8ff9e5247aa94ccd94f0b09bb3402e99498d83bcc9", size = 4989624, upload-time = "2026-06-15T10:08:56.284Z" },
1761
+ { url = "https://files.pythonhosted.org/packages/1c/20/f2e124d607b8bb90a9f1ce976afff38c70e215cd7ea86af784cb2e8a19dd/pypdfium2-5.10.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:139a6387a3a2652f288e53164268bb03af4fa221d78484ee18407053a60082a3", size = 4535124, upload-time = "2026-06-15T10:08:58.214Z" },
1762
+ { url = "https://files.pythonhosted.org/packages/59/ef/469ea87f668a32ff3280ea15e522e3a7858d2c80f1ecc320ece1244624c9/pypdfium2-5.10.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:172ff3e10358d66456e27fb0b8b5098e28ec24e51072eb4b5b86077d550e21bb", size = 5229373, upload-time = "2026-06-15T10:09:00.702Z" },
1763
+ { url = "https://files.pythonhosted.org/packages/c3/48/c7ed3001f0c5e28114c98bf918c8121e422a65ba7323e1e12f3f28e2d278/pypdfium2-5.10.1-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:09fda0609dc4749c9865a0315c447d6f2583a580693bcabd69980d3fbb22ad51", size = 5140010, upload-time = "2026-06-15T10:09:02.734Z" },
1764
+ { url = "https://files.pythonhosted.org/packages/b2/bc/00b731bfc1fdc0f3c7d91108fceb54978ff4f5a92336820e5ed6c12535ff/pypdfium2-5.10.1-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:6b01adcfe9aaf7a635a59bed5687fd4fd7b0da292664f050d4ebd2bfa5c70584", size = 4643310, upload-time = "2026-06-15T10:09:04.794Z" },
1765
+ { url = "https://files.pythonhosted.org/packages/9a/e8/3ad242233f657c19092a8c83684c8154b8d02a826535a6a2591d91aa5dde/pypdfium2-5.10.1-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:610e14c37d2090b826dccc0604fc7e7612c0ce591190780ae228abdb9abb971e", size = 5087879, upload-time = "2026-06-15T10:09:06.861Z" },
1766
+ { url = "https://files.pythonhosted.org/packages/80/3f/9d0747a02ac3021ed3db7ac27c5187d97e78b0253a4bdfbf386666968474/pypdfium2-5.10.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f2803020952afa57e1e148adc19369b835154e1fa251a1ca85008ab6466a710f", size = 5047369, upload-time = "2026-06-15T10:09:08.883Z" },
1767
+ { url = "https://files.pythonhosted.org/packages/9e/41/7d5187e9527eae81890a3b13193442112ef788d61ddaa68a6d59ac447bec/pypdfium2-5.10.1-py3-none-win32.whl", hash = "sha256:8702bb4f01ddfc8e7757b41b4c2c8392ac17c9f0234476e1e69672ea7c6d6aa0", size = 3680056, upload-time = "2026-06-15T10:09:10.95Z" },
1768
+ { url = "https://files.pythonhosted.org/packages/16/1d/c62bd59dd8345cc4b640f942f465633f6b07b859d01ddb648610a7bf5c7c/pypdfium2-5.10.1-py3-none-win_amd64.whl", hash = "sha256:58da5b51fb7884c7d21a05062ab13edb011d1a08dfd9694f3d5d685df62796b9", size = 3812105, upload-time = "2026-06-15T10:09:12.684Z" },
1769
+ { url = "https://files.pythonhosted.org/packages/10/d5/21bac39125df8a93e99c04583486b58a62b5997d6b3541e3ad0f69053392/pypdfium2-5.10.1-py3-none-win_arm64.whl", hash = "sha256:e3301c2f7a66fb8cb57dba857d0c9e90215e178f6602a87c5a306cd98513dab8", size = 3600043, upload-time = "2026-06-15T10:09:14.606Z" },
1770
+ ]
1771
+
1772
  [[package]]
1773
  name = "python-dateutil"
1774
  version = "2.9.0.post0"
 
1845
  { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
1846
  ]
1847
 
1848
+ [[package]]
1849
+ name = "referencing"
1850
+ version = "0.37.0"
1851
+ source = { registry = "https://pypi.org/simple" }
1852
+ dependencies = [
1853
+ { name = "attrs" },
1854
+ { name = "rpds-py" },
1855
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
1856
+ ]
1857
+ sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
1858
+ wheels = [
1859
+ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
1860
+ ]
1861
+
1862
  [[package]]
1863
  name = "regex"
1864
  version = "2026.5.9"
 
1974
  { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
1975
  ]
1976
 
1977
+ [[package]]
1978
+ name = "rpds-py"
1979
+ version = "2026.5.1"
1980
+ source = { registry = "https://pypi.org/simple" }
1981
+ sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" }
1982
+ wheels = [
1983
+ { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" },
1984
+ { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" },
1985
+ { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" },
1986
+ { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" },
1987
+ { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" },
1988
+ { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" },
1989
+ { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" },
1990
+ { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" },
1991
+ { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" },
1992
+ { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" },
1993
+ { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" },
1994
+ { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" },
1995
+ { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" },
1996
+ { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" },
1997
+ { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" },
1998
+ { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" },
1999
+ { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" },
2000
+ { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" },
2001
+ { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" },
2002
+ { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" },
2003
+ { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" },
2004
+ { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" },
2005
+ { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" },
2006
+ { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" },
2007
+ { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" },
2008
+ { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" },
2009
+ { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" },
2010
+ { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" },
2011
+ { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" },
2012
+ { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" },
2013
+ { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" },
2014
+ { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" },
2015
+ { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" },
2016
+ { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" },
2017
+ { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" },
2018
+ { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" },
2019
+ { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" },
2020
+ { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" },
2021
+ { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" },
2022
+ { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" },
2023
+ { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" },
2024
+ { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" },
2025
+ { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" },
2026
+ { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" },
2027
+ { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" },
2028
+ { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" },
2029
+ { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" },
2030
+ { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" },
2031
+ { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" },
2032
+ { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" },
2033
+ { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" },
2034
+ { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" },
2035
+ { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" },
2036
+ { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" },
2037
+ { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" },
2038
+ { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" },
2039
+ { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" },
2040
+ { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" },
2041
+ { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" },
2042
+ { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" },
2043
+ { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" },
2044
+ { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" },
2045
+ { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" },
2046
+ { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" },
2047
+ { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" },
2048
+ { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" },
2049
+ { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" },
2050
+ { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" },
2051
+ { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" },
2052
+ { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" },
2053
+ { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" },
2054
+ { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" },
2055
+ { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" },
2056
+ { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" },
2057
+ { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" },
2058
+ { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" },
2059
+ { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" },
2060
+ { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" },
2061
+ { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" },
2062
+ { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" },
2063
+ { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" },
2064
+ { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" },
2065
+ { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" },
2066
+ { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" },
2067
+ { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" },
2068
+ { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" },
2069
+ { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" },
2070
+ { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" },
2071
+ { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" },
2072
+ { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" },
2073
+ { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" },
2074
+ { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" },
2075
+ { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" },
2076
+ { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" },
2077
+ { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" },
2078
+ { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" },
2079
+ { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" },
2080
+ { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" },
2081
+ { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" },
2082
+ { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" },
2083
+ { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" },
2084
+ { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" },
2085
+ ]
2086
+
2087
  [[package]]
2088
  name = "safehttpx"
2089
  version = "0.1.7"