Franskaman110 commited on
Commit
329142e
·
1 Parent(s): d5643e9

Readme + frontend graphs

Browse files
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Pay Equity For Eu
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: Multilingual AI assistant to guarantee fair pay
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  ---
14
 
15
  # Pay Equity for EU
16
 
17
- Multilingual Gradio Space for Danish users: **expected pay** (IDA lønstatistik)
18
- and **pay-transparency rights** (EU Directive 2023/970), grounded with citations.
 
19
 
20
- ## Quick start
21
 
22
- ```bash
23
- uv sync
24
- uv run python main.py
25
- ```
 
 
 
26
 
27
- Requires a built vector index at `data/processed/index/` (see below). Backend
28
- details: [`src/backend/README.md`](src/backend/README.md).
29
 
30
- ## Build the vector index
31
 
32
- Embed the corpus once (directive articles + IDA salary rows), then commit the
33
- output:
 
 
 
 
 
 
 
34
 
35
- ```bash
36
- uv run python scripts/build_index.py # GPU / ZeroGPU Space admin button
37
- ```
38
-
39
- Produces `data/processed/index/index.faiss` + `chunks.jsonl`.
40
 
41
- ### Refresh metadata only (no re-embed)
42
 
43
- After changing `Chunk.metadata` mapping in `src/backend/indexing/ingest.py`:
44
-
45
- ```bash
46
- uv run python scripts/refresh_metadata.py
47
- ```
48
 
49
- Rewrites `chunks.jsonl` only; vectors stay unchanged. Re-embed if chunk ids or
50
- order changed.
51
 
52
- ## Run the RAG benchmark
 
 
 
 
 
 
 
53
 
54
- Offline evaluation of retrieval (and lightweight generation checks) against
55
- 15 ground-truth questions in `scripts/eval/benchmark_questions.json`.
56
 
57
- **Prerequisites:** committed `chunks.jsonl` plus a local `index.faiss`. If
58
- `index.faiss` is missing or a Git LFS pointer, rebuild it from the committed
59
- chunks (CPU-only, ~few minutes):
60
 
61
- ```bash
62
- uv run python scripts/eval/build_local_index.py
63
- ```
64
 
65
- **Run the benchmark:**
 
 
 
 
66
 
67
- ```bash
68
- # Full run: retrieval metrics + generation fact/citation checks (default k=5)
69
- uv run python scripts/eval/run_benchmark.py
70
 
71
- # Retrieval only (faster)
72
- uv run python scripts/eval/run_benchmark.py --no-generation
73
 
74
- # Custom top-k
75
- uv run python scripts/eval/run_benchmark.py --k 10
76
- ```
77
 
78
- **Output:** human-readable summary on stdout + JSON report at
79
- `scripts/eval/results/benchmark_YYYYMMDD_HHMMSS.json`.
80
 
81
- **Metrics:** Recall@k, MRR, NDCG@k (retrieval); fact coverage and citation
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
- > **Note:** The benchmark script uses a single global FAISS top-k search, not
86
- > the production per-source + RRF fusion in `rag.py`. Use it to track embedding
87
- > quality; for end-to-end production behaviour, test via the Gradio UI or
88
- > `/chat` API.
89
 
90
- ## Data
 
 
91
 
92
- | Path | Contents |
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
- Raw PDFs live under `data/raw/` (gitignored).
 
 
 
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 gaugeSVG(pct) {
670
- if (pct == null) return "";
671
- const S = 150, r = 56, cx = S / 2, cy = S / 2, C = 2 * Math.PI * r;
672
- const frac = Math.max(0, Math.min(100, pct)) / 100;
673
- return `
674
- <div class="chart-card chart-gauge">
675
- <h4 class="chart-title">Your standing</h4>
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
- </div>`;
 
 
 
 
 
 
 
 
 
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
- if (pct != null) html += gaugeSVG(pct);
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; }