Commit ·
1e6fa10
1
Parent(s): 68c07e1
Frontend
Browse files- README.md +9 -6
- src/backend/README.md +10 -9
- src/backend/ui.py +0 -149
- src/frontend/README.md +58 -20
- src/frontend/static/app.js +15 -2
- src/frontend/static/styles.css +2 -0
README.md
CHANGED
|
@@ -27,7 +27,7 @@ tags:
|
|
| 27 |
|
| 28 |
# Pay Equity for EU
|
| 29 |
|
| 30 |
-
A grounded,
|
| 31 |
graduates ask every job offer: **"What pay should I expect?"** and **"What does
|
| 32 |
the law actually let me ask for?"**
|
| 33 |
|
|
@@ -49,9 +49,11 @@ This app fixes both, in one chat.
|
|
| 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
|
| 53 |
-
|
| 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 |
|
|
@@ -72,8 +74,9 @@ for a real circle of people (Danish graduates job-hunting right now).
|
|
| 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 |
|
| 76 |
-
|
|
|
|
|
| 77 |
|
| 78 |
All model revisions are pinned by SHA in `configs/models.yaml`. No `latest`.
|
| 79 |
|
|
|
|
| 27 |
|
| 28 |
# Pay Equity for EU
|
| 29 |
|
| 30 |
+
A grounded, citation-first 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 |
|
|
|
|
| 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 with
|
| 53 |
+
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))"*). Answers are
|
| 55 |
+
in **English** in v1; the directive text is already parsed in DA / FR and the
|
| 56 |
+
generation model is multilingual, so localized answers are a fast follow.
|
| 57 |
- **Private document review** — session-only upload of a contract or payslip,
|
| 58 |
never persisted, never sent to a third-party API.
|
| 59 |
|
|
|
|
| 74 |
| Swap-in LLM | OpenBMB MiniCPM3-4B | One-line swap via `LLMClient` interface |
|
| 75 |
| Embeddings | **NVIDIA Nemotron-Embed 1B v2** | Strong multilingual retrieval, ungated |
|
| 76 |
| Retrieval | FAISS IndexFlatIP + per-source top-k + Reciprocal Rank Fusion | Keeps salary numbers and legal text from drowning each other |
|
| 77 |
+
| Runtime | Gradio Server + ZeroGPU on HF Spaces | FastAPI-style `@app.api` endpoints behind a custom JS frontend |
|
| 78 |
+
| Frontend | **Hand-authored HTML / CSS / JS SPA** (no Gradio Blocks at the root) | Onboarding wizard → profile-driven dashboard with hand-drawn SVG charts (bell curve, gauge, comparison bars) → contextual rights chat with seeded questions, served on top of `gradio.Server` via the JS client |
|
| 79 |
+
| Type / palette | Bricolage Grotesque + Inter on a sage / ink Scandinavian palette | Goes well beyond default Gradio look |
|
| 80 |
|
| 81 |
All model revisions are pinned by SHA in `configs/models.yaml`. No `latest`.
|
| 82 |
|
src/backend/README.md
CHANGED
|
@@ -4,7 +4,9 @@ A retrieval-augmented chat assistant over two sources — **IDA salary
|
|
| 4 |
statistics** (engineers) and the **EU Pay Transparency Directive 2023/970** —
|
| 5 |
served through a [Gradio Server](https://huggingface.co/blog/introducing-gradio-server).
|
| 6 |
`build_server()` (in `server.py`) creates the `gradio.Server`, registers the
|
| 7 |
-
backend API endpoints, and
|
|
|
|
|
|
|
| 8 |
|
| 9 |
## Start it
|
| 10 |
|
|
@@ -14,9 +16,9 @@ From the repo root:
|
|
| 14 |
uv run python main.py
|
| 15 |
```
|
| 16 |
|
| 17 |
-
`main.py`
|
| 18 |
-
|
| 19 |
-
|
| 20 |
|
| 21 |
Before first run, build the vector index (see **RAG pipeline** below).
|
| 22 |
|
|
@@ -53,13 +55,13 @@ selected by `llm.<role>.backend` (`tiny_aya` | `minicpm` | `llama_cpp` |
|
|
| 53 |
|
| 54 |
## Endpoints
|
| 55 |
|
| 56 |
-
Registered in `api/endpoints.py` via `@app.api(name=...)`
|
| 57 |
-
|
| 58 |
-
reachable at `/` for `gradio_client`.
|
| 59 |
|
| 60 |
| `api_name` | Input | Returns | Status |
|
| 61 |
|---|---|---|---|
|
| 62 |
| `/chat` | `messages: list[dict]`, `lang: str` | `ChatResponse` (reply + citations) | RAG-grounded |
|
|
|
|
| 63 |
| `/parse_directive` | `lang: str` | `list[Chunk]` | real (article chunks) |
|
| 64 |
| `/parse_lonstatistik` | `file: FileData`, `source: str` | `ParseResult` | placeholder |
|
| 65 |
| `/index_documents` | `source: str` | `IndexStatus` | reports loaded index |
|
|
@@ -80,8 +82,7 @@ print(r["reply"], r["citations"])
|
|
| 80 |
## Layout
|
| 81 |
|
| 82 |
```
|
| 83 |
-
server.py build_server() — Server + endpoints +
|
| 84 |
-
ui.py build_blocks(): sage-themed gr.Blocks ChatInterface + gr.api chat
|
| 85 |
rag.py answer() + single @spaces.GPU retrieve→generate function
|
| 86 |
config.py load_models_config() (configs/models.yaml) + get_secret()
|
| 87 |
schemas.py pydantic models (Chunk, ChatMessage/Request/Response, …)
|
|
|
|
| 4 |
statistics** (engineers) and the **EU Pay Transparency Directive 2023/970** —
|
| 5 |
served through a [Gradio Server](https://huggingface.co/blog/introducing-gradio-server).
|
| 6 |
`build_server()` (in `server.py`) creates the `gradio.Server`, registers the
|
| 7 |
+
backend API endpoints, and serves the **custom HTML/CSS/JS SPA** from
|
| 8 |
+
[`../frontend/`](../frontend/) at `/`. The SPA calls the registered
|
| 9 |
+
`@app.api` endpoints via the Gradio JS client.
|
| 10 |
|
| 11 |
## Start it
|
| 12 |
|
|
|
|
| 16 |
uv run python main.py
|
| 17 |
```
|
| 18 |
|
| 19 |
+
`main.py` calls `build_server().launch(...)`. The server prints a local URL
|
| 20 |
+
(default `http://127.0.0.1:7860`). Open it to use the SPA (wizard → dashboard
|
| 21 |
+
→ rights chat).
|
| 22 |
|
| 23 |
Before first run, build the vector index (see **RAG pipeline** below).
|
| 24 |
|
|
|
|
| 55 |
|
| 56 |
## Endpoints
|
| 57 |
|
| 58 |
+
Registered in `api/endpoints.py` via `@app.api(name=...)` and consumed by the
|
| 59 |
+
custom SPA in `../frontend/static/app.js` through the Gradio JS client.
|
|
|
|
| 60 |
|
| 61 |
| `api_name` | Input | Returns | Status |
|
| 62 |
|---|---|---|---|
|
| 63 |
| `/chat` | `messages: list[dict]`, `lang: str` | `ChatResponse` (reply + citations) | RAG-grounded |
|
| 64 |
+
| `/dashboard` | `profile: Profile` | `Dashboard` (KPIs + cells + projection) | profile-driven IDA lookup |
|
| 65 |
| `/parse_directive` | `lang: str` | `list[Chunk]` | real (article chunks) |
|
| 66 |
| `/parse_lonstatistik` | `file: FileData`, `source: str` | `ParseResult` | placeholder |
|
| 67 |
| `/index_documents` | `source: str` | `IndexStatus` | reports loaded index |
|
|
|
|
| 82 |
## Layout
|
| 83 |
|
| 84 |
```
|
| 85 |
+
server.py build_server() — Server + endpoints + serves the custom SPA at /
|
|
|
|
| 86 |
rag.py answer() + single @spaces.GPU retrieve→generate function
|
| 87 |
config.py load_models_config() (configs/models.yaml) + get_secret()
|
| 88 |
schemas.py pydantic models (Chunk, ChatMessage/Request/Response, …)
|
src/backend/ui.py
DELETED
|
@@ -1,149 +0,0 @@
|
|
| 1 |
-
"""Gradio-native chat UI (replaces the old custom HTML/JS frontend).
|
| 2 |
-
|
| 3 |
-
A free-text, grounded Q&A chat styled to match the project's sage/Scandinavian
|
| 4 |
-
look — implemented through Gradio's own theming + CSS rather than custom HTML.
|
| 5 |
-
The chat function calls the same shared ``rag.answer()`` as the ``/chat`` API
|
| 6 |
-
endpoint, so there is one grounding code path.
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
-
from __future__ import annotations
|
| 10 |
-
|
| 11 |
-
import gradio as gr
|
| 12 |
-
|
| 13 |
-
from .rag import answer, answer_stream, build_index
|
| 14 |
-
from .schemas import ChatMessage, ChatResponse
|
| 15 |
-
|
| 16 |
-
# The disclaimer is owned by ChatResponse; reuse its default as the UI footer.
|
| 17 |
-
_DISCLAIMER = ChatResponse.model_fields["disclaimer"].default
|
| 18 |
-
|
| 19 |
-
# --- palette (ported from the old static/styles.css :root tokens) ----------
|
| 20 |
-
SAGE = "#C4D5D3"
|
| 21 |
-
SAGE_LIGHT = "#E7EFEE"
|
| 22 |
-
INK = "#15211D"
|
| 23 |
-
ACCENT = "#1F4A3F"
|
| 24 |
-
|
| 25 |
-
SAGE_THEME = gr.themes.Soft(
|
| 26 |
-
# Editorial serif to match the Djøf lønstatistik look (Lora ≈ Djøf's warm,
|
| 27 |
-
# moderate-contrast Nordic serif, with proper ø/å glyphs).
|
| 28 |
-
font=[gr.themes.GoogleFont("Lora"), "Georgia", "serif"],
|
| 29 |
-
font_mono=[gr.themes.GoogleFont("Inter"), "monospace"],
|
| 30 |
-
).set(
|
| 31 |
-
body_background_fill="#FFFFFF",
|
| 32 |
-
body_text_color=INK,
|
| 33 |
-
block_title_text_color=INK,
|
| 34 |
-
button_primary_background_fill=INK,
|
| 35 |
-
button_primary_background_fill_hover=ACCENT,
|
| 36 |
-
button_primary_text_color="#FFFFFF",
|
| 37 |
-
input_background_fill="#FFFFFF",
|
| 38 |
-
block_border_color="#D7E0DD",
|
| 39 |
-
)
|
| 40 |
-
|
| 41 |
-
CUSTOM_CSS = """
|
| 42 |
-
@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..600&display=swap');
|
| 43 |
-
.gradio-container { font-family: 'Lora', Georgia, 'Times New Roman', serif; }
|
| 44 |
-
h1, h2, h3, .prose h1, .prose h2 {
|
| 45 |
-
font-family: 'Lora', Georgia, serif;
|
| 46 |
-
font-weight: 600;
|
| 47 |
-
letter-spacing: -0.01em;
|
| 48 |
-
}
|
| 49 |
-
#hero { background: #C4D5D3; border-radius: 16px; padding: 28px 32px; margin-bottom: 8px; }
|
| 50 |
-
#hero h1 { margin: 0 0 6px; font-size: 2.2rem; font-weight: 700; color: #15211D !important; }
|
| 51 |
-
#hero p { margin: 0; font-size: 1.05rem; color: #34423D !important; }
|
| 52 |
-
|
| 53 |
-
/* user bubble: white text on ink */
|
| 54 |
-
.message.user, .user .message-bubble-border, div[data-testid="user"] {
|
| 55 |
-
background: #15211D !important; color: #FFFFFF !important;
|
| 56 |
-
}
|
| 57 |
-
/* assistant bubble: dark ink text on sage-light — the readability fix.
|
| 58 |
-
The background was set but the text color was not, so it inherited the
|
| 59 |
-
dark theme's light text → light-on-light. Force ink on the bubble and all
|
| 60 |
-
its markdown children (paragraphs, bold "Sources", italic disclaimer). */
|
| 61 |
-
.message.bot, div[data-testid="bot"] {
|
| 62 |
-
background: #E7EFEE !important; color: #15211D !important;
|
| 63 |
-
}
|
| 64 |
-
.message.bot *, div[data-testid="bot"] * { color: #15211D !important; }
|
| 65 |
-
.message.bot a, div[data-testid="bot"] a {
|
| 66 |
-
color: #1F4A3F !important; text-decoration: underline;
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
.disclaimer { color: #6E7B77; font-size: 0.85rem; text-align: center; margin-top: 8px; }
|
| 70 |
-
footer { display: none !important; }
|
| 71 |
-
"""
|
| 72 |
-
|
| 73 |
-
_HERO = (
|
| 74 |
-
"<div id='hero'><h1>Fair pay, made clear.</h1>"
|
| 75 |
-
"<p>Ask about expected pay for engineers in Denmark, or your rights under "
|
| 76 |
-
"the EU Pay Transparency Directive (2023/970). Answers are grounded in "
|
| 77 |
-
"IDA salary statistics and the Directive, with citations.</p></div>"
|
| 78 |
-
)
|
| 79 |
-
|
| 80 |
-
# Pin the page to light mode so the sage/white theme renders as designed —
|
| 81 |
-
# Gradio otherwise follows the visitor's system dark mode. Runs once on load;
|
| 82 |
-
# only redirects if not already light, so no reload loop.
|
| 83 |
-
# NOTE: passed to ``launch(js=...)`` (Gradio 6 moved `js` off the Blocks ctor).
|
| 84 |
-
FORCE_LIGHT_JS = """
|
| 85 |
-
() => {
|
| 86 |
-
const u = new URL(window.location.href);
|
| 87 |
-
if (u.searchParams.get('__theme') !== 'light') {
|
| 88 |
-
u.searchParams.set('__theme', 'light');
|
| 89 |
-
window.location.replace(u.href);
|
| 90 |
-
}
|
| 91 |
-
}
|
| 92 |
-
"""
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def _chat_fn(message: str, history: list[dict], lang: str):
|
| 96 |
-
"""Streaming ChatInterface callback → grounded answer, then sources + disclaimer.
|
| 97 |
-
|
| 98 |
-
Yields the reply incrementally as tokens arrive (smooth UX), then a final
|
| 99 |
-
frame with the citation footer and disclaimer appended.
|
| 100 |
-
"""
|
| 101 |
-
msgs = [ChatMessage(role=h["role"], content=h["content"]) for h in history]
|
| 102 |
-
msgs.append(ChatMessage(role="user", content=message))
|
| 103 |
-
resp = ChatResponse(reply="", citations=[])
|
| 104 |
-
for resp in answer_stream(msgs, lang=lang):
|
| 105 |
-
yield resp.reply
|
| 106 |
-
footer = ""
|
| 107 |
-
if resp.citations:
|
| 108 |
-
footer += "\n\n**Sources:** " + " · ".join(resp.citations)
|
| 109 |
-
footer += f"\n\n_{resp.disclaimer}_"
|
| 110 |
-
yield resp.reply + footer
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
def _chat_api(messages: list[dict], lang: str = "en") -> dict:
|
| 114 |
-
"""Structured chat endpoint (api_name='chat') for gradio_client.
|
| 115 |
-
|
| 116 |
-
Same contract as before: ``{messages, lang}`` → ``{reply, citations,
|
| 117 |
-
disclaimer}``. Delegates to the shared ``answer()`` so the programmatic API
|
| 118 |
-
and the UI share one grounding path.
|
| 119 |
-
"""
|
| 120 |
-
msgs = [ChatMessage(role=m["role"], content=m["content"]) for m in messages]
|
| 121 |
-
return answer(msgs, lang=lang).model_dump()
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
def build_blocks() -> gr.Blocks:
|
| 125 |
-
"""Build the sage-themed chat UI (mounted at the site root)."""
|
| 126 |
-
with gr.Blocks(title="Pay Equity for EU") as demo:
|
| 127 |
-
gr.HTML(_HERO)
|
| 128 |
-
lang = gr.Dropdown(
|
| 129 |
-
choices=[("English", "en"), ("Dansk", "da"), ("Français", "fr")],
|
| 130 |
-
value="en",
|
| 131 |
-
label="Language",
|
| 132 |
-
)
|
| 133 |
-
gr.ChatInterface(
|
| 134 |
-
fn=_chat_fn,
|
| 135 |
-
additional_inputs=[lang],
|
| 136 |
-
api_name=False, # the structured gr.api below is the public chat API
|
| 137 |
-
examples=[
|
| 138 |
-
["What monthly salary should an experienced engineer expect?"],
|
| 139 |
-
["What information am I entitled to about pay at my workplace?"],
|
| 140 |
-
["Can my employer ask my salary history during hiring?"],
|
| 141 |
-
],
|
| 142 |
-
)
|
| 143 |
-
gr.Markdown(f"<p class='disclaimer'>{_DISCLAIMER}</p>")
|
| 144 |
-
with gr.Accordion("Admin", open=False):
|
| 145 |
-
build_btn = gr.Button("Build Index", variant="secondary")
|
| 146 |
-
build_status = gr.Textbox(label="Status", interactive=False)
|
| 147 |
-
build_btn.click(fn=build_index, inputs=[], outputs=[build_status])
|
| 148 |
-
gr.api(_chat_api, api_name="chat")
|
| 149 |
-
return demo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/frontend/README.md
CHANGED
|
@@ -1,34 +1,72 @@
|
|
| 1 |
# Frontend
|
| 2 |
|
| 3 |
-
|
| 4 |
-
`gr.ChatInterface`
|
| 5 |
-
|
| 6 |
-
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
## Start it
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
```bash
|
| 19 |
-
uv run python main.py
|
| 20 |
```
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
them); `styles.css` is kept only as the reference for the palette/fonts ported
|
| 27 |
-
into `ui.py`. These can be deleted in a follow-up cleanup.
|
| 28 |
|
| 29 |
## Editing
|
| 30 |
|
| 31 |
-
- **Layout / behavior:**
|
| 32 |
-
- **
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
| 1 |
# Frontend
|
| 2 |
|
| 3 |
+
A **hand-authored single-page app** in `index.html` + `static/` — no Gradio
|
| 4 |
+
Blocks at the root, no `gr.ChatInterface`. The page is served by
|
| 5 |
+
`build_server()` in `../backend/server.py`, which mounts `static/` and returns
|
| 6 |
+
`index.html` from `/`. The page connects back to the same origin via the Gradio
|
| 7 |
+
JS client (`@gradio/client`) and calls the registered `@app.api` endpoints.
|
| 8 |
|
| 9 |
+
## What's on the page
|
| 10 |
+
|
| 11 |
+
- **Hero** with a two-branch entry point: *I'm employed — benchmark my pay* /
|
| 12 |
+
*I'm job-seeking — prep for an offer*.
|
| 13 |
+
- **Onboarding wizard** (`#wizard`) — 9 questions, branch-aware, with a
|
| 14 |
+
per-step help text written in plain language. Salary entry supports either
|
| 15 |
+
a known total range or `base × (1 + perks%)`.
|
| 16 |
+
- **Dashboard** (`#dashboard`) — KPI row, comparison cards from real IDA
|
| 17 |
+
cells, and a "make it visual" toggle that swaps in hand-drawn SVG charts:
|
| 18 |
+
- Bell curve of your peer group with your pay marked and the percentile
|
| 19 |
+
computed via a JS-side normal CDF
|
| 20 |
+
- Circular gauge of your standing
|
| 21 |
+
- Horizontal comparison bars across all matched IDA cells
|
| 22 |
+
- **Rights chat** (`#rights`) — seeded with profile-aware suggested questions
|
| 23 |
+
(e.g. mode-, sector-, headcount-, gender-driven), session-only document
|
| 24 |
+
upload, inline citations and disclaimer rendered under each reply.
|
| 25 |
+
- **Language switcher** (EN / DA / FR) wired through `static/i18n.js`.
|
| 26 |
+
|
| 27 |
+
All charts are hand-rolled SVG (no charting library) and degrade gracefully
|
| 28 |
+
down to a single matched cell.
|
| 29 |
+
|
| 30 |
+
## Style system
|
| 31 |
+
|
| 32 |
+
`static/styles.css` defines a Scandinavian / Djøf-inspired token system:
|
| 33 |
+
|
| 34 |
+
- Sage hero (`#C4D5D3`), ink near-black (`#15211D`), deep-teal accent
|
| 35 |
+
(`#1F4A3F`), sage-light alt sections (`#E7EFEE`).
|
| 36 |
+
- Display: **Bricolage Grotesque**. Body: **Inter**. Both via Google Fonts.
|
| 37 |
+
- Pill, rounded, and editorial radii (`--radius-md/lg/xl/pill`) plus a custom
|
| 38 |
+
shadow scale and motion tokens.
|
| 39 |
+
|
| 40 |
+
This is the project's Off-Brand UI; Gradio's own theming engine is **not** used
|
| 41 |
+
on the served page.
|
| 42 |
|
| 43 |
## Start it
|
| 44 |
|
| 45 |
+
From the repo root:
|
| 46 |
|
| 47 |
```bash
|
| 48 |
+
uv run python main.py
|
| 49 |
```
|
| 50 |
|
| 51 |
+
`main.py` calls `build_server().launch(...)`, which serves `index.html` at `/`
|
| 52 |
+
and the API endpoints alongside it.
|
| 53 |
+
|
| 54 |
+
## API contract used by the SPA
|
| 55 |
+
|
| 56 |
+
| `api_name` | Used by |
|
| 57 |
+
|---|---|
|
| 58 |
+
| `/dashboard` | `submitProfile()` after the wizard |
|
| 59 |
+
| `/chat` | `sendMessage()` (rights chat) |
|
| 60 |
+
| `/parse_lonstatistik` | `uploadDocument()` (file upload) |
|
| 61 |
|
| 62 |
+
See `../backend/api/endpoints.py` for the registered endpoints and
|
| 63 |
+
`../backend/schemas.py` for the request/response models.
|
|
|
|
|
|
|
| 64 |
|
| 65 |
## Editing
|
| 66 |
|
| 67 |
+
- **Layout / wizard / chat behavior:** `static/app.js`.
|
| 68 |
+
- **Translations:** `static/i18n.js` (`en` populated; `da`/`fr` keys exist with
|
| 69 |
+
empty values, falling back to EN).
|
| 70 |
+
- **Style:** `static/styles.css` (token system at the top, sectioned by
|
| 71 |
+
component below).
|
| 72 |
+
- **Page shell:** `index.html`.
|
src/frontend/static/app.js
CHANGED
|
@@ -803,13 +803,21 @@ function appendMessage(role, content, citations = []) {
|
|
| 803 |
return el;
|
| 804 |
}
|
| 805 |
|
|
|
|
|
|
|
| 806 |
async function sendMessage() {
|
| 807 |
const text = chatInput.value.trim();
|
| 808 |
if (!text) return;
|
| 809 |
chatInput.value = "";
|
| 810 |
appendMessage("user", text);
|
| 811 |
messages.push({ role: "user", content: text });
|
| 812 |
-
const pending = appendMessage(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 813 |
try {
|
| 814 |
const c = await getClient();
|
| 815 |
const result = await c.predict("/chat", {
|
|
@@ -817,6 +825,8 @@ async function sendMessage() {
|
|
| 817 |
lang: window.i18n.lang,
|
| 818 |
});
|
| 819 |
const data = Array.isArray(result.data) ? result.data[0] : result.data;
|
|
|
|
|
|
|
| 820 |
pending.textContent = data.reply ?? String(data);
|
| 821 |
if (data.citations?.length) {
|
| 822 |
const cit = document.createElement("span");
|
|
@@ -832,7 +842,10 @@ async function sendMessage() {
|
|
| 832 |
}
|
| 833 |
messages.push({ role: "assistant", content: data.reply ?? "" });
|
| 834 |
} catch (err) {
|
| 835 |
-
pending.
|
|
|
|
|
|
|
|
|
|
| 836 |
}
|
| 837 |
}
|
| 838 |
|
|
|
|
| 803 |
return el;
|
| 804 |
}
|
| 805 |
|
| 806 |
+
let chatWarmed = false; // first reply loads the model on GPU — warn once.
|
| 807 |
+
|
| 808 |
async function sendMessage() {
|
| 809 |
const text = chatInput.value.trim();
|
| 810 |
if (!text) return;
|
| 811 |
chatInput.value = "";
|
| 812 |
appendMessage("user", text);
|
| 813 |
messages.push({ role: "user", content: text });
|
| 814 |
+
const pending = appendMessage(
|
| 815 |
+
"assistant",
|
| 816 |
+
chatWarmed
|
| 817 |
+
? "…"
|
| 818 |
+
: "Cold-starting the assistant — the first reply can take up to a minute while the model loads. Thanks for your patience…"
|
| 819 |
+
);
|
| 820 |
+
if (!chatWarmed) pending.classList.add("msg-warming");
|
| 821 |
try {
|
| 822 |
const c = await getClient();
|
| 823 |
const result = await c.predict("/chat", {
|
|
|
|
| 825 |
lang: window.i18n.lang,
|
| 826 |
});
|
| 827 |
const data = Array.isArray(result.data) ? result.data[0] : result.data;
|
| 828 |
+
chatWarmed = true;
|
| 829 |
+
pending.classList.remove("msg-warming");
|
| 830 |
pending.textContent = data.reply ?? String(data);
|
| 831 |
if (data.citations?.length) {
|
| 832 |
const cit = document.createElement("span");
|
|
|
|
| 842 |
}
|
| 843 |
messages.push({ role: "assistant", content: data.reply ?? "" });
|
| 844 |
} catch (err) {
|
| 845 |
+
pending.classList.remove("msg-warming");
|
| 846 |
+
pending.textContent =
|
| 847 |
+
"The assistant is still waking up or busy right now — please try again in a moment.";
|
| 848 |
+
console.error("chat error:", err);
|
| 849 |
}
|
| 850 |
}
|
| 851 |
|
src/frontend/static/styles.css
CHANGED
|
@@ -307,6 +307,8 @@ section { padding: var(--section-padding-y) 0; }
|
|
| 307 |
.msg.user { align-self: flex-end; background: var(--color-ink); color: #fff; border-bottom-right-radius: var(--radius-sm); }
|
| 308 |
.msg.assistant { align-self: flex-start; background: var(--color-sage-light); color: var(--color-text-primary); border-bottom-left-radius: var(--radius-sm); }
|
| 309 |
.msg.assistant .citations { display:block; margin-top: var(--space-2); font-size: var(--text-xs); color: var(--color-text-muted); }
|
|
|
|
|
|
|
| 310 |
.chat-input {
|
| 311 |
display: flex;
|
| 312 |
gap: var(--space-3);
|
|
|
|
| 307 |
.msg.user { align-self: flex-end; background: var(--color-ink); color: #fff; border-bottom-right-radius: var(--radius-sm); }
|
| 308 |
.msg.assistant { align-self: flex-start; background: var(--color-sage-light); color: var(--color-text-primary); border-bottom-left-radius: var(--radius-sm); }
|
| 309 |
.msg.assistant .citations { display:block; margin-top: var(--space-2); font-size: var(--text-xs); color: var(--color-text-muted); }
|
| 310 |
+
.msg.assistant.msg-warming { font-style: italic; color: var(--color-text-secondary); animation: warm-pulse 1.6s ease-in-out infinite; }
|
| 311 |
+
@keyframes warm-pulse { 0%,100% { opacity: 0.6; } 50% { opacity: 1; } }
|
| 312 |
.chat-input {
|
| 313 |
display: flex;
|
| 314 |
gap: var(--space-3);
|