Commit ·
bcbeac3
1
Parent(s): b59aea7
UI
Browse files- data/processed/index/chunks.jsonl +0 -0
- data/processed/index/index.faiss +2 -2
- data/processed/lonstatistik/curated_growth_forecast.jsonl +5 -0
- data/processed/lonstatistik/ida_records.jsonl +5 -0
- main.py +7 -4
- scripts/build_ida_records.py +36 -0
- src/backend/api/endpoints.py +13 -0
- src/backend/fusion.py +28 -0
- src/backend/indexing/ida_transform.py +4 -0
- src/backend/indexing/ingest.py +17 -22
- src/backend/schemas.py +14 -4
- src/backend/server.py +25 -10
- src/frontend/index.html +37 -46
- src/frontend/static/app.js +500 -29
- src/frontend/static/i18n.js +21 -17
- src/frontend/static/styles.css +72 -0
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:
|
| 3 |
-
size
|
|
|
|
| 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 |
-
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
-
from src.backend.
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 =
|
| 119 |
-
"""Load
|
| 120 |
|
| 121 |
-
Each
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 131 |
continue
|
| 132 |
page = rec.get("source_page")
|
|
|
|
|
|
|
|
|
|
| 133 |
chunks.append(
|
| 134 |
Chunk(
|
| 135 |
id=rec["id"],
|
| 136 |
text=rec["rag_text"],
|
| 137 |
-
lang="
|
| 138 |
source="lonstatistik",
|
| 139 |
page=page,
|
| 140 |
-
citation=
|
| 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
|
| 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 (
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
See: https://huggingface.co/blog/introducing-gradio-server
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
| 12 |
from gradio import Server
|
| 13 |
|
| 14 |
from .api.endpoints import register
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
def build_server() -> Server:
|
| 19 |
-
"""Create the configured Gradio Server with the
|
| 20 |
app = Server()
|
| 21 |
|
| 22 |
-
# Backend API endpoints (queue-managed, gradio_client-compatible).
|
| 23 |
register(app)
|
| 24 |
|
| 25 |
-
#
|
| 26 |
-
#
|
| 27 |
-
app =
|
| 28 |
-
|
| 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="#
|
| 23 |
-
<a href="#
|
| 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">
|
| 39 |
<p class="lead" data-i18n="hero.lead"></p>
|
| 40 |
<div class="hero-actions">
|
| 41 |
-
<button class="btn-primary" data-
|
| 42 |
-
|
| 43 |
</button>
|
| 44 |
-
<button class="btn-secondary" data-
|
| 45 |
-
|
| 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 |
-
<!--
|
| 65 |
-
<section id="
|
| 66 |
<div class="container">
|
| 67 |
-
<h2 class="section-title" data-i18n="
|
| 68 |
-
|
| 69 |
-
<
|
| 70 |
-
|
| 71 |
-
<div class="
|
| 72 |
-
|
| 73 |
-
<
|
| 74 |
-
<
|
| 75 |
-
|
| 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 |
-
<!--
|
| 96 |
-
<section id="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
<div class="container">
|
| 98 |
-
<h2 class="section-title" data-i18n="
|
|
|
|
| 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="#
|
| 140 |
-
<li><a href="#
|
| 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 |
-
//
|
| 2 |
-
//
|
|
|
|
|
|
|
| 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 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const fileInput = document.getElementById("file-input");
|
| 20 |
const fileName = document.getElementById("file-name");
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 36 |
-
|
| 37 |
return el;
|
| 38 |
}
|
| 39 |
|
| 40 |
async function sendMessage() {
|
| 41 |
-
const text =
|
| 42 |
if (!text) return;
|
| 43 |
-
|
| 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
|
| 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 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 9 |
"hero.lead":
|
| 10 |
-
"
|
| 11 |
-
"hero.
|
| 12 |
-
"hero.
|
| 13 |
-
"
|
| 14 |
-
|
| 15 |
-
"
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
"
|
| 19 |
-
|
| 20 |
-
"
|
| 21 |
-
"
|
| 22 |
-
|
| 23 |
-
"
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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); }
|