Franskaman110 commited on
Commit
bcbeac3
·
1 Parent(s): b59aea7
data/processed/index/chunks.jsonl CHANGED
The diff for this file is too large to render. See raw diff
 
data/processed/index/index.faiss CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:c7fa250458221c13fc2a83ccc5ca01cf0e6eacb940a91c455f8c09793e896256
3
- size 16359469
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b8c2a6e73d9cbfc8d151ebfc1c2f7185eff39853f2e4411351a9bba3849d21ce
3
+ size 19947565
data/processed/lonstatistik/curated_growth_forecast.jsonl CHANGED
@@ -1,2 +1,7 @@
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."}
 
 
 
 
 
 
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."}
3
+ {"id":"IDA-curated-minwage-cand_scient","union":"IDA","source_doc":"IDA Lønstatistik 2025","source_page":6,"table_title":"DIMITTENDLØN / VEJLEDENDE MINIMALLØN","record_type":"minimum_wage_recommendation","sector":"all","period":"2026","currency":"DKK","pay_concept":"gross_monthly","education_type":"cand_scient","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","dimension_keys":[],"specificity":0,"reliability_tier":"full","measure":{"mean":47400.0},"rag_text":"IDA Lønstatistik 2025 — Science graduates (cand.scient.), all. IDA recommended minimum starting salary (vejledende minimalløn) for new graduates: kr. 47400 gross per month. Full reliability (n≥50). Source: IDA Lønstatistik 2025, p.6."}
4
+ {"id":"IDA-curated-minwage-civilingenior","union":"IDA","source_doc":"IDA Lønstatistik 2025","source_page":6,"table_title":"DIMITTENDLØN / VEJLEDENDE MINIMALLØN","record_type":"minimum_wage_recommendation","sector":"all","period":"2026","currency":"DKK","pay_concept":"gross_monthly","education_type":"civilingenior","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","dimension_keys":[],"specificity":0,"reliability_tier":"full","measure":{"mean":48200.0},"rag_text":"IDA Lønstatistik 2025 — Civil engineers (civilingeniører), all. IDA recommended minimum starting salary (vejledende minimalløn) for new graduates: kr. 48200 gross per month. Full reliability (n≥50). Source: IDA Lønstatistik 2025, p.6."}
5
+ {"id":"IDA-curated-minwage-diplom_eksportingenior","union":"IDA","source_doc":"IDA Lønstatistik 2025","source_page":6,"table_title":"DIMITTENDLØN / VEJLEDENDE MINIMALLØN","record_type":"minimum_wage_recommendation","sector":"all","period":"2026","currency":"DKK","pay_concept":"gross_monthly","education_type":"diplom_eksportingenior","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","dimension_keys":[],"specificity":0,"reliability_tier":"full","measure":{"mean":45300.0},"rag_text":"IDA Lønstatistik 2025 — Diploma/export engineers, all. IDA recommended minimum starting salary (vejledende minimalløn) for new graduates: kr. 45300 gross per month. Full reliability (n≥50). Source: IDA Lønstatistik 2025, p.6."}
6
+ {"id":"IDA-curated-minwage-akademiingenior","union":"IDA","source_doc":"IDA Lønstatistik 2025","source_page":6,"table_title":"DIMITTENDLØN / VEJLEDENDE MINIMALLØN","record_type":"minimum_wage_recommendation","sector":"all","period":"2026","currency":"DKK","pay_concept":"gross_monthly","education_type":"akademiingenior","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","dimension_keys":[],"specificity":0,"reliability_tier":"full","measure":{"mean":45300.0},"rag_text":"IDA Lønstatistik 2025 — Academy engineers (akademiingeniører), all. IDA recommended minimum starting salary (vejledende minimalløn) for new graduates: kr. 45300 gross per month. Full reliability (n≥50). Source: IDA Lønstatistik 2025, p.6."}
7
+ {"id":"IDA-curated-minwage-teknikumingenior","union":"IDA","source_doc":"IDA Lønstatistik 2025","source_page":6,"table_title":"DIMITTENDLØN / VEJLEDENDE MINIMALLØN","record_type":"minimum_wage_recommendation","sector":"all","period":"2026","currency":"DKK","pay_concept":"gross_monthly","education_type":"teknikumingenior","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","dimension_keys":[],"specificity":0,"reliability_tier":"full","measure":{"mean":45300.0},"rag_text":"IDA Lønstatistik 2025 — Technical engineers (teknikumingeniører), all. IDA recommended minimum starting salary (vejledende minimalløn) for new graduates: kr. 45300 gross per month. Full reliability (n≥50). Source: IDA Lønstatistik 2025, p.6."}
data/processed/lonstatistik/ida_records.jsonl CHANGED
@@ -2398,3 +2398,8 @@
2398
  {"id":"ida-reextract-27-0558","union":"IDA","source_doc":"IDA Lønstatistik 2025","source_page":30,"table_id":27,"table_title":"STATEN: STILLINGSNIVEAU","record_type":"salary_observation","sector":"public_state","data_period_month":"2025-05","currency":"DKK","pay_concept":"gross_monthly","education_type":"all","region":"all","position_level":"afdelingschef","position_level_rank":7,"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","row_dimension":"stillingsniveau × kandidatår","row_label":"Alle","segment_label":"Chef","dimension_keys":["position_level"],"specificity":1,"sample_size":361.0,"reliability_tier":"full","measure":{"count":361.0,"mean":87264.0},"rag_text":"IDA Lønstatistik 2025 — IDA members, state sector — department heads/team leaders. average 87.264 DKK/month. Based on 361 respondents. Full reliability (n≥50). Source: IDA Lønstatistik 2025, tabel 27, p.30."}
2399
  {"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."}
2400
  {"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."}
 
 
 
 
 
 
2398
  {"id":"ida-reextract-27-0558","union":"IDA","source_doc":"IDA Lønstatistik 2025","source_page":30,"table_id":27,"table_title":"STATEN: STILLINGSNIVEAU","record_type":"salary_observation","sector":"public_state","data_period_month":"2025-05","currency":"DKK","pay_concept":"gross_monthly","education_type":"all","region":"all","position_level":"afdelingschef","position_level_rank":7,"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","row_dimension":"stillingsniveau × kandidatår","row_label":"Alle","segment_label":"Chef","dimension_keys":["position_level"],"specificity":1,"sample_size":361.0,"reliability_tier":"full","measure":{"count":361.0,"mean":87264.0},"rag_text":"IDA Lønstatistik 2025 — IDA members, state sector — department heads/team leaders. average 87.264 DKK/month. Based on 361 respondents. Full reliability (n≥50). Source: IDA Lønstatistik 2025, tabel 27, p.30."}
2399
  {"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."}
2400
  {"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."}
2401
+ {"id":"IDA-curated-minwage-cand_scient","union":"IDA","source_doc":"IDA Lønstatistik 2025","source_page":6,"table_title":"DIMITTENDLØN / VEJLEDENDE MINIMALLØN","record_type":"minimum_wage_recommendation","sector":"all","period":"2026","currency":"DKK","pay_concept":"gross_monthly","education_type":"cand_scient","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","dimension_keys":[],"specificity":0,"reliability_tier":"full","measure":{"mean":47400.0},"rag_text":"IDA Lønstatistik 2025 — Science graduates (cand.scient.), all. IDA recommended minimum starting salary (vejledende minimalløn) for new graduates: kr. 47400 gross per month. Full reliability (n≥50). Source: IDA Lønstatistik 2025, p.6."}
2402
+ {"id":"IDA-curated-minwage-civilingenior","union":"IDA","source_doc":"IDA Lønstatistik 2025","source_page":6,"table_title":"DIMITTENDLØN / VEJLEDENDE MINIMALLØN","record_type":"minimum_wage_recommendation","sector":"all","period":"2026","currency":"DKK","pay_concept":"gross_monthly","education_type":"civilingenior","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","dimension_keys":[],"specificity":0,"reliability_tier":"full","measure":{"mean":48200.0},"rag_text":"IDA Lønstatistik 2025 — Civil engineers (civilingeniører), all. IDA recommended minimum starting salary (vejledende minimalløn) for new graduates: kr. 48200 gross per month. Full reliability (n≥50). Source: IDA Lønstatistik 2025, p.6."}
2403
+ {"id":"IDA-curated-minwage-diplom_eksportingenior","union":"IDA","source_doc":"IDA Lønstatistik 2025","source_page":6,"table_title":"DIMITTENDLØN / VEJLEDENDE MINIMALLØN","record_type":"minimum_wage_recommendation","sector":"all","period":"2026","currency":"DKK","pay_concept":"gross_monthly","education_type":"diplom_eksportingenior","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","dimension_keys":[],"specificity":0,"reliability_tier":"full","measure":{"mean":45300.0},"rag_text":"IDA Lønstatistik 2025 — Diploma/export engineers, all. IDA recommended minimum starting salary (vejledende minimalløn) for new graduates: kr. 45300 gross per month. Full reliability (n≥50). Source: IDA Lønstatistik 2025, p.6."}
2404
+ {"id":"IDA-curated-minwage-akademiingenior","union":"IDA","source_doc":"IDA Lønstatistik 2025","source_page":6,"table_title":"DIMITTENDLØN / VEJLEDENDE MINIMALLØN","record_type":"minimum_wage_recommendation","sector":"all","period":"2026","currency":"DKK","pay_concept":"gross_monthly","education_type":"akademiingenior","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","dimension_keys":[],"specificity":0,"reliability_tier":"full","measure":{"mean":45300.0},"rag_text":"IDA Lønstatistik 2025 — Academy engineers (akademiingeniører), all. IDA recommended minimum starting salary (vejledende minimalløn) for new graduates: kr. 45300 gross per month. Full reliability (n≥50). Source: IDA Lønstatistik 2025, p.6."}
2405
+ {"id":"IDA-curated-minwage-teknikumingenior","union":"IDA","source_doc":"IDA Lønstatistik 2025","source_page":6,"table_title":"DIMITTENDLØN / VEJLEDENDE MINIMALLØN","record_type":"minimum_wage_recommendation","sector":"all","period":"2026","currency":"DKK","pay_concept":"gross_monthly","education_type":"teknikumingenior","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","dimension_keys":[],"specificity":0,"reliability_tier":"full","measure":{"mean":45300.0},"rag_text":"IDA Lønstatistik 2025 — Technical engineers (teknikumingeniører), all. IDA recommended minimum starting salary (vejledende minimalløn) for new graduates: kr. 45300 gross per month. Full reliability (n≥50). Source: IDA Lønstatistik 2025, p.6."}
main.py CHANGED
@@ -1,10 +1,13 @@
1
  """Thin entry point for the Pay Equity Gradio Space.
2
 
3
  All orchestration lives in src/backend; this file only builds and launches the
4
- app. HF Space `app_file` points here.
 
5
  """
6
 
7
- from src.backend.ui import CUSTOM_CSS, FORCE_LIGHT_JS, SAGE_THEME, build_blocks
8
 
9
- demo = build_blocks()
10
- demo.launch(show_error=True, theme=SAGE_THEME, css=CUSTOM_CSS, js=FORCE_LIGHT_JS)
 
 
 
1
  """Thin entry point for the Pay Equity Gradio Space.
2
 
3
  All orchestration lives in src/backend; this file only builds and launches the
4
+ Gradio Server (custom HTML frontend + API endpoints). HF Space `app_file`
5
+ points here.
6
  """
7
 
8
+ from src.backend.server import build_server
9
 
10
+ app = build_server()
11
+
12
+ if __name__ == "__main__":
13
+ app.launch(show_error=True)
scripts/build_ida_records.py CHANGED
@@ -68,9 +68,45 @@ def build_curated() -> list[IdaSalaryRecord]:
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 []
 
68
  measure=Measure(mean=forecast_mid), rag_text="",
69
  ),
70
  ]
71
+ recs += build_minimum_wage()
72
  return recs
73
 
74
 
75
+ # IDA 2026 vejledende minimalløn (dimittend floors, booklet p.6). The brief named
76
+ # three: cand.scient 47 400; civil/cand.IT 48 200; diplom/bachelor 45 300.
77
+ # akademiingeniør + teknikumingeniør are bachelor-level engineering degrees → mapped
78
+ # to the diplom/bachelor floor (45 300). [Assumption flagged to the user.]
79
+ DIMITTEND_FLOOR_2026 = {
80
+ "cand_scient": 47400,
81
+ "civilingenior": 48200,
82
+ "diplom_eksportingenior": 45300,
83
+ "akademiingenior": 45300,
84
+ "teknikumingenior": 45300,
85
+ }
86
+
87
+
88
+ def build_minimum_wage() -> list[IdaSalaryRecord]:
89
+ """Curated dimittend minimum-wage recommendations (record_type schema-permitted).
90
+
91
+ Carrier convention (same as growth/forecast): the recommended monthly floor in
92
+ DKK lives in ``measure.mean``; its meaning comes from ``record_type``, and it is
93
+ never rendered as an observed average. ``sector="all"`` — the floor is
94
+ sector-agnostic IDA guidance.
95
+ """
96
+ return [
97
+ IdaSalaryRecord(
98
+ id=f"IDA-curated-minwage-{edu}",
99
+ source_doc="IDA Lønstatistik 2025",
100
+ source_page=6, table_id=None, table_title="DIMITTENDLØN / VEJLEDENDE MINIMALLØN",
101
+ record_type="minimum_wage_recommendation", sector="all",
102
+ education_type=edu, period="2026", pay_concept="gross_monthly",
103
+ reliability_tier="full",
104
+ measure=Measure(mean=float(floor)), rag_text="",
105
+ )
106
+ for edu, floor in DIMITTEND_FLOOR_2026.items()
107
+ ]
108
+
109
+
110
  def load_reextracted() -> list[IdaSalaryRecord]:
111
  if not REEXTRACT_JSONL.exists():
112
  return []
src/backend/api/endpoints.py CHANGED
@@ -22,6 +22,7 @@ from ..schemas import (
22
  Chunk,
23
  IndexStatus,
24
  ParseResult,
 
25
  )
26
 
27
 
@@ -67,3 +68,15 @@ def register(app: Server) -> None:
67
  """Chat with the RAG assistant: retrieve grounded chunks, cite sources."""
68
  msgs = [ChatMessage(**m) for m in messages]
69
  return answer(msgs, lang=lang).model_dump()
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  Chunk,
23
  IndexStatus,
24
  ParseResult,
25
+ Profile,
26
  )
27
 
28
 
 
68
  """Chat with the RAG assistant: retrieve grounded chunks, cite sources."""
69
  msgs = [ChatMessage(**m) for m in messages]
70
  return answer(msgs, lang=lang).model_dump()
71
+
72
+ @app.api(name="dashboard")
73
+ def dashboard_endpoint(profile: dict) -> dict:
74
+ """Build the profile-driven salary dashboard (structured IDA retrieval).
75
+
76
+ Takes the onboarding answers (a ``Profile``) and returns the fused
77
+ ``Dashboard`` — per-axis comparison cells, reliability-gated, projected
78
+ to 2026, with the user's percentile and gap. CPU-only (no embedding).
79
+ """
80
+ from ..fusion import build_dashboard
81
+
82
+ return build_dashboard(Profile(**profile)).model_dump()
src/backend/fusion.py CHANGED
@@ -133,6 +133,19 @@ def _forecast_pct(profile: Profile) -> float | None:
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(
@@ -237,6 +250,19 @@ def build_dashboard(profile: Profile) -> Dashboard:
237
  f"Figures projected to 2026 using the {forecast_range[0]}–{forecast_range[1]}% "
238
  f"private forecast band (~{growth_pct}% applied)."
239
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  if not cells:
241
  notes.append("No reliable salary cells matched this profile "
242
  "(samples may be suppressed; broaden the profile).")
@@ -248,5 +274,7 @@ def build_dashboard(profile: Profile) -> Dashboard:
248
  user_percentile=user_pct,
249
  gap_to_median=gap,
250
  projected_negotiation_target=projected_target,
 
 
251
  notes=notes,
252
  )
 
133
  return None
134
 
135
 
136
+ def _dimittend_floor(profile: Profile) -> tuple[int | None, str | None]:
137
+ """IDA 2026 vejledende minimalløn for the profile's degree (Branch B).
138
+
139
+ Returns (floor_dkk, citation). The recommended floor is carried in
140
+ ``measure.mean`` by the curated record convention (see build_ida_records).
141
+ """
142
+ for r in retrieve(profile, record_type="minimum_wage_recommendation"):
143
+ if r.education_type == profile.education_type and r.measure.mean is not None:
144
+ cite = f"{r.source_doc}, p.{r.source_page}"
145
+ return int(r.measure.mean), cite
146
+ return None, None
147
+
148
+
149
  def _estimate_percentile(value: float, m) -> float | None:
150
  """Approximate percentile of ``value`` within a cell's quantiles."""
151
  pts = sorted(
 
250
  f"Figures projected to 2026 using the {forecast_range[0]}–{forecast_range[1]}% "
251
  f"private forecast band (~{growth_pct}% applied)."
252
  )
253
+
254
+ # Branch B: surface IDA's 2026 vejledende minimalløn as a hard negotiation floor
255
+ # for new graduates (and any job-seeking profile that flags dimittend status).
256
+ recommended_floor = floor_citation = None
257
+ if profile.is_dimittend or profile.mode == "job_seeking":
258
+ recommended_floor, floor_citation = _dimittend_floor(profile)
259
+ if recommended_floor is not None:
260
+ floor_fmt = f"{recommended_floor:,}".replace(",", ".") # DK thousands sep
261
+ notes.append(
262
+ f"IDA 2026 vejledende minimalløn for your degree is kr. {floor_fmt} "
263
+ f"per month — treat this as a negotiation floor, not a target."
264
+ )
265
+
266
  if not cells:
267
  notes.append("No reliable salary cells matched this profile "
268
  "(samples may be suppressed; broaden the profile).")
 
274
  user_percentile=user_pct,
275
  gap_to_median=gap,
276
  projected_negotiation_target=projected_target,
277
+ recommended_floor=recommended_floor,
278
+ floor_citation=floor_citation,
279
  notes=notes,
280
  )
src/backend/indexing/ida_transform.py CHANGED
@@ -393,6 +393,10 @@ def make_rag_text(rec: IdaSalaryRecord) -> str:
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"
 
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
+ elif rec.record_type == "minimum_wage_recommendation":
397
+ floor = int(rec.measure.mean) if rec.measure.mean is not None else "n/a"
398
+ body = (f"IDA recommended minimum starting salary (vejledende minimalløn) for "
399
+ f"new graduates: kr. {floor} gross per month.")
400
  else:
401
  stats = _stats_phrase(rec)
402
  n = int(rec.sample_size) if rec.sample_size is not None else "n/a"
src/backend/indexing/ingest.py CHANGED
@@ -24,7 +24,9 @@ from ..config import REPO_ROOT
24
  from ..schemas import Chunk
25
 
26
  DIRECTIVE_JSON = REPO_ROOT / "data" / "processed" / "directive" / "document.json"
27
- SALARY_JSONL = REPO_ROOT / "data" / "processed" / "lonstatistik" / "salary_records.jsonl"
 
 
28
 
29
  # Element classes that carry article body text (everything else is page
30
  # furniture: page headers, footnotes, the document title).
@@ -115,11 +117,14 @@ def load_directive_chunks(
115
  return chunks
116
 
117
 
118
- def load_ida_chunks(path: str | Path = SALARY_JSONL) -> list[Chunk]:
119
- """Load IDA salary rows (``union == "IDA"``) as chunks from the JSONL.
120
 
121
- Each row's ``rag_text`` is already a self-contained, citation-bearing
122
- natural-language summary, so it is embedded verbatim.
 
 
 
123
  """
124
  chunks: list[Chunk] = []
125
  for line in Path(path).read_text(encoding="utf-8").splitlines():
@@ -127,31 +132,21 @@ def load_ida_chunks(path: str | Path = SALARY_JSONL) -> list[Chunk]:
127
  if not line:
128
  continue
129
  rec = json.loads(line)
130
- if rec.get("union") != "IDA":
131
  continue
132
  page = rec.get("source_page")
 
 
 
133
  chunks.append(
134
  Chunk(
135
  id=rec["id"],
136
  text=rec["rag_text"],
137
- lang="da",
138
  source="lonstatistik",
139
  page=page,
140
- citation=f"{rec['source_doc']}, p.{page}",
141
- metadata={
142
- "sector": rec.get("sector"),
143
- "category": rec.get("table_title"),
144
- "row_label": rec.get("row_label"),
145
- "segment_label": rec.get("segment_label"),
146
- "experience_years_min": rec.get("experience_years_min"),
147
- "experience_years_max": rec.get("experience_years_max"),
148
- "graduation_year_start": rec.get("graduation_year_start"),
149
- "graduation_year_end": rec.get("graduation_year_end"),
150
- "period": rec.get("period"),
151
- "pay_concept": rec.get("pay_concept"),
152
- "currency": rec.get("currency"),
153
- "measure": rec.get("measure"),
154
- },
155
  )
156
  )
157
  return chunks
 
24
  from ..schemas import Chunk
25
 
26
  DIRECTIVE_JSON = REPO_ROOT / "data" / "processed" / "directive" / "document.json"
27
+ # Structured IDA corpus (built by scripts/build_ida_records.py): the source of
28
+ # truth for both the vector index and the profile-driven retrieval/fusion path.
29
+ IDA_RECORDS_JSONL = REPO_ROOT / "data" / "processed" / "lonstatistik" / "ida_records.jsonl"
30
 
31
  # Element classes that carry article body text (everything else is page
32
  # furniture: page headers, footnotes, the document title).
 
117
  return chunks
118
 
119
 
120
+ def load_ida_chunks(path: str | Path = IDA_RECORDS_JSONL) -> list[Chunk]:
121
+ """Load the structured IDA corpus (``ida_records.jsonl``) as chunks.
122
 
123
+ Each record's ``rag_text`` is a dimension-complete, citation-bearing summary
124
+ embedded verbatim. The full typed record is carried in ``Chunk.metadata`` so
125
+ the vector path can hard-filter on the same dimensions as the profile-driven
126
+ retrieval. Only salary observations are embedded (growth/forecast records are
127
+ joined structurally, not retrieved by vector).
128
  """
129
  chunks: list[Chunk] = []
130
  for line in Path(path).read_text(encoding="utf-8").splitlines():
 
132
  if not line:
133
  continue
134
  rec = json.loads(line)
135
+ if rec.get("record_type") not in ("salary_observation", "self_employed_income"):
136
  continue
137
  page = rec.get("source_page")
138
+ tid = rec.get("table_id")
139
+ citation = (f"{rec['source_doc']}, tabel {tid}, p.{page}" if tid
140
+ else f"{rec['source_doc']}, p.{page}")
141
  chunks.append(
142
  Chunk(
143
  id=rec["id"],
144
  text=rec["rag_text"],
145
+ lang="en",
146
  source="lonstatistik",
147
  page=page,
148
+ citation=citation,
149
+ metadata=rec, # full typed record (dims, measure, reliability…)
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  )
151
  )
152
  return chunks
src/backend/schemas.py CHANGED
@@ -229,16 +229,22 @@ class Profile(BaseModel):
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:
@@ -276,6 +282,10 @@ class Dashboard(BaseModel):
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. "
 
229
 
230
  education_type: EducationType # Q1
231
  graduation_year: int # Q2 (→ cohort interval; experience = 2025 − year)
232
+ sector: Sector # Q3 (employed: current; job_seeking: target)
233
  gender: Optional[Literal["male", "female"]] = None # Q4 (skippable)
234
+ current_monthly_gross: int | None = None # Q5 (input; job_seeking: desired)
235
  current_monthly_gross_max: int | None = None # Q5 upper bound if a range
236
+ position_level: PositionLevel = "all" # Q6 (employed: current; job_seeking: target)
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 (employed: current; job_seeking: target)
241
  total_headcount_band: str | None = None # Q8b (captured, unused v1)
242
+ # Branch (employed vs job-seeking) — Branch B reuses the fields above as
243
+ # *target* values; only these three are new. All defaulted, so the
244
+ # ``/dashboard`` contract is unchanged for existing (Branch A) callers.
245
+ mode: Literal["employed", "job_seeking"] = "employed"
246
+ is_dimittend: bool = False # Branch B "new graduate?" → unlocks the floor
247
+ target_branche: str | None = None # Branch B target field (captured, unused v1)
248
 
249
  @property
250
  def experience_years(self) -> int:
 
282
  user_percentile: float | None = None # position within the best cell
283
  gap_to_median: float | None = None # user gross − cell median (DKK)
284
  projected_negotiation_target: float | None = None # 2026-projected ask
285
+ # IDA's 2026 vejledende minimalløn for the user's degree — a hard negotiation
286
+ # floor, surfaced for dimittend / job-seeking profiles (Branch B).
287
+ recommended_floor: int | None = None
288
+ floor_citation: str | None = None
289
  notes: list[str] = Field(default_factory=list)
290
  disclaimer: str = (
291
  "Salary figures are statistical benchmarks, not guarantees. "
src/backend/server.py CHANGED
@@ -1,31 +1,46 @@
1
  """Gradio Server assembly.
2
 
3
  Builds the ``gradio.Server`` (FastAPI-style API through Gradio), registers the
4
- backend endpoints, and mounts the Gradio-native chat UI at the site root.
 
 
 
 
 
 
5
 
6
  See: https://huggingface.co/blog/introducing-gradio-server
7
  """
8
 
9
  from __future__ import annotations
10
 
11
- import gradio as gr
 
 
 
12
  from gradio import Server
13
 
14
  from .api.endpoints import register
15
- from .ui import CUSTOM_CSS, SAGE_THEME, build_blocks
 
 
 
 
16
 
17
 
18
  def build_server() -> Server:
19
- """Create the configured Gradio Server with the chat UI mounted at ``/``."""
20
  app = Server()
21
 
22
- # Backend API endpoints (queue-managed, gradio_client-compatible).
23
  register(app)
24
 
25
- # Gradio-native chat UI at the root path. Theme + CSS are passed here (Gradio
26
- # 6 moved them off the Blocks constructor onto launch/mount).
27
- app = gr.mount_gradio_app(
28
- app, build_blocks(), path="/", theme=SAGE_THEME, css=CUSTOM_CSS
29
- )
 
 
30
 
31
  return app
 
1
  """Gradio Server assembly.
2
 
3
  Builds the ``gradio.Server`` (FastAPI-style API through Gradio), registers the
4
+ backend endpoints, and serves the **custom HTML/JS frontend** at the site root.
5
+
6
+ The frontend (``src/frontend/index.html`` + ``static/``) is a hand-authored
7
+ single-page app — onboarding wizard → salary dashboard → contextual rights chat —
8
+ that calls the registered ``@app.api`` endpoints (``/dashboard``, ``/chat``,
9
+ ``/parse_lonstatistik``) via the Gradio JS client. This is the "off-brand" custom
10
+ UI; Gradio's own theming is not used here.
11
 
12
  See: https://huggingface.co/blog/introducing-gradio-server
13
  """
14
 
15
  from __future__ import annotations
16
 
17
+ from pathlib import Path
18
+
19
+ from fastapi.responses import FileResponse
20
+ from fastapi.staticfiles import StaticFiles
21
  from gradio import Server
22
 
23
  from .api.endpoints import register
24
+
25
+ # src/frontend/{index.html, static/}
26
+ FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
27
+ INDEX_HTML = FRONTEND / "index.html"
28
+ STATIC_DIR = FRONTEND / "static"
29
 
30
 
31
  def build_server() -> Server:
32
+ """Create the configured Gradio Server with the custom frontend at ``/``."""
33
  app = Server()
34
 
35
+ # Backend API endpoints (queue-managed, gradio_client / JS-client compatible).
36
  register(app)
37
 
38
+ # Serve the custom single-page frontend. The Gradio JS client connects to the
39
+ # same origin and calls the registered API endpoints by name.
40
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
41
+
42
+ @app.get("/")
43
+ def _index() -> FileResponse: # noqa: D401 — serves the SPA shell
44
+ return FileResponse(str(INDEX_HTML))
45
 
46
  return app
src/frontend/index.html CHANGED
@@ -19,8 +19,8 @@
19
  <div class="logo">pay<span>equity</span></div>
20
  <nav class="nav">
21
  <div class="nav-links" style="display:flex; gap: var(--space-6);">
22
- <a href="#features" data-i18n="nav.pay">Expected pay</a>
23
- <a href="#chat" data-i18n="nav.rights">Your rights</a>
24
  </div>
25
  <div class="lang-switch">
26
  <button data-lang="en" class="active">EN</button>
@@ -35,19 +35,19 @@
35
  <section class="hero">
36
  <div class="container">
37
  <div class="hero-text">
38
- <h1 data-i18n="hero.title">Fair pay, made clear.</h1>
39
  <p class="lead" data-i18n="hero.lead"></p>
40
  <div class="hero-actions">
41
- <button class="btn-primary" data-cta-focus data-i18n="hero.cta_pay">
42
- What pay should I expect?
43
  </button>
44
- <button class="btn-secondary" data-cta-focus data-i18n="hero.cta_rights">
45
- What are my rights?
46
  </button>
47
  </div>
 
48
  </div>
49
  <div class="hero-visual" aria-hidden="true">
50
- <!-- Inline illustration: rising pay bars balanced on a fairness baseline. -->
51
  <svg viewBox="0 0 320 240" fill="none" xmlns="http://www.w3.org/2000/svg">
52
  <rect x="40" y="150" width="38" height="60" rx="6" fill="#15211D" />
53
  <rect x="96" y="120" width="38" height="90" rx="6" fill="#1F4A3F" />
@@ -61,47 +61,41 @@
61
  </div>
62
  </section>
63
 
64
- <!-- Features -->
65
- <section id="features" class="section-alt">
66
  <div class="container">
67
- <h2 class="section-title" data-i18n="features.title">
68
- Two questions, grounded answers
69
- </h2>
70
- <div class="card-grid">
71
- <div class="feature-card">
72
- <span class="feature-icon"></span>
73
- <h3 data-i18n="feature.pay.title">What pay should I expect?</h3>
74
- <p data-i18n="feature.pay.body"></p>
75
- </div>
76
- <div class="feature-card">
77
- <span class="feature-icon"></span>
78
- <h3 data-i18n="feature.rights.title">What are my rights?</h3>
79
- <p data-i18n="feature.rights.body"></p>
80
- </div>
81
- <div class="feature-card">
82
- <span class="feature-icon"></span>
83
- <h3 data-i18n="feature.upload.title">Bring your own document</h3>
84
- <p data-i18n="feature.upload.body"></p>
85
- </div>
86
- <div class="feature-card">
87
- <span class="feature-icon"></span>
88
- <h3 data-i18n="feature.multilingual.title">Multilingual</h3>
89
- <p data-i18n="feature.multilingual.body"></p>
90
  </div>
91
  </div>
92
  </div>
93
  </section>
94
 
95
- <!-- Chat -->
96
- <section id="chat">
 
 
 
 
 
 
 
 
 
97
  <div class="container">
98
- <h2 class="section-title" data-i18n="chat.title">Ask the assistant</h2>
 
99
  <div class="panel">
 
100
  <div id="chat-log" class="chat-log">
101
- <div class="msg assistant">
102
- Hi! Ask me what pay to expect or what the EU Pay Transparency
103
- Directive guarantees you.
104
- </div>
105
  </div>
106
  <div class="upload-row">
107
  <label class="file-btn" for="file-input" data-i18n="chat.upload"
@@ -117,12 +111,9 @@
117
  data-i18n-ph="chat.placeholder"
118
  placeholder="Ask about your pay or your rights…"
119
  />
120
- <button id="chat-send" class="btn-primary" data-i18n="chat.send">
121
- Send
122
- </button>
123
  </div>
124
  </div>
125
- <p class="disclaimer" data-i18n="disclaimer"></p>
126
  </div>
127
  </section>
128
 
@@ -136,8 +127,8 @@
136
  <div>
137
  <h4 data-i18n="footer.product">Product</h4>
138
  <ul>
139
- <li><a href="#features" data-i18n="nav.pay">Expected pay</a></li>
140
- <li><a href="#chat" data-i18n="nav.rights">Your rights</a></li>
141
  </ul>
142
  </div>
143
  <div>
 
19
  <div class="logo">pay<span>equity</span></div>
20
  <nav class="nav">
21
  <div class="nav-links" style="display:flex; gap: var(--space-6);">
22
+ <a href="#wizard" data-i18n="nav.pay">Expected pay</a>
23
+ <a href="#rights" data-i18n="nav.rights">Your rights</a>
24
  </div>
25
  <div class="lang-switch">
26
  <button data-lang="en" class="active">EN</button>
 
35
  <section class="hero">
36
  <div class="container">
37
  <div class="hero-text">
38
+ <h1 data-i18n="hero.title">Know your worth. Know your rights.</h1>
39
  <p class="lead" data-i18n="hero.lead"></p>
40
  <div class="hero-actions">
41
+ <button class="btn-primary" data-branch="employed" data-i18n="hero.cta_employed">
42
+ I'm employed — benchmark my pay
43
  </button>
44
+ <button class="btn-secondary" data-branch="job_seeking" data-i18n="hero.cta_seeking">
45
+ I'm job-seeking — prep for an offer
46
  </button>
47
  </div>
48
+ <p class="hero-note" data-i18n="hero.note"></p>
49
  </div>
50
  <div class="hero-visual" aria-hidden="true">
 
51
  <svg viewBox="0 0 320 240" fill="none" xmlns="http://www.w3.org/2000/svg">
52
  <rect x="40" y="150" width="38" height="60" rx="6" fill="#15211D" />
53
  <rect x="96" y="120" width="38" height="90" rx="6" fill="#1F4A3F" />
 
61
  </div>
62
  </section>
63
 
64
+ <!-- Onboarding wizard -->
65
+ <section id="wizard">
66
  <div class="container">
67
+ <h2 class="section-title" data-i18n="wizard.title">Build your profile</h2>
68
+ <p class="section-lead" data-i18n="wizard.lead"></p>
69
+ <div class="panel wizard" id="wizard-panel">
70
+ <div class="wizard-progress" id="wizard-progress" aria-hidden="true"></div>
71
+ <div class="wizard-body" id="wizard-step"><!-- rendered by app.js --></div>
72
+ <div class="wizard-nav">
73
+ <button class="btn-secondary" id="wizard-back" type="button" data-i18n="wizard.back">Back</button>
74
+ <span class="wizard-counter" id="wizard-counter"></span>
75
+ <button class="btn-primary" id="wizard-next" type="button" data-i18n="wizard.next">Next</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  </div>
77
  </div>
78
  </div>
79
  </section>
80
 
81
+ <!-- Dashboard (hidden until the profile is submitted) -->
82
+ <section id="dashboard" class="section-alt" hidden>
83
+ <div class="container">
84
+ <h2 class="section-title" data-i18n="dash.title">Your salary overview</h2>
85
+ <div id="dashboard-content"><!-- rendered by app.js --></div>
86
+ <p class="disclaimer" id="dashboard-disclaimer"></p>
87
+ </div>
88
+ </section>
89
+
90
+ <!-- Contextual rights chat -->
91
+ <section id="rights" hidden>
92
  <div class="container">
93
+ <h2 class="section-title" data-i18n="rights.title">What are my rights?</h2>
94
+ <p class="section-lead" data-i18n="rights.lead"></p>
95
  <div class="panel">
96
+ <div class="chat-seeds" id="chat-seeds"><!-- suggested questions, profile-seeded --></div>
97
  <div id="chat-log" class="chat-log">
98
+ <div class="msg assistant" data-i18n="rights.greeting"></div>
 
 
 
99
  </div>
100
  <div class="upload-row">
101
  <label class="file-btn" for="file-input" data-i18n="chat.upload"
 
111
  data-i18n-ph="chat.placeholder"
112
  placeholder="Ask about your pay or your rights…"
113
  />
114
+ <button id="chat-send" class="btn-primary" data-i18n="chat.send">Send</button>
 
 
115
  </div>
116
  </div>
 
117
  </div>
118
  </section>
119
 
 
127
  <div>
128
  <h4 data-i18n="footer.product">Product</h4>
129
  <ul>
130
+ <li><a href="#wizard" data-i18n="nav.pay">Expected pay</a></li>
131
+ <li><a href="#rights" data-i18n="nav.rights">Your rights</a></li>
132
  </ul>
133
  </div>
134
  <div>
src/frontend/static/app.js CHANGED
@@ -1,11 +1,15 @@
1
- // Frontend wiring: talks to the Gradio Server endpoints via the Gradio JS
2
- // client. No default Gradio UI — this drives our custom HTML.
 
 
3
  import {
4
  Client,
5
  handle_file,
6
  } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
7
 
8
  const t = (k) => window.i18n.t(k);
 
 
9
 
10
  let client = null;
11
  async function getClient() {
@@ -13,14 +17,475 @@ async function getClient() {
13
  return client;
14
  }
15
 
16
- const log = document.getElementById("chat-log");
17
- const input = document.getElementById("chat-input");
18
- const sendBtn = document.getElementById("chat-send");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  const fileInput = document.getElementById("file-input");
20
  const fileName = document.getElementById("file-name");
 
21
 
22
- // Conversation kept client-side; sent in full on each turn.
23
- const messages = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  function appendMessage(role, content, citations = []) {
26
  const el = document.createElement("div");
@@ -32,23 +497,22 @@ function appendMessage(role, content, citations = []) {
32
  c.textContent = citations.join(" · ");
33
  el.appendChild(c);
34
  }
35
- log.appendChild(el);
36
- log.scrollTop = log.scrollHeight;
37
  return el;
38
  }
39
 
40
  async function sendMessage() {
41
- const text = input.value.trim();
42
  if (!text) return;
43
- input.value = "";
44
  appendMessage("user", text);
45
  messages.push({ role: "user", content: text });
46
-
47
  const pending = appendMessage("assistant", "…");
48
  try {
49
  const c = await getClient();
50
  const result = await c.predict("/chat", {
51
- messages: messages,
52
  lang: window.i18n.lang,
53
  });
54
  const data = Array.isArray(result.data) ? result.data[0] : result.data;
@@ -59,6 +523,12 @@ async function sendMessage() {
59
  cit.textContent = data.citations.join(" · ");
60
  pending.appendChild(cit);
61
  }
 
 
 
 
 
 
62
  messages.push({ role: "assistant", content: data.reply ?? "" });
63
  } catch (err) {
64
  pending.textContent = "Error: " + (err?.message ?? err);
@@ -74,32 +544,33 @@ async function uploadDocument(file) {
74
  source: "upload",
75
  });
76
  const data = Array.isArray(result.data) ? result.data[0] : result.data;
77
- appendMessage(
78
- "assistant",
79
- `Received "${file.name}". ${data.note ?? ""}`.trim()
80
- );
81
  } catch (err) {
82
  appendMessage("assistant", "Upload failed: " + (err?.message ?? err));
83
  }
84
  }
85
 
86
- sendBtn.addEventListener("click", sendMessage);
87
- input.addEventListener("keydown", (e) => {
 
 
 
 
 
 
 
 
88
  if (e.key === "Enter") sendMessage();
89
  });
90
  fileInput.addEventListener("change", (e) => {
91
  const f = e.target.files[0];
92
  if (f) uploadDocument(f);
93
  });
94
- document.querySelectorAll(".lang-switch button").forEach((b) => {
95
- b.addEventListener("click", () => window.i18n.setLang(b.dataset.lang));
96
- });
97
- document.querySelectorAll("[data-cta-focus]").forEach((b) => {
98
- b.addEventListener("click", () => {
99
- document.getElementById("chat").scrollIntoView({ behavior: "smooth" });
100
- input.focus();
101
- });
102
- });
103
 
104
- // Initial render.
 
105
  window.i18n.setLang("en");
 
 
1
+ // Custom frontend for the Pay Equity Gradio Server.
2
+ // Onboarding wizard → /dashboard → contextual rights chat (/chat). No default
3
+ // Gradio UI: this drives hand-authored HTML and talks to the registered
4
+ // @app.api endpoints through the Gradio JS client.
5
  import {
6
  Client,
7
  handle_file,
8
  } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
9
 
10
  const t = (k) => window.i18n.t(k);
11
+ const dkk = (n) =>
12
+ n == null ? "—" : "kr. " + Math.round(n).toLocaleString("da-DK");
13
 
14
  let client = null;
15
  async function getClient() {
 
17
  return client;
18
  }
19
 
20
+ // ---------------------------------------------------------------------------
21
+ // Option sets. VALUES are the backend Literals (schemas.py) — do not translate
22
+ // them; only the labels are display text.
23
+ // ---------------------------------------------------------------------------
24
+ const OPT = {
25
+ education: [
26
+ ["civilingenior", "Civil engineer (civilingeniør / cand.polyt.)"],
27
+ ["cand_scient", "Science graduate (cand.scient.)"],
28
+ ["diplom_eksportingenior", "Diploma / export engineer"],
29
+ ["akademiingenior", "Academy engineer (akademiingeniør)"],
30
+ ["teknikumingenior", "Technical engineer (teknikumingeniør)"],
31
+ ],
32
+ sector: [
33
+ ["private", "Private sector"],
34
+ ["public_state", "State (staten)"],
35
+ ["public_municipal", "Municipal (kommune)"],
36
+ ["public_regional", "Regional (region)"],
37
+ ],
38
+ gender: [
39
+ ["female", "Woman"],
40
+ ["male", "Man"],
41
+ ],
42
+ position: [
43
+ ["topchef", "Top executive (topchef)"],
44
+ ["funktionsdirektor", "Functional director"],
45
+ ["afdelingschef", "Department head (afdelingschef)"],
46
+ ["projektleder", "Project manager"],
47
+ ["chef_seniorkonsulent", "Chief / senior consultant"],
48
+ ["specialist", "Specialist"],
49
+ ["ingenior_akademiker", "Engineer / academic (no management)"],
50
+ ["trainee", "Trainee"],
51
+ ["anden", "Other"],
52
+ ],
53
+ // value carries manages_people + management_span_band; see assembleProfile().
54
+ management: [
55
+ ["none", "No people-management responsibility"],
56
+ ["1-4", "1–4 reports"],
57
+ ["5-10", "5–10 reports"],
58
+ ["11-19", "11–19 reports"],
59
+ ["20-29", "20+ reports"],
60
+ ],
61
+ stem: [
62
+ ["1-9", "1–9 engineers/STEM"],
63
+ ["10-24", "10–24"],
64
+ ["25-99", "25–99"],
65
+ ["100-249", "100–249"],
66
+ ["250+", "250 or more"],
67
+ ],
68
+ headcount: [
69
+ ["1-49", "1–49 employees"],
70
+ ["50-99", "50–99"],
71
+ ["100-149", "100–149"],
72
+ ["150-249", "150–249"],
73
+ ["250+", "250 or more"],
74
+ ],
75
+ region: [
76
+ ["hovedstaden", "Capital Region (Hovedstaden)"],
77
+ ["sjaelland", "Zealand (Sjælland)"],
78
+ ["syddanmark", "Southern Denmark (Syddanmark)"],
79
+ ["midtjylland", "Central Denmark (Midtjylland)"],
80
+ ["nordjylland", "North Denmark (Nordjylland)"],
81
+ ],
82
+ };
83
+
84
+ const BRUTTO_HELP =
85
+ "Enter your IDA bruttoløn so the comparison is apples-to-apples: base salary " +
86
+ "+ permanent and temporary tillæg + fritvalg + taxable perks + your own AND " +
87
+ "your employer's pension (incl. ATP). Most people undercount by leaving out " +
88
+ "employer pension.";
89
+
90
+ // One question per step. `branches` says which branch(es) show it.
91
+ function questions(branch) {
92
+ const targetWord = branch === "job_seeking" ? "Target" : "Your current";
93
+ const steps = [
94
+ { key: "education_type", type: "select", required: true, options: OPT.education,
95
+ label: "What is your education?",
96
+ help: "This selects the right IDA salary table (civil vs. academy vs. diploma vs. cand.scient.)." },
97
+ { key: "graduation_year", type: "number", required: true, min: 1960, max: 2026,
98
+ label: "What year did you graduate (kandidatår)?",
99
+ help: "The primary row in every IDA table. We derive experience as 2025 − this year." },
100
+ { key: "sector", type: "select", required: true, options: OPT.sector,
101
+ label: `${targetWord} sector?`,
102
+ help: "Private and public use different pay concepts — and decide whether you can rely on the directive directly against a public employer." },
103
+ { key: "gender", type: "select", optional: true, options: OPT.gender,
104
+ label: "Your gender (optional)",
105
+ help: "Skippable — but it unlocks the equal-pay-for-equal-value comparison, the core of the directive." },
106
+ ];
107
+
108
+ if (branch === "employed") {
109
+ steps.push(
110
+ { key: "current_gross", type: "range",
111
+ label: "Your current monthly gross salary (bruttoløn)?",
112
+ help: BRUTTO_HELP },
113
+ { key: "position_level", type: "select", optional: true, options: OPT.position,
114
+ label: "Your position level?",
115
+ help: "Bigger spread than education type." },
116
+ { key: "management", type: "select", optional: true, options: OPT.management,
117
+ label: "People-management responsibility?",
118
+ help: "Strongly correlated with pay." },
119
+ { key: "stem_count_band", type: "select", optional: true, options: OPT.stem,
120
+ label: "How many engineers / STEM colleagues at your company?" },
121
+ { key: "total_headcount_band", type: "select", optional: true, options: OPT.headcount,
122
+ label: "Total company headcount?",
123
+ help: "Decides whether your employer must publish its gender pay gap under the directive." },
124
+ { key: "region", type: "select", optional: true, options: OPT.region,
125
+ label: "Your region?",
126
+ help: "The Capital Region runs materially higher." },
127
+ );
128
+ } else {
129
+ steps.push(
130
+ { key: "is_dimittend", type: "bool",
131
+ label: "Are you a new graduate (dimittend)?",
132
+ help: "If yes, we surface IDA's 2026 vejledende minimalløn — a hard negotiation floor for your degree." },
133
+ { key: "current_gross", type: "range",
134
+ label: "Your desired monthly salary range (bruttoløn)?",
135
+ help: BRUTTO_HELP },
136
+ { key: "position_level", type: "select", optional: true, options: OPT.position,
137
+ label: "Target position level?" },
138
+ { key: "region", type: "select", optional: true, options: OPT.region,
139
+ label: "Target region?" },
140
+ { key: "target_branche", type: "text", optional: true,
141
+ label: "Target industry / retning (optional)",
142
+ placeholder: "e.g. energy, pharma, software…" },
143
+ );
144
+ }
145
+ return steps;
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Wizard state
150
+ // ---------------------------------------------------------------------------
151
+ const state = { branch: null, steps: [], idx: 0, answers: {} };
152
+
153
+ const els = {
154
+ panel: document.getElementById("wizard-panel"),
155
+ progress: document.getElementById("wizard-progress"),
156
+ step: document.getElementById("wizard-step"),
157
+ back: document.getElementById("wizard-back"),
158
+ next: document.getElementById("wizard-next"),
159
+ counter: document.getElementById("wizard-counter"),
160
+ dashSection: document.getElementById("dashboard"),
161
+ dashContent: document.getElementById("dashboard-content"),
162
+ dashDisclaimer: document.getElementById("dashboard-disclaimer"),
163
+ rightsSection: document.getElementById("rights"),
164
+ };
165
+
166
+ function startBranch(branch, scroll = true) {
167
+ state.branch = branch;
168
+ state.steps = questions(branch);
169
+ state.idx = 0;
170
+ state.answers = {};
171
+ renderStep();
172
+ if (scroll)
173
+ document.getElementById("wizard").scrollIntoView({ behavior: "smooth" });
174
+ }
175
+
176
+ function renderProgress() {
177
+ els.progress.innerHTML = "";
178
+ state.steps.forEach((_, i) => {
179
+ const dot = document.createElement("span");
180
+ dot.className = "wiz-dot" + (i < state.idx ? " done" : i === state.idx ? " active" : "");
181
+ els.progress.appendChild(dot);
182
+ });
183
+ }
184
+
185
+ function renderStep() {
186
+ const q = state.steps[state.idx];
187
+ renderProgress();
188
+ els.counter.textContent = `${state.idx + 1} / ${state.steps.length}`;
189
+ els.back.style.visibility = state.idx === 0 ? "hidden" : "visible";
190
+ els.next.textContent =
191
+ state.idx === state.steps.length - 1 ? t("wizard.finish") : t("wizard.next");
192
+
193
+ const cur = state.answers[q.key];
194
+ let body = `<div class="wiz-q"><h3>${q.label}</h3>`;
195
+ if (q.help) body += `<p class="wiz-help">${q.help}</p>`;
196
+
197
+ if (q.type === "select") {
198
+ body += `<div class="wiz-options" role="radiogroup">`;
199
+ for (const [val, lbl] of q.options) {
200
+ const on = cur === val ? " selected" : "";
201
+ body += `<button class="wiz-opt${on}" data-val="${val}" type="button">${lbl}</button>`;
202
+ }
203
+ if (q.optional)
204
+ body += `<button class="wiz-opt skip${cur === "__skip" ? " selected" : ""}" data-val="__skip" type="button">No preference / skip</button>`;
205
+ body += `</div>`;
206
+ } else if (q.type === "number") {
207
+ body += `<input class="wiz-input" id="wiz-field" type="number" inputmode="numeric"
208
+ min="${q.min}" max="${q.max}" value="${cur ?? ""}" placeholder="e.g. 2012" />`;
209
+ } else if (q.type === "text") {
210
+ body += `<input class="wiz-input" id="wiz-field" type="text"
211
+ value="${cur ?? ""}" placeholder="${q.placeholder || ""}" />`;
212
+ } else if (q.type === "range") {
213
+ const lo = cur?.lo ?? "", hi = cur?.hi ?? "";
214
+ body += `<div class="wiz-range">
215
+ <input class="wiz-input" id="wiz-lo" type="number" inputmode="numeric" value="${lo}" placeholder="From (kr./month)" />
216
+ <span class="wiz-range-sep">–</span>
217
+ <input class="wiz-input" id="wiz-hi" type="number" inputmode="numeric" value="${hi}" placeholder="To (optional)" />
218
+ </div>`;
219
+ } else if (q.type === "bool") {
220
+ body += `<div class="wiz-options" role="radiogroup">
221
+ <button class="wiz-opt${cur === true ? " selected" : ""}" data-val="yes" type="button">Yes</button>
222
+ <button class="wiz-opt${cur === false ? " selected" : ""}" data-val="no" type="button">No</button>
223
+ </div>`;
224
+ }
225
+ body += `</div>`;
226
+ els.step.innerHTML = body;
227
+ wireStepInputs(q);
228
+ }
229
+
230
+ function wireStepInputs(q) {
231
+ if (q.type === "select") {
232
+ els.step.querySelectorAll(".wiz-opt").forEach((b) =>
233
+ b.addEventListener("click", () => {
234
+ state.answers[q.key] = b.dataset.val;
235
+ advance();
236
+ })
237
+ );
238
+ } else if (q.type === "bool") {
239
+ els.step.querySelectorAll(".wiz-opt").forEach((b) =>
240
+ b.addEventListener("click", () => {
241
+ state.answers[q.key] = b.dataset.val === "yes";
242
+ advance();
243
+ })
244
+ );
245
+ }
246
+ // number/text/range commit on Next (read in commitStep()).
247
+ }
248
+
249
+ function commitStep() {
250
+ const q = state.steps[state.idx];
251
+ if (q.type === "number") {
252
+ const v = document.getElementById("wiz-field").value.trim();
253
+ state.answers[q.key] = v === "" ? null : parseInt(v, 10);
254
+ } else if (q.type === "text") {
255
+ const v = document.getElementById("wiz-field").value.trim();
256
+ state.answers[q.key] = v === "" ? null : v;
257
+ } else if (q.type === "range") {
258
+ const lo = document.getElementById("wiz-lo").value.trim();
259
+ const hi = document.getElementById("wiz-hi").value.trim();
260
+ state.answers[q.key] = {
261
+ lo: lo === "" ? null : parseInt(lo, 10),
262
+ hi: hi === "" ? null : parseInt(hi, 10),
263
+ };
264
+ }
265
+ if (q.required) {
266
+ const a = state.answers[q.key];
267
+ if (a == null || a === "__skip") {
268
+ flashRequired();
269
+ return false;
270
+ }
271
+ }
272
+ return true;
273
+ }
274
+
275
+ function flashRequired() {
276
+ els.panel.classList.add("wiz-shake");
277
+ setTimeout(() => els.panel.classList.remove("wiz-shake"), 500);
278
+ }
279
+
280
+ function advance() {
281
+ if (!commitStep()) return;
282
+ if (state.idx < state.steps.length - 1) {
283
+ state.idx += 1;
284
+ renderStep();
285
+ } else {
286
+ submitProfile();
287
+ }
288
+ }
289
+
290
+ function back() {
291
+ if (state.idx > 0) {
292
+ state.idx -= 1;
293
+ renderStep();
294
+ }
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Profile assembly + /dashboard
299
+ // ---------------------------------------------------------------------------
300
+ function assembleProfile() {
301
+ const a = state.answers;
302
+ const p = {
303
+ mode: state.branch,
304
+ education_type: a.education_type,
305
+ graduation_year: a.graduation_year,
306
+ sector: a.sector,
307
+ };
308
+ const set = (k, v, skipWildcard) => {
309
+ if (v != null && v !== "__skip") p[k] = v;
310
+ else if (skipWildcard) p[k] = "all";
311
+ };
312
+ set("gender", a.gender); // skipped → omitted → None
313
+ set("position_level", a.position_level, true); // skipped → "all"
314
+ set("region", a.region, true); // skipped → "all"
315
+ set("stem_count_band", a.stem_count_band);
316
+ set("target_branche", a.target_branche);
317
+ if (a.total_headcount_band && a.total_headcount_band !== "__skip")
318
+ p.total_headcount_band = a.total_headcount_band;
319
+ if (typeof a.is_dimittend === "boolean") p.is_dimittend = a.is_dimittend;
320
+
321
+ // management → manages_people + management_span_band
322
+ if (a.management && a.management !== "__skip") {
323
+ if (a.management === "none") p.manages_people = "no";
324
+ else {
325
+ p.manages_people = "yes";
326
+ p.management_span_band = a.management;
327
+ }
328
+ }
329
+ // salary range
330
+ if (a.current_gross) {
331
+ if (a.current_gross.lo != null) p.current_monthly_gross = a.current_gross.lo;
332
+ if (a.current_gross.hi != null) p.current_monthly_gross_max = a.current_gross.hi;
333
+ }
334
+ return p;
335
+ }
336
+
337
+ async function submitProfile() {
338
+ const profile = assembleProfile();
339
+ els.dashSection.hidden = false;
340
+ els.rightsSection.hidden = false;
341
+ els.dashContent.innerHTML = `<p class="dash-loading">${t("dash.loading")}</p>`;
342
+ els.dashSection.scrollIntoView({ behavior: "smooth" });
343
+ try {
344
+ const c = await getClient();
345
+ const res = await c.predict("/dashboard", { profile });
346
+ const d = Array.isArray(res.data) ? res.data[0] : res.data;
347
+ renderDashboard(d);
348
+ seedRights(profile, d);
349
+ } catch (err) {
350
+ els.dashContent.innerHTML = `<p class="dash-error">Could not build the dashboard: ${
351
+ err?.message ?? err
352
+ }</p>`;
353
+ }
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Dashboard rendering
358
+ // ---------------------------------------------------------------------------
359
+ const TIER_LABEL = {
360
+ full: "high reliability",
361
+ quartiles_ok: "good reliability",
362
+ median_ok: "moderate reliability",
363
+ mean_only: "low reliability (small sample)",
364
+ suppressed: "suppressed (too few respondents)",
365
+ };
366
+
367
+ function central(m) {
368
+ return m?.median ?? m?.mean ?? null;
369
+ }
370
+
371
+ function kpis(d) {
372
+ const items = [];
373
+ if (d.recommended_floor != null)
374
+ items.push({
375
+ big: dkk(d.recommended_floor),
376
+ lbl: "Negotiation floor (IDA 2026 dimittendløn)",
377
+ cite: d.floor_citation,
378
+ accent: true,
379
+ });
380
+ if (d.user_percentile != null)
381
+ items.push({
382
+ big: Math.round(d.user_percentile) + "ᵗʰ",
383
+ lbl: "Your percentile vs. peers",
384
+ });
385
+ if (d.gap_to_median != null)
386
+ items.push({
387
+ big: (d.gap_to_median >= 0 ? "+" : "−") + dkk(Math.abs(d.gap_to_median)),
388
+ lbl: "Gap to the median",
389
+ });
390
+ if (d.projected_negotiation_target != null)
391
+ items.push({
392
+ big: dkk(d.projected_negotiation_target),
393
+ lbl: "2026 negotiation target",
394
+ });
395
+ if (!items.length) return "";
396
+ return (
397
+ `<div class="kpi-row">` +
398
+ items
399
+ .map(
400
+ (k) => `<div class="kpi${k.accent ? " kpi-accent" : ""}">
401
+ <div class="kpi-big">${k.big}</div>
402
+ <div class="kpi-lbl">${k.lbl}</div>
403
+ ${k.cite ? `<div class="kpi-cite">${k.cite}</div>` : ""}
404
+ </div>`
405
+ )
406
+ .join("") +
407
+ `</div>`
408
+ );
409
+ }
410
+
411
+ function measureLine(m) {
412
+ const parts = [];
413
+ const c = central(m);
414
+ if (c != null) parts.push(`<strong>${dkk(c)}</strong>/month`);
415
+ if (m.p25 != null && m.p75 != null)
416
+ parts.push(`<span class="cell-range">${dkk(m.p25)} – ${dkk(m.p75)} (p25–p75)</span>`);
417
+ if (m.p90 != null) parts.push(`<span class="cell-range">${dkk(m.p90)} (p90)</span>`);
418
+ return parts.join(" · ");
419
+ }
420
+
421
+ function renderDashboard(d) {
422
+ let html = kpis(d);
423
+
424
+ if (d.cells?.length) {
425
+ html += `<div class="cell-grid">`;
426
+ for (const cell of d.cells) {
427
+ const proj = cell.projected_median_2026
428
+ ? `<div class="cell-proj">2026 projection: ${dkk(cell.projected_median_2026)}</div>`
429
+ : "";
430
+ const n = cell.sample_size != null ? `n=${Math.round(cell.sample_size)}` : "n/a";
431
+ const tier = TIER_LABEL[cell.reliability_tier] || cell.reliability_tier;
432
+ html += `<div class="cell-card">
433
+ <div class="cell-head">${cell.comparison}</div>
434
+ <div class="cell-measure">${measureLine(cell.measure)}</div>
435
+ ${proj}
436
+ <div class="cell-foot">
437
+ <span class="cell-tier tier-${cell.reliability_tier}">${tier} · ${n}</span>
438
+ <span class="cell-cite">${cell.citation}</span>
439
+ </div>
440
+ </div>`;
441
+ }
442
+ html += `</div>`;
443
+ } else {
444
+ html += `<p class="dash-error">${t("dash.empty")}</p>`;
445
+ }
446
+
447
+ if (d.notes?.length) {
448
+ html += `<ul class="dash-notes">${d.notes
449
+ .map((n) => `<li>${n}</li>`)
450
+ .join("")}</ul>`;
451
+ }
452
+ els.dashContent.innerHTML = html;
453
+ els.dashDisclaimer.textContent = d.disclaimer || "";
454
+ }
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // Contextual rights chat
458
+ // ---------------------------------------------------------------------------
459
+ const messages = [];
460
+ const chatLog = document.getElementById("chat-log");
461
+ const chatInput = document.getElementById("chat-input");
462
+ const chatSend = document.getElementById("chat-send");
463
  const fileInput = document.getElementById("file-input");
464
  const fileName = document.getElementById("file-name");
465
+ const seedsEl = document.getElementById("chat-seeds");
466
 
467
+ function seedRights(profile, dashboard) {
468
+ const seeds = ["What information am I entitled to about pay at my workplace?"];
469
+ if (profile.mode === "job_seeking" || profile.is_dimittend)
470
+ seeds.push("Can an employer ask my salary history during hiring?");
471
+ const big = ["150-249", "250+"].includes(profile.total_headcount_band);
472
+ if (big || profile.total_headcount_band === "100-149")
473
+ seeds.push("Does my employer have to report its gender pay gap?");
474
+ if (String(profile.sector).startsWith("public_"))
475
+ seeds.push("Can I rely on the directive directly against a public employer?");
476
+ if (profile.gender)
477
+ seeds.push("How do I know if I'm underpaid for work of equal value?");
478
+
479
+ seedsEl.innerHTML = seeds
480
+ .map((s) => `<button class="seed-chip" type="button">${s}</button>`)
481
+ .join("");
482
+ seedsEl.querySelectorAll(".seed-chip").forEach((b) =>
483
+ b.addEventListener("click", () => {
484
+ chatInput.value = b.textContent;
485
+ sendMessage();
486
+ })
487
+ );
488
+ }
489
 
490
  function appendMessage(role, content, citations = []) {
491
  const el = document.createElement("div");
 
497
  c.textContent = citations.join(" · ");
498
  el.appendChild(c);
499
  }
500
+ chatLog.appendChild(el);
501
+ chatLog.scrollTop = chatLog.scrollHeight;
502
  return el;
503
  }
504
 
505
  async function sendMessage() {
506
+ const text = chatInput.value.trim();
507
  if (!text) return;
508
+ chatInput.value = "";
509
  appendMessage("user", text);
510
  messages.push({ role: "user", content: text });
 
511
  const pending = appendMessage("assistant", "…");
512
  try {
513
  const c = await getClient();
514
  const result = await c.predict("/chat", {
515
+ messages,
516
  lang: window.i18n.lang,
517
  });
518
  const data = Array.isArray(result.data) ? result.data[0] : result.data;
 
523
  cit.textContent = data.citations.join(" · ");
524
  pending.appendChild(cit);
525
  }
526
+ if (data.disclaimer) {
527
+ const dc = document.createElement("span");
528
+ dc.className = "citations disclaimer-inline";
529
+ dc.textContent = data.disclaimer;
530
+ pending.appendChild(dc);
531
+ }
532
  messages.push({ role: "assistant", content: data.reply ?? "" });
533
  } catch (err) {
534
  pending.textContent = "Error: " + (err?.message ?? err);
 
544
  source: "upload",
545
  });
546
  const data = Array.isArray(result.data) ? result.data[0] : result.data;
547
+ appendMessage("assistant", `Received "${file.name}". ${data.note ?? ""}`.trim());
 
 
 
548
  } catch (err) {
549
  appendMessage("assistant", "Upload failed: " + (err?.message ?? err));
550
  }
551
  }
552
 
553
+ // ---------------------------------------------------------------------------
554
+ // Wiring
555
+ // ---------------------------------------------------------------------------
556
+ document.querySelectorAll("[data-branch]").forEach((b) =>
557
+ b.addEventListener("click", () => startBranch(b.dataset.branch))
558
+ );
559
+ els.next.addEventListener("click", advance);
560
+ els.back.addEventListener("click", back);
561
+ chatSend.addEventListener("click", sendMessage);
562
+ chatInput.addEventListener("keydown", (e) => {
563
  if (e.key === "Enter") sendMessage();
564
  });
565
  fileInput.addEventListener("change", (e) => {
566
  const f = e.target.files[0];
567
  if (f) uploadDocument(f);
568
  });
569
+ document.querySelectorAll(".lang-switch button").forEach((b) =>
570
+ b.addEventListener("click", () => window.i18n.setLang(b.dataset.lang))
571
+ );
 
 
 
 
 
 
572
 
573
+ // Initial render: default to the employed branch so the wizard is never empty
574
+ // (no scroll — let the visitor read the hero first).
575
  window.i18n.setLang("en");
576
+ startBranch("employed", false);
src/frontend/static/i18n.js CHANGED
@@ -5,24 +5,28 @@ const STRINGS = {
5
  en: {
6
  "nav.rights": "Your rights",
7
  "nav.pay": "Expected pay",
8
- "hero.title": "Fair pay, made clear.",
9
  "hero.lead":
10
- "A multilingual assistant for the EU Pay Transparency Directive — understand what you should earn and what the law guarantees you.",
11
- "hero.cta_pay": "What pay should I expect?",
12
- "hero.cta_rights": "What are my rights?",
13
- "features.title": "Two questions, grounded answers",
14
- "feature.pay.title": "What pay should I expect?",
15
- "feature.pay.body":
16
- "Deterministic lookup over Djøf and IDA salary statistics — no guessing.",
17
- "feature.rights.title": "What are my rights?",
18
- "feature.rights.body":
19
- "Answers grounded in EU Directive 2023/970, with article citations.",
20
- "feature.upload.title": "Bring your own document",
21
- "feature.upload.body":
22
- "Upload a contract or payslip for this session only — never stored.",
23
- "feature.multilingual.title": "Multilingual",
24
- "feature.multilingual.body":
25
- "Ask in English, Danish, or French.",
 
 
 
 
26
  "chat.title": "Ask the assistant",
27
  "chat.placeholder": "Ask about your pay or your rights…",
28
  "chat.send": "Send",
 
5
  en: {
6
  "nav.rights": "Your rights",
7
  "nav.pay": "Expected pay",
8
+ "hero.title": "Know your worth. Know your rights.",
9
  "hero.lead":
10
+ "Answer a few questions about your engineering career and get a grounded salary benchmark from IDA's lønstatistik — plus your rights under the EU Pay Transparency Directive (2023/970).",
11
+ "hero.cta_employed": "I'm employed — benchmark my pay",
12
+ "hero.cta_seeking": "I'm job-seeking — prep for an offer",
13
+ "hero.note":
14
+ "Built on IDA salary statistics and Directive 2023/970. Not legal or financial advice.",
15
+ "wizard.title": "Build your profile",
16
+ "wizard.lead":
17
+ "The more you tell us, the more specific the benchmark. Optional questions can be skipped.",
18
+ "wizard.back": "Back",
19
+ "wizard.next": "Next",
20
+ "wizard.finish": "See my overview",
21
+ "dash.title": "Your salary overview",
22
+ "dash.loading": "Building your benchmark…",
23
+ "dash.empty":
24
+ "No reliable salary cells matched this profile — try broadening it (the sample may be suppressed).",
25
+ "rights.title": "What are my rights?",
26
+ "rights.lead":
27
+ "Ask about the EU Pay Transparency Directive. Answers are grounded in the directive text, with article citations.",
28
+ "rights.greeting":
29
+ "Based on your profile, here are a few things worth knowing. Tap a question or ask your own.",
30
  "chat.title": "Ask the assistant",
31
  "chat.placeholder": "Ask about your pay or your rights…",
32
  "chat.send": "Send",
src/frontend/static/styles.css CHANGED
@@ -372,6 +372,78 @@ section { padding: var(--section-padding-y) 0; }
372
  .footer-grid ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2); }
373
  .footer-meta { margin-top: var(--space-10); font-size: var(--text-sm); opacity: 0.55; }
374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  /* ---------- Responsive ---------- */
376
  @media (max-width: 900px) {
377
  .hero .container { grid-template-columns: 1fr; gap: var(--space-10); }
 
372
  .footer-grid ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2); }
373
  .footer-meta { margin-top: var(--space-10); font-size: var(--text-sm); opacity: 0.55; }
374
 
375
+ /* ---------- Hero note + section lead ---------- */
376
+ .hero-note { margin-top: var(--space-5); font-size: var(--text-sm); color: var(--color-text-muted); max-width: 40ch; }
377
+ .section-lead { font-size: var(--text-lg); color: var(--color-text-secondary); max-width: 56ch; margin: 0 0 var(--space-8); }
378
+
379
+ /* ---------- Wizard ---------- */
380
+ .wizard { max-width: var(--max-width-text); margin: 0 auto; padding: var(--space-8); }
381
+ .wizard-progress { display: flex; gap: var(--space-2); margin-bottom: var(--space-8); }
382
+ .wiz-dot { flex: 1; height: 4px; border-radius: var(--radius-pill); background: var(--color-border); transition: background var(--transition-base); }
383
+ .wiz-dot.done { background: var(--color-accent); }
384
+ .wiz-dot.active { background: var(--color-ink); }
385
+ .wizard-body { min-height: 220px; }
386
+ .wiz-q h3 { font-size: var(--text-2xl); font-weight: var(--font-semibold); letter-spacing: -0.01em; margin: 0 0 var(--space-2); }
387
+ .wiz-help { font-size: var(--text-sm); color: var(--color-text-muted); margin: 0 0 var(--space-6); line-height: var(--leading-snug); }
388
+ .wiz-options { display: flex; flex-direction: column; gap: var(--space-3); }
389
+ .wiz-opt {
390
+ text-align: left; font: inherit; font-size: var(--text-base); font-weight: var(--font-medium);
391
+ background: #fff; color: var(--color-ink);
392
+ border: 1.5px solid var(--color-border); border-radius: var(--radius-md);
393
+ padding: 0.85rem 1.1rem; cursor: pointer;
394
+ transition: border-color var(--transition-base), background var(--transition-base), transform var(--transition-fast);
395
+ }
396
+ .wiz-opt:hover { border-color: var(--color-accent); transform: translateX(2px); }
397
+ .wiz-opt.selected { border-color: var(--color-ink); background: var(--color-sage-light); font-weight: var(--font-semibold); }
398
+ .wiz-opt.skip { color: var(--color-text-muted); font-style: italic; border-style: dashed; }
399
+ .wiz-input {
400
+ width: 100%; font: inherit; font-size: var(--text-lg);
401
+ border: 1.5px solid var(--color-border); border-radius: var(--radius-md);
402
+ padding: 0.85rem 1.1rem; outline: none; transition: border-color var(--transition-base);
403
+ }
404
+ .wiz-input:focus { border-color: var(--color-accent); }
405
+ .wiz-range { display: flex; align-items: center; gap: var(--space-3); }
406
+ .wiz-range-sep { color: var(--color-text-muted); font-size: var(--text-xl); }
407
+ .wizard-nav { display: flex; align-items: center; justify-content: space-between; margin-top: var(--space-8); }
408
+ .wizard-counter { font-size: var(--text-sm); color: var(--color-text-muted); font-weight: var(--font-medium); }
409
+ .wiz-shake { animation: wiz-shake 0.45s; }
410
+ @keyframes wiz-shake { 0%,100%{transform:translateX(0)} 20%,60%{transform:translateX(-7px)} 40%,80%{transform:translateX(7px)} }
411
+
412
+ /* ---------- Dashboard ---------- */
413
+ .dash-loading, .dash-error { text-align: center; color: var(--color-text-muted); padding: var(--space-8); }
414
+ .dash-error { color: var(--color-accent-dark); }
415
+ .kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8); }
416
+ .kpi { background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-6); box-shadow: var(--shadow-sm); }
417
+ .kpi-accent { background: var(--color-ink); color: #fff; border-color: var(--color-ink); }
418
+ .kpi-big { font-family: var(--font-display); font-size: var(--text-3xl); font-weight: var(--font-bold); letter-spacing: -0.02em; line-height: 1.1; }
419
+ .kpi-lbl { font-size: var(--text-sm); color: var(--color-text-secondary); margin-top: var(--space-2); }
420
+ .kpi-accent .kpi-lbl { color: var(--color-sage); }
421
+ .kpi-cite { font-size: var(--text-xs); color: var(--color-text-muted); margin-top: var(--space-2); }
422
+ .kpi-accent .kpi-cite { color: rgba(255,255,255,0.55); }
423
+ .cell-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--space-4); }
424
+ .cell-card { background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-2); }
425
+ .cell-head { font-weight: var(--font-semibold); font-size: var(--text-base); line-height: var(--leading-snug); }
426
+ .cell-measure { font-size: var(--text-lg); }
427
+ .cell-range { font-size: var(--text-sm); color: var(--color-text-secondary); }
428
+ .cell-proj { font-size: var(--text-sm); color: var(--color-accent); font-weight: var(--font-medium); }
429
+ .cell-foot { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); margin-top: var(--space-2); }
430
+ .cell-tier { font-size: var(--text-xs); font-weight: var(--font-semibold); padding: 2px 8px; border-radius: var(--radius-pill); background: var(--color-sage-light); color: var(--color-accent-dark); }
431
+ .tier-mean_only, .tier-suppressed { background: #F3E6DE; color: #7A4A2E; }
432
+ .cell-cite { font-size: var(--text-xs); color: var(--color-text-muted); }
433
+ .dash-notes { max-width: var(--max-width-text); margin: var(--space-8) auto 0; padding-left: var(--space-5); color: var(--color-text-secondary); font-size: var(--text-sm); line-height: var(--leading-normal); }
434
+ .dash-notes li { margin-bottom: var(--space-2); }
435
+
436
+ /* ---------- Chat seeds ---------- */
437
+ .chat-seeds { display: flex; flex-wrap: wrap; gap: var(--space-2); padding: var(--space-5) var(--space-6) 0; }
438
+ .seed-chip {
439
+ font: inherit; font-size: var(--text-sm); cursor: pointer;
440
+ background: var(--color-sage-light); color: var(--color-accent-dark);
441
+ border: 1px solid var(--color-sage-deep); border-radius: var(--radius-pill);
442
+ padding: var(--space-2) var(--space-4); transition: background var(--transition-base);
443
+ }
444
+ .seed-chip:hover { background: var(--color-sage); }
445
+ .disclaimer-inline { font-style: italic; }
446
+
447
  /* ---------- Responsive ---------- */
448
  @media (max-width: 900px) {
449
  .hero .container { grid-template-columns: 1fr; gap: var(--space-10); }