Commit ·
329142e
1
Parent(s): d5643e9
Readme + frontend graphs
Browse files- README.md +77 -61
- src/frontend/static/app.js +19 -15
- src/frontend/static/styles.css +6 -0
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title: Pay Equity For
|
| 3 |
emoji: 🐨
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: indigo
|
|
@@ -9,90 +9,106 @@ python_version: '3.12'
|
|
| 9 |
app_file: main.py
|
| 10 |
pinned: false
|
| 11 |
license: apache-2.0
|
| 12 |
-
short_description:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
---
|
| 14 |
|
| 15 |
# Pay Equity for EU
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
|
|
|
| 19 |
|
| 20 |
-
##
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
details: [`src/backend/README.md`](src/backend/README.md).
|
| 29 |
|
| 30 |
-
##
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
Produces `data/processed/index/index.faiss` + `chunks.jsonl`.
|
| 40 |
|
| 41 |
-
##
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
```bash
|
| 46 |
-
uv run python scripts/refresh_metadata.py
|
| 47 |
-
```
|
| 48 |
|
| 49 |
-
|
| 50 |
-
order changed.
|
| 51 |
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
15 ground-truth questions in `scripts/eval/benchmark_questions.json`.
|
| 56 |
|
| 57 |
-
**
|
| 58 |
-
|
| 59 |
-
chunks (CPU-only, ~few minutes):
|
| 60 |
|
| 61 |
-
|
| 62 |
-
uv run python scripts/eval/build_local_index.py
|
| 63 |
-
```
|
| 64 |
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
-
|
| 68 |
-
# Full run: retrieval metrics + generation fact/citation checks (default k=5)
|
| 69 |
-
uv run python scripts/eval/run_benchmark.py
|
| 70 |
|
| 71 |
-
|
| 72 |
-
uv run python scripts/eval/run_benchmark.py --no-generation
|
| 73 |
|
| 74 |
-
#
|
| 75 |
-
uv run python scripts/eval/run_benchmark.py --k 10
|
| 76 |
-
```
|
| 77 |
|
| 78 |
-
|
| 79 |
-
`scripts/eval/results/benchmark_YYYYMMDD_HHMMSS.json`.
|
| 80 |
|
| 81 |
-
|
| 82 |
-
presence (generation). See [`scripts/eval/BENCHMARK_FINDINGS.md`](scripts/eval/BENCHMARK_FINDINGS.md)
|
| 83 |
-
for the latest scored run and failure analysis.
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
|
| 90 |
-
|
|
|
|
|
|
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|---|---|
|
| 94 |
-
| `data/processed/directive/` | Parsed EU Directive 2023/970 |
|
| 95 |
-
| `data/processed/lonstatistik/` | IDA + Djøf salary statistics ([README](data/processed/lonstatistik/README.md)) |
|
| 96 |
-
| `data/processed/index/` | FAISS index + aligned `chunks.jsonl` |
|
| 97 |
|
| 98 |
-
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Pay Equity For EU
|
| 3 |
emoji: 🐨
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: indigo
|
|
|
|
| 9 |
app_file: main.py
|
| 10 |
pinned: false
|
| 11 |
license: apache-2.0
|
| 12 |
+
short_description: Know what you should earn, and what the law says you can ask.
|
| 13 |
+
tags:
|
| 14 |
+
- build-small-hackathon
|
| 15 |
+
- backyard-ai
|
| 16 |
+
- gradio
|
| 17 |
+
- rag
|
| 18 |
+
- multilingual
|
| 19 |
+
- tiny-model
|
| 20 |
+
- tiny-titan
|
| 21 |
+
- off-brand
|
| 22 |
+
- nemotron
|
| 23 |
+
- pay-equity
|
| 24 |
+
- legal-tech
|
| 25 |
+
- denmark
|
| 26 |
---
|
| 27 |
|
| 28 |
# Pay Equity for EU
|
| 29 |
|
| 30 |
+
A grounded, multilingual chat assistant that answers two questions Danish
|
| 31 |
+
graduates ask every job offer: **"What pay should I expect?"** and **"What does
|
| 32 |
+
the law actually let me ask for?"**
|
| 33 |
|
| 34 |
+
## The problem
|
| 35 |
|
| 36 |
+
In Denmark, salary negotiation is famously opaque. New graduates rarely know
|
| 37 |
+
what the market pays, and the data that *does* exist sits behind union paywalls
|
| 38 |
+
in long PDFs. On top of that, the EU recently passed the **Pay Transparency
|
| 39 |
+
Directive (2023/970)**, which gives every employee the right to ask their
|
| 40 |
+
employer for pay information, gender pay-gap reports, and more. The catch: it's
|
| 41 |
+
80 pages of legalese, in 24 languages, and almost no one has read it. So people
|
| 42 |
+
sign offers without knowing either the number or the rights they already have.
|
| 43 |
|
| 44 |
+
This app fixes both, in one chat.
|
|
|
|
| 45 |
|
| 46 |
+
## What it does
|
| 47 |
|
| 48 |
+
- **Expected pay** — deterministic lookup over parsed IDA *lønstatistik*
|
| 49 |
+
(salary statistics from the engineers' union), broken down by sector,
|
| 50 |
+
experience, and role. No hallucinations: numbers come straight from the
|
| 51 |
+
union's published tables.
|
| 52 |
+
- **Your rights** — RAG over the EU Pay Transparency Directive in EN / DA / FR,
|
| 53 |
+
with article-level citations on every claim (e.g. *"You can request the
|
| 54 |
+
average pay for your role broken down by gender (Art. 7(1))"*).
|
| 55 |
+
- **Private document review** — session-only upload of a contract or payslip,
|
| 56 |
+
never persisted, never sent to a third-party API.
|
| 57 |
|
| 58 |
+
Currently scoped to **IDA** (engineers / software graduates — the author and
|
| 59 |
+
his friends). Djøf (law, business, economics, social science) is the obvious
|
| 60 |
+
next union, with the same plumbing.
|
|
|
|
|
|
|
| 61 |
|
| 62 |
+
## Hackathon track
|
| 63 |
|
| 64 |
+
**Backyard AI** — practical, problem-solving, runs on hardware you own, built
|
| 65 |
+
for a real circle of people (Danish graduates job-hunting right now).
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
+
## Tech stack
|
|
|
|
| 68 |
|
| 69 |
+
| Layer | Choice | Why |
|
| 70 |
+
|---|---|---|
|
| 71 |
+
| Generation LLM | **Cohere Tiny Aya 3B** | Multilingual (EN/DA/FR), under the Tiny Titan 4B cap |
|
| 72 |
+
| Swap-in LLM | OpenBMB MiniCPM3-4B | One-line swap via `LLMClient` interface |
|
| 73 |
+
| Embeddings | **NVIDIA Nemotron-Embed 1B v2** | Strong multilingual retrieval, ungated |
|
| 74 |
+
| Retrieval | FAISS IndexFlatIP + per-source top-k + Reciprocal Rank Fusion | Keeps salary numbers and legal text from drowning each other |
|
| 75 |
+
| Runtime | Gradio Server + ZeroGPU on HF Spaces | Single `main.py`, FastAPI-style endpoints exposed alongside the chat UI |
|
| 76 |
+
| Theme | Custom Scandinavian sage palette via `gr.themes.Soft` + Bricolage Grotesque / Inter | Goes well beyond default Gradio look |
|
| 77 |
|
| 78 |
+
All model revisions are pinned by SHA in `configs/models.yaml`. No `latest`.
|
|
|
|
| 79 |
|
| 80 |
+
Total parameter footprint: **~4B** (Tiny Aya 3B + Nemotron-Embed 1B), well
|
| 81 |
+
under the 32B cap.
|
|
|
|
| 82 |
|
| 83 |
+
## Why small models matter here
|
|
|
|
|
|
|
| 84 |
|
| 85 |
+
Legal advice and salary numbers are exactly the domain where you don't want a
|
| 86 |
+
big confident model to free-associate. Grounding the small model on parsed
|
| 87 |
+
union tables and article-chunked directive text means every answer can be
|
| 88 |
+
checked against a citation. A small, transparent stack is the *right* tool for
|
| 89 |
+
"does this sentence come from the law or did the model make it up?"
|
| 90 |
|
| 91 |
+
## Demo video
|
|
|
|
|
|
|
| 92 |
|
| 93 |
+
[Watch the demo](TODO_YOUTUBE_URL)
|
|
|
|
| 94 |
|
| 95 |
+
## Social post
|
|
|
|
|
|
|
| 96 |
|
| 97 |
+
[Read the post](TODO_SOCIAL_URL)
|
|
|
|
| 98 |
|
| 99 |
+
## Run it locally
|
|
|
|
|
|
|
| 100 |
|
| 101 |
+
```bash
|
| 102 |
+
uv sync
|
| 103 |
+
uv run python main.py
|
| 104 |
+
```
|
| 105 |
|
| 106 |
+
Requires a built vector index at `data/processed/index/`. See
|
| 107 |
+
[`src/backend/README.md`](src/backend/README.md) for the RAG pipeline and
|
| 108 |
+
[`scripts/eval/`](scripts/eval/) for the offline benchmark.
|
| 109 |
|
| 110 |
+
## Disclaimer
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
+
This app surfaces public salary statistics and the published text of EU
|
| 113 |
+
Directive 2023/970. It is **not legal advice**. For binding interpretation,
|
| 114 |
+
talk to your union or a lawyer.
|
src/frontend/static/app.js
CHANGED
|
@@ -666,13 +666,13 @@ function bellCurveSVG(cell, ug, floor) {
|
|
| 666 |
</div>`;
|
| 667 |
}
|
| 668 |
|
| 669 |
-
function
|
| 670 |
-
if (pct == null) return "";
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
<svg viewBox="0 0 ${S} ${S}" class="chart" role="img">
|
| 677 |
<circle cx="${cx}" cy="${cy}" r="${r}" class="ga-track" />
|
| 678 |
<circle cx="${cx}" cy="${cy}" r="${r}" class="ga-fill"
|
|
@@ -683,8 +683,17 @@ function gaugeSVG(pct) {
|
|
| 683 |
</svg>
|
| 684 |
<p class="chart-cap">Out of 100 comparable engineers, about <strong>${Math.round(
|
| 685 |
pct
|
| 686 |
-
)}</strong> earn less than you.</p>
|
| 687 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
}
|
| 689 |
|
| 690 |
function barsSVG(d, ug) {
|
|
@@ -739,14 +748,9 @@ function renderVisualDash(d) {
|
|
| 739 |
|
| 740 |
html += `<div class="chart-grid">`;
|
| 741 |
if (curveCell) html += bellCurveSVG(curveCell, ug, d.recommended_floor);
|
| 742 |
-
|
| 743 |
html += `</div>`;
|
| 744 |
html += barsSVG(d, ug);
|
| 745 |
-
|
| 746 |
-
if (d.projected_negotiation_target != null)
|
| 747 |
-
html += `<p class="chart-cap chart-cap-wide">A fair ask for 2026: <strong>${dkk(
|
| 748 |
-
d.projected_negotiation_target
|
| 749 |
-
)}</strong> — based on IDA's expected pay rises.</p>`;
|
| 750 |
return html;
|
| 751 |
}
|
| 752 |
|
|
|
|
| 666 |
</div>`;
|
| 667 |
}
|
| 668 |
|
| 669 |
+
function standingCard(pct, target) {
|
| 670 |
+
if (pct == null && target == null) return "";
|
| 671 |
+
let inner = "";
|
| 672 |
+
if (pct != null) {
|
| 673 |
+
const S = 150, r = 56, cx = S / 2, cy = S / 2, C = 2 * Math.PI * r;
|
| 674 |
+
const frac = Math.max(0, Math.min(100, pct)) / 100;
|
| 675 |
+
inner += `
|
| 676 |
<svg viewBox="0 0 ${S} ${S}" class="chart" role="img">
|
| 677 |
<circle cx="${cx}" cy="${cy}" r="${r}" class="ga-track" />
|
| 678 |
<circle cx="${cx}" cy="${cy}" r="${r}" class="ga-fill"
|
|
|
|
| 683 |
</svg>
|
| 684 |
<p class="chart-cap">Out of 100 comparable engineers, about <strong>${Math.round(
|
| 685 |
pct
|
| 686 |
+
)}</strong> earn less than you.</p>`;
|
| 687 |
+
}
|
| 688 |
+
if (target != null) {
|
| 689 |
+
inner += `
|
| 690 |
+
<div class="standing-ask">
|
| 691 |
+
<div class="standing-ask-lbl">Typical pay to ask (2026)</div>
|
| 692 |
+
<div class="standing-ask-big">${dkk(target)}</div>
|
| 693 |
+
<div class="standing-ask-sub">based on IDA's expected pay rises</div>
|
| 694 |
+
</div>`;
|
| 695 |
+
}
|
| 696 |
+
return `<div class="chart-card chart-gauge"><h4 class="chart-title">Your standing</h4>${inner}</div>`;
|
| 697 |
}
|
| 698 |
|
| 699 |
function barsSVG(d, ug) {
|
|
|
|
| 748 |
|
| 749 |
html += `<div class="chart-grid">`;
|
| 750 |
if (curveCell) html += bellCurveSVG(curveCell, ug, d.recommended_floor);
|
| 751 |
+
html += standingCard(pct, d.projected_negotiation_target);
|
| 752 |
html += `</div>`;
|
| 753 |
html += barsSVG(d, ug);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 754 |
return html;
|
| 755 |
}
|
| 756 |
|
src/frontend/static/styles.css
CHANGED
|
@@ -479,6 +479,12 @@ section { padding: var(--section-padding-y) 0; }
|
|
| 479 |
.ga-big { fill: var(--color-ink); font-size: 30px; font-weight: 700; font-family: var(--font-display); }
|
| 480 |
.ga-sub { fill: var(--color-text-muted); font-size: 12px; font-family: var(--font-sans); }
|
| 481 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
/* horizontal comparison bars */
|
| 483 |
.vbar-wrap { position: relative; display: flex; flex-direction: column; gap: var(--space-4); padding-top: var(--space-5); }
|
| 484 |
.vbar-row { display: flex; flex-direction: column; gap: 4px; }
|
|
|
|
| 479 |
.ga-big { fill: var(--color-ink); font-size: 30px; font-weight: 700; font-family: var(--font-display); }
|
| 480 |
.ga-sub { fill: var(--color-text-muted); font-size: 12px; font-family: var(--font-sans); }
|
| 481 |
|
| 482 |
+
/* highlighted "typical pay to ask" inside the standing card */
|
| 483 |
+
.standing-ask { margin-top: var(--space-5); width: 100%; background: var(--color-sage-light); border: 1px solid var(--color-sage-deep); border-radius: var(--radius-md); padding: var(--space-4) var(--space-5); }
|
| 484 |
+
.standing-ask-lbl { font-size: var(--text-xs); font-weight: var(--font-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
|
| 485 |
+
.standing-ask-big { font-family: var(--font-display); font-size: var(--text-2xl); font-weight: var(--font-bold); color: var(--color-accent-dark); letter-spacing: -0.01em; line-height: 1.15; }
|
| 486 |
+
.standing-ask-sub { font-size: var(--text-xs); color: var(--color-text-muted); }
|
| 487 |
+
|
| 488 |
/* horizontal comparison bars */
|
| 489 |
.vbar-wrap { position: relative; display: flex; flex-direction: column; gap: var(--space-4); padding-top: var(--space-5); }
|
| 490 |
.vbar-row { display: flex; flex-direction: column; gap: 4px; }
|