XQ commited on
Commit
9b053cd
·
1 Parent(s): c44bb5c

Update UI

Browse files
Files changed (2) hide show
  1. src/agent/router.py +61 -7
  2. src/ui/app.py +387 -54
src/agent/router.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import logging
4
  import math
 
5
 
6
  from langchain_core.runnables import Runnable
7
 
@@ -42,9 +43,55 @@ class QueryRouter:
42
  self._generator = generator
43
  self._translate_query = translate_query
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def _detect_and_translate_query(self, query: str) -> tuple[str, str]:
46
  """Detect the query language and optionally translate to Danish.
47
 
 
 
 
 
 
48
  Translation is only performed when ``self._translate_query`` is True and
49
  the detected language is not Danish.
50
 
@@ -56,13 +103,20 @@ class QueryRouter:
56
  retrieval_query is Danish when translation is enabled; otherwise the
57
  original query. detected_language is e.g. "English", "Danish".
58
  """
59
- prompt = (
60
- "Detect the language of the following text. "
61
- "Reply with ONLY the language name in English (e.g. 'Danish', 'English', 'German'). "
62
- "Nothing else.\n\n"
63
- f"Text: {query}"
64
- )
65
- detected = str(self._generator.invoke(prompt)).strip().strip(".")
 
 
 
 
 
 
 
66
  logger.info("Detected query language: %s", detected)
67
 
68
  if detected.lower() in ("danish", "dansk"):
 
2
 
3
  import logging
4
  import math
5
+ import unicodedata
6
 
7
  from langchain_core.runnables import Runnable
8
 
 
43
  self._generator = generator
44
  self._translate_query = translate_query
45
 
46
+ @staticmethod
47
+ def _detect_script(text: str) -> str | None:
48
+ """Detect language from Unicode script for non-Latin text.
49
+
50
+ Returns a language name (e.g. "Chinese") if the script is
51
+ unambiguously identifiable, or None to fall back to LLM detection.
52
+ """
53
+ script_counts: dict[str, int] = {}
54
+ for ch in text:
55
+ if ch.isspace() or ch in ".,!?;:\"'()[]{}":
56
+ continue
57
+ try:
58
+ name = unicodedata.name(ch, "")
59
+ except ValueError:
60
+ continue
61
+ if name.startswith("CJK") or name.startswith("KANGXI"):
62
+ script_counts["CJK"] = script_counts.get("CJK", 0) + 1
63
+ elif name.startswith("HIRAGANA") or name.startswith("KATAKANA"):
64
+ script_counts["Japanese"] = script_counts.get("Japanese", 0) + 1
65
+ elif name.startswith("HANGUL"):
66
+ script_counts["Korean"] = script_counts.get("Korean", 0) + 1
67
+ elif name.startswith("ARABIC"):
68
+ script_counts["Arabic"] = script_counts.get("Arabic", 0) + 1
69
+ elif name.startswith("DEVANAGARI"):
70
+ script_counts["Hindi"] = script_counts.get("Hindi", 0) + 1
71
+ elif name.startswith("THAI"):
72
+ script_counts["Thai"] = script_counts.get("Thai", 0) + 1
73
+ elif name.startswith("CYRILLIC"):
74
+ script_counts["Russian"] = script_counts.get("Russian", 0) + 1
75
+
76
+ if not script_counts:
77
+ return None
78
+
79
+ dominant = max(script_counts, key=lambda k: script_counts[k])
80
+ # CJK characters alone -> Chinese; if mixed with Hiragana/Katakana -> Japanese
81
+ if dominant == "CJK" and "Japanese" in script_counts:
82
+ return "Japanese"
83
+ if dominant == "CJK":
84
+ return "Chinese"
85
+ return dominant
86
+
87
  def _detect_and_translate_query(self, query: str) -> tuple[str, str]:
88
  """Detect the query language and optionally translate to Danish.
89
 
90
+ Uses Unicode script detection first for non-Latin scripts (Chinese,
91
+ Japanese, Korean, Arabic, etc.) which are reliably identifiable from
92
+ character ranges. Falls back to LLM-based detection for Latin-script
93
+ languages.
94
+
95
  Translation is only performed when ``self._translate_query`` is True and
96
  the detected language is not Danish.
97
 
 
103
  retrieval_query is Danish when translation is enabled; otherwise the
104
  original query. detected_language is e.g. "English", "Danish".
105
  """
106
+ # Fast path: detect non-Latin scripts via Unicode
107
+ detected = self._detect_script(query)
108
+
109
+ if detected is None:
110
+ # Latin-script text — use LLM for detection
111
+ prompt = (
112
+ "Detect the language of the following text. "
113
+ "Reply with ONLY the language name in English "
114
+ "(e.g. 'Danish', 'English', 'German'). "
115
+ "Nothing else.\n\n"
116
+ f"Text: {query}"
117
+ )
118
+ detected = str(self._generator.invoke(prompt)).strip().strip(".")
119
+
120
  logger.info("Detected query language: %s", detected)
121
 
122
  if detected.lower() in ("danish", "dansk"):
src/ui/app.py CHANGED
@@ -1,7 +1,7 @@
1
  """Streamlit frontend for Dokumentassistent.
2
 
3
  Calls the FastAPI backend at http://localhost:8000.
4
- Single-page document search interface styled after ku.dk design language.
5
  """
6
 
7
  import os
@@ -85,6 +85,23 @@ TEXTS: Dict[str, Dict[str, str]] = {
85
  "pipeline_rank": "#",
86
  "pipeline_no_results": "Ingen resultater",
87
  "pipeline_score_change": "Score-aendring",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  },
89
  "en": {
90
  "page_title": "Document Assistant",
@@ -155,6 +172,23 @@ TEXTS: Dict[str, Dict[str, str]] = {
155
  "pipeline_rank": "#",
156
  "pipeline_no_results": "No results",
157
  "pipeline_score_change": "Score change",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  },
159
  }
160
 
@@ -164,11 +198,11 @@ TEXTS: Dict[str, Dict[str, str]] = {
164
  st.set_page_config(
165
  page_title="Dokumentassistent",
166
  page_icon=None,
167
- layout="centered",
168
  )
169
 
170
  # ---------------------------------------------------------------------------
171
- # Custom CSS -- KU visual identity
172
  # ---------------------------------------------------------------------------
173
  st.markdown(
174
  """
@@ -180,104 +214,222 @@ st.markdown(
180
  background-color: #FFFFFF;
181
  }
182
 
183
- /* Hide default Streamlit branding but keep the sidebar toggle */
184
  #MainMenu, footer {visibility: hidden;}
185
- header[data-testid="stHeader"] {background: transparent;}
186
 
187
- /* ---------- Forankringslinje ---------- */
188
- .ku-line {
189
- width: 100%;
190
- height: 4px;
191
- background-color: #901A1E;
192
- margin-bottom: 1.5rem;
193
  }
194
 
195
- /* ---------- Title ---------- */
196
- .ku-title {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  font-family: Georgia, 'Times New Roman', serif;
198
- font-size: 2.2rem;
199
  font-weight: 700;
200
  color: #901A1E;
201
- margin: 0 0 0.4rem 0;
202
  letter-spacing: -0.02em;
203
  }
204
- .ku-subtitle {
 
 
 
 
 
205
  font-family: Arial, Helvetica, sans-serif;
206
- font-size: 1.05rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  color: #666666;
208
- margin: 0 0 2rem 0;
209
  line-height: 1.6;
210
  }
211
 
212
  /* ---------- Sidebar ---------- */
213
  section[data-testid="stSidebar"] {
214
- background-color: #FAFAFA;
215
  border-right: 1px solid #E0E0E0;
216
  }
217
- section[data-testid="stSidebar"] .ku-sidebar-heading {
218
  font-family: Georgia, 'Times New Roman', serif;
219
- font-size: 1.2rem;
220
  font-weight: 700;
221
- color: #901A1E;
222
  margin-bottom: 0.5rem;
 
 
223
  }
224
  section[data-testid="stSidebar"] p,
225
  section[data-testid="stSidebar"] li {
226
- font-size: 0.92rem;
227
  color: #555555;
228
  line-height: 1.55;
229
  }
230
 
231
  /* ---------- Source card ---------- */
232
  .source-card {
233
- border: 1px solid #CCCCCC;
 
 
 
234
  padding: 1rem 1.2rem;
235
  margin-bottom: 0.75rem;
236
  background-color: #FAFAFA;
 
 
 
 
237
  }
238
  .source-card-title {
239
  font-weight: 600;
240
- color: #333333;
241
  font-size: 0.95rem;
242
  margin-bottom: 0.3rem;
243
  }
244
  .source-card-text {
245
- font-size: 0.88rem;
246
  color: #555555;
247
  line-height: 1.55;
248
  }
249
  .source-card-meta {
250
- font-size: 0.8rem;
251
  color: #888888;
252
  margin-top: 0.4rem;
253
  }
254
 
255
  /* ---------- Result metadata ---------- */
256
  .result-meta {
257
- font-size: 0.88rem;
258
  color: #666666;
259
  margin-bottom: 1.2rem;
260
- padding-bottom: 0.8rem;
261
- border-bottom: 1px solid #E0E0E0;
 
262
  }
263
 
264
  /* ---------- Answer area ---------- */
265
  .answer-block {
266
- font-size: 1.05rem;
267
  line-height: 1.7;
268
  color: #333333;
269
  margin-bottom: 1.5rem;
 
 
 
270
  }
271
 
272
  /* ---------- Inputs ---------- */
273
  .stTextInput > div > div > input {
274
  border-radius: 0 !important;
275
- border: 1px solid #999999 !important;
276
  font-family: Arial, Helvetica, sans-serif !important;
 
277
  }
278
  .stTextInput > div > div > input:focus {
279
  border-color: #901A1E !important;
280
- box-shadow: none !important;
281
  }
282
 
283
  /* ---------- Button ---------- */
@@ -287,9 +439,11 @@ st.markdown(
287
  color: #FFFFFF !important;
288
  border: none !important;
289
  font-family: Arial, Helvetica, sans-serif !important;
290
- font-size: 0.95rem !important;
291
- padding: 0.5rem 2rem !important;
292
  letter-spacing: 0.02em;
 
 
293
  }
294
  .stButton > button:hover {
295
  background-color: #7A1619 !important;
@@ -308,38 +462,175 @@ st.markdown(
308
  border-radius: 0 !important;
309
  }
310
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  /* ---------- Expander ---------- */
312
  .streamlit-expanderHeader {
313
  font-family: Arial, Helvetica, sans-serif !important;
314
- font-size: 1rem !important;
315
  color: #333333 !important;
316
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  </style>
318
  """,
319
  unsafe_allow_html=True,
320
  )
321
 
322
  # ---------------------------------------------------------------------------
323
- # Language selector -- top-right corner via columns
324
  # ---------------------------------------------------------------------------
325
- _col_spacer, _col_lang = st.columns([5, 1])
326
- with _col_lang:
 
 
 
 
 
 
 
 
 
 
327
  lang = st.selectbox(
328
- "🌐",
329
  options=["da", "en"],
330
  format_func=lambda c: "Dansk" if c == "da" else "English",
331
- index=0,
332
  label_visibility="collapsed",
 
333
  )
 
334
 
335
  t = TEXTS[lang]
336
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  # ---------------------------------------------------------------------------
338
  # Sidebar
339
  # ---------------------------------------------------------------------------
340
  with st.sidebar:
341
  st.markdown(
342
- f'<div class="ku-sidebar-heading">{t["sidebar_heading"]}</div>',
343
  unsafe_allow_html=True,
344
  )
345
  st.markdown(t["sidebar_body"])
@@ -363,7 +654,6 @@ with st.sidebar:
363
 
364
  st.markdown("---")
365
 
366
- # Fetch and display current model info from backend
367
  try:
368
  _health = requests.get(f"{API_BASE}/health", timeout=5).json()
369
  _llm = _health.get("llm_model", "")
@@ -371,7 +661,7 @@ with st.sidebar:
371
  _emb = _health.get("embedding_model", "")
372
  _emb_prov = _health.get("embedding_provider", "")
373
  st.markdown(
374
- f'<div class="ku-sidebar-heading">{t["model_heading"]}</div>',
375
  unsafe_allow_html=True,
376
  )
377
  st.markdown(
@@ -382,16 +672,15 @@ with st.sidebar:
382
  st.caption(t["model_unavailable"])
383
 
384
  # ---------------------------------------------------------------------------
385
- # Main content
386
  # ---------------------------------------------------------------------------
387
-
388
- # Forankringslinje
389
- st.markdown('<div class="ku-line"></div>', unsafe_allow_html=True)
390
-
391
- # Title block
392
- st.markdown(f'<div class="ku-title">{t["title"]}</div>', unsafe_allow_html=True)
393
  st.markdown(
394
- f'<div class="ku-subtitle">{t["subtitle"]}</div>',
 
 
 
 
 
395
  unsafe_allow_html=True,
396
  )
397
 
@@ -496,6 +785,12 @@ if search_clicked and question.strip():
496
 
497
  st.markdown("---")
498
 
 
 
 
 
 
 
499
  def _render_result_table(results: list[dict], label: str) -> None:
500
  """Render a ranked results table."""
501
  st.markdown(f"**{label}**")
@@ -504,7 +799,7 @@ if search_clicked and question.strip():
504
  return
505
  header = f'| {t["pipeline_rank"]} | {t["pipeline_doc"]} | {t["pipeline_score"]} |\n|---|---|---|'
506
  rows = "\n".join(
507
- f'| {i + 1} | {r.get("document_id", "")} | {r.get("score", 0):.4f} |'
508
  for i, r in enumerate(results)
509
  )
510
  st.markdown(f"{header}\n{rows}")
@@ -548,7 +843,7 @@ if search_clicked and question.strip():
548
  else:
549
  change = "-"
550
  rows_list.append(
551
- f'| {i + 1} | {r.get("document_id", "")} | {new_score:.4f} | {change} |'
552
  )
553
  st.markdown(f"{header}\n" + "\n".join(rows_list))
554
  else:
@@ -556,3 +851,41 @@ if search_clicked and question.strip():
556
 
557
  elif search_clicked:
558
  st.warning(t["empty_warning"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Streamlit frontend for Dokumentassistent.
2
 
3
  Calls the FastAPI backend at http://localhost:8000.
4
+ Single-page document search interface inspired by Danish university design.
5
  """
6
 
7
  import os
 
85
  "pipeline_rank": "#",
86
  "pipeline_no_results": "Ingen resultater",
87
  "pipeline_score_change": "Score-aendring",
88
+ "nav_home": "Forside",
89
+ "nav_docs": "Dokumenter",
90
+ "nav_search": "Soegning",
91
+ "nav_about": "Om systemet",
92
+ "breadcrumb_home": "Forside",
93
+ "breadcrumb_current": "Dokumentassistent",
94
+ "footer_contact": "Kontakt",
95
+ "footer_contact_text": "IT-support for administrativt personale",
96
+ "footer_services": "Services",
97
+ "footer_service_api": "API-dokumentation",
98
+ "footer_service_status": "Systemstatus",
99
+ "footer_service_guide": "Brugervejledning",
100
+ "footer_about": "Om",
101
+ "footer_about_privacy": "Privatlivspolitik",
102
+ "footer_about_data": "Databehandling",
103
+ "footer_about_access": "Tilgaengelighed",
104
+ "footer_copyright": "Dokumentassistent — RAG-prototype",
105
  },
106
  "en": {
107
  "page_title": "Document Assistant",
 
172
  "pipeline_rank": "#",
173
  "pipeline_no_results": "No results",
174
  "pipeline_score_change": "Score change",
175
+ "nav_home": "Home",
176
+ "nav_docs": "Documents",
177
+ "nav_search": "Search",
178
+ "nav_about": "About",
179
+ "breadcrumb_home": "Home",
180
+ "breadcrumb_current": "Document Assistant",
181
+ "footer_contact": "Contact",
182
+ "footer_contact_text": "IT support for administrative staff",
183
+ "footer_services": "Services",
184
+ "footer_service_api": "API Documentation",
185
+ "footer_service_status": "System Status",
186
+ "footer_service_guide": "User Guide",
187
+ "footer_about": "About",
188
+ "footer_about_privacy": "Privacy Policy",
189
+ "footer_about_data": "Data Processing",
190
+ "footer_about_access": "Accessibility",
191
+ "footer_copyright": "Document Assistant — RAG Prototype",
192
  },
193
  }
194
 
 
198
  st.set_page_config(
199
  page_title="Dokumentassistent",
200
  page_icon=None,
201
+ layout="wide",
202
  )
203
 
204
  # ---------------------------------------------------------------------------
205
+ # Custom CSS -- University-inspired visual identity
206
  # ---------------------------------------------------------------------------
207
  st.markdown(
208
  """
 
214
  background-color: #FFFFFF;
215
  }
216
 
217
+ /* Hide default Streamlit branding */
218
  #MainMenu, footer {visibility: hidden;}
219
+ header[data-testid="stHeader"] {background: transparent; height: 0;}
220
 
221
+ /* Force hide Streamlit header space */
222
+ .stApp > header {display: none;}
223
+ .block-container {
224
+ padding-top: 0 !important;
225
+ max-width: 1100px;
 
226
  }
227
 
228
+ /* ---------- Top utility bar ---------- */
229
+ .top-utility-bar {
230
+ background-color: #2B2B2B;
231
+ color: #CCCCCC;
232
+ font-size: 0.78rem;
233
+ padding: 6px 0;
234
+ margin: -1rem -4rem 0 -4rem;
235
+ width: 100vw;
236
+ position: relative;
237
+ left: 50%;
238
+ transform: translateX(-50%);
239
+ }
240
+ .top-utility-inner {
241
+ max-width: 1100px;
242
+ margin: 0 auto;
243
+ padding: 0 2rem;
244
+ display: flex;
245
+ justify-content: flex-end;
246
+ align-items: center;
247
+ gap: 1.5rem;
248
+ }
249
+ .top-utility-bar a {
250
+ color: #CCCCCC;
251
+ text-decoration: none;
252
+ transition: color 0.15s;
253
+ }
254
+ .top-utility-bar a:hover {
255
+ color: #FFFFFF;
256
+ }
257
+ .utility-lang-switch {
258
+ border-left: 1px solid #555;
259
+ padding-left: 1.5rem;
260
+ }
261
+
262
+ /* ---------- Main navigation bar ---------- */
263
+ .main-nav-bar {
264
+ background-color: #FFFFFF;
265
+ border-bottom: 3px solid #901A1E;
266
+ margin: 0 -4rem;
267
+ width: 100vw;
268
+ position: relative;
269
+ left: 50%;
270
+ transform: translateX(-50%);
271
+ }
272
+ .main-nav-inner {
273
+ max-width: 1100px;
274
+ margin: 0 auto;
275
+ padding: 0.9rem 2rem;
276
+ display: flex;
277
+ justify-content: space-between;
278
+ align-items: center;
279
+ }
280
+ .nav-brand {
281
  font-family: Georgia, 'Times New Roman', serif;
282
+ font-size: 1.5rem;
283
  font-weight: 700;
284
  color: #901A1E;
285
+ text-decoration: none;
286
  letter-spacing: -0.02em;
287
  }
288
+ .nav-links {
289
+ display: flex;
290
+ gap: 2rem;
291
+ align-items: center;
292
+ }
293
+ .nav-links a {
294
  font-family: Arial, Helvetica, sans-serif;
295
+ font-size: 0.92rem;
296
+ color: #333333;
297
+ text-decoration: none;
298
+ font-weight: 500;
299
+ padding: 0.3rem 0;
300
+ border-bottom: 2px solid transparent;
301
+ transition: border-color 0.15s, color 0.15s;
302
+ }
303
+ .nav-links a:hover, .nav-links a.active {
304
+ color: #901A1E;
305
+ border-bottom-color: #901A1E;
306
+ }
307
+
308
+ /* ---------- Breadcrumbs ---------- */
309
+ .breadcrumbs {
310
+ font-size: 0.82rem;
311
+ color: #888888;
312
+ padding: 0.7rem 0 0.4rem 0;
313
+ margin-bottom: 0.2rem;
314
+ }
315
+ .breadcrumbs a {
316
+ color: #901A1E;
317
+ text-decoration: none;
318
+ }
319
+ .breadcrumbs a:hover {
320
+ text-decoration: underline;
321
+ }
322
+ .breadcrumbs .separator {
323
+ color: #AAAAAA;
324
+ margin: 0 0.4rem;
325
+ }
326
+
327
+ /* ---------- Page title area ---------- */
328
+ .page-title-area {
329
+ border-bottom: 1px solid #E0E0E0;
330
+ padding-bottom: 1.2rem;
331
+ margin-bottom: 1.8rem;
332
+ }
333
+ .page-title {
334
+ font-family: Georgia, 'Times New Roman', serif;
335
+ font-size: 2rem;
336
+ font-weight: 700;
337
+ color: #2B2B2B;
338
+ margin: 0.4rem 0 0.5rem 0;
339
+ letter-spacing: -0.02em;
340
+ line-height: 1.2;
341
+ }
342
+ .page-subtitle {
343
+ font-family: Arial, Helvetica, sans-serif;
344
+ font-size: 1rem;
345
  color: #666666;
346
+ margin: 0;
347
  line-height: 1.6;
348
  }
349
 
350
  /* ---------- Sidebar ---------- */
351
  section[data-testid="stSidebar"] {
352
+ background-color: #F7F7F7;
353
  border-right: 1px solid #E0E0E0;
354
  }
355
+ section[data-testid="stSidebar"] .sidebar-section-heading {
356
  font-family: Georgia, 'Times New Roman', serif;
357
+ font-size: 1.1rem;
358
  font-weight: 700;
359
+ color: #2B2B2B;
360
  margin-bottom: 0.5rem;
361
+ padding-bottom: 0.35rem;
362
+ border-bottom: 2px solid #901A1E;
363
  }
364
  section[data-testid="stSidebar"] p,
365
  section[data-testid="stSidebar"] li {
366
+ font-size: 0.88rem;
367
  color: #555555;
368
  line-height: 1.55;
369
  }
370
 
371
  /* ---------- Source card ---------- */
372
  .source-card {
373
+ border-left: 3px solid #901A1E;
374
+ border-top: 1px solid #E0E0E0;
375
+ border-right: 1px solid #E0E0E0;
376
+ border-bottom: 1px solid #E0E0E0;
377
  padding: 1rem 1.2rem;
378
  margin-bottom: 0.75rem;
379
  background-color: #FAFAFA;
380
+ transition: box-shadow 0.15s;
381
+ }
382
+ .source-card:hover {
383
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
384
  }
385
  .source-card-title {
386
  font-weight: 600;
387
+ color: #2B2B2B;
388
  font-size: 0.95rem;
389
  margin-bottom: 0.3rem;
390
  }
391
  .source-card-text {
392
+ font-size: 0.85rem;
393
  color: #555555;
394
  line-height: 1.55;
395
  }
396
  .source-card-meta {
397
+ font-size: 0.78rem;
398
  color: #888888;
399
  margin-top: 0.4rem;
400
  }
401
 
402
  /* ---------- Result metadata ---------- */
403
  .result-meta {
404
+ font-size: 0.85rem;
405
  color: #666666;
406
  margin-bottom: 1.2rem;
407
+ padding: 0.6rem 0.9rem;
408
+ background-color: #F7F7F7;
409
+ border-left: 3px solid #901A1E;
410
  }
411
 
412
  /* ---------- Answer area ---------- */
413
  .answer-block {
414
+ font-size: 1.02rem;
415
  line-height: 1.7;
416
  color: #333333;
417
  margin-bottom: 1.5rem;
418
+ padding: 1.2rem;
419
+ background-color: #FFFFFF;
420
+ border: 1px solid #E0E0E0;
421
  }
422
 
423
  /* ---------- Inputs ---------- */
424
  .stTextInput > div > div > input {
425
  border-radius: 0 !important;
426
+ border: 1px solid #BBBBBB !important;
427
  font-family: Arial, Helvetica, sans-serif !important;
428
+ padding: 0.6rem 0.8rem !important;
429
  }
430
  .stTextInput > div > div > input:focus {
431
  border-color: #901A1E !important;
432
+ box-shadow: 0 0 0 1px #901A1E !important;
433
  }
434
 
435
  /* ---------- Button ---------- */
 
439
  color: #FFFFFF !important;
440
  border: none !important;
441
  font-family: Arial, Helvetica, sans-serif !important;
442
+ font-size: 0.92rem !important;
443
+ padding: 0.55rem 2.2rem !important;
444
  letter-spacing: 0.02em;
445
+ font-weight: 600 !important;
446
+ text-transform: uppercase;
447
  }
448
  .stButton > button:hover {
449
  background-color: #7A1619 !important;
 
462
  border-radius: 0 !important;
463
  }
464
 
465
+ /* ---------- Pipeline tables ---------- */
466
+ [data-testid="stExpander"] table {
467
+ table-layout: fixed;
468
+ width: 100%;
469
+ word-break: break-all;
470
+ overflow-wrap: break-word;
471
+ }
472
+ [data-testid="stExpander"] table td,
473
+ [data-testid="stExpander"] table th {
474
+ overflow: hidden;
475
+ text-overflow: ellipsis;
476
+ max-width: 0;
477
+ font-size: 0.82rem;
478
+ padding: 0.3rem 0.5rem;
479
+ }
480
+ [data-testid="stExpander"] table td:first-child,
481
+ [data-testid="stExpander"] table th:first-child {
482
+ width: 2.5rem;
483
+ }
484
+ [data-testid="stExpander"] table td:last-child,
485
+ [data-testid="stExpander"] table th:last-child {
486
+ width: 5rem;
487
+ }
488
+
489
  /* ---------- Expander ---------- */
490
  .streamlit-expanderHeader {
491
  font-family: Arial, Helvetica, sans-serif !important;
492
+ font-size: 0.95rem !important;
493
  color: #333333 !important;
494
  }
495
+
496
+ /* ---------- Footer ---------- */
497
+ .site-footer {
498
+ background-color: #2B2B2B;
499
+ color: #CCCCCC;
500
+ margin: 3rem -4rem 0 -4rem;
501
+ width: 100vw;
502
+ position: relative;
503
+ left: 50%;
504
+ transform: translateX(-50%);
505
+ padding: 2.5rem 0 1.5rem 0;
506
+ }
507
+ .footer-inner {
508
+ max-width: 1100px;
509
+ margin: 0 auto;
510
+ padding: 0 2rem;
511
+ display: grid;
512
+ grid-template-columns: 2fr 1fr 1fr 1fr;
513
+ gap: 2rem;
514
+ }
515
+ .footer-col h4 {
516
+ font-family: Georgia, 'Times New Roman', serif;
517
+ font-size: 0.95rem;
518
+ font-weight: 700;
519
+ color: #FFFFFF;
520
+ margin: 0 0 0.8rem 0;
521
+ padding-bottom: 0.4rem;
522
+ border-bottom: 2px solid #901A1E;
523
+ display: inline-block;
524
+ }
525
+ .footer-col p, .footer-col a {
526
+ font-size: 0.82rem;
527
+ color: #AAAAAA;
528
+ line-height: 1.7;
529
+ text-decoration: none;
530
+ display: block;
531
+ }
532
+ .footer-col a:hover {
533
+ color: #FFFFFF;
534
+ }
535
+ .footer-bottom {
536
+ max-width: 1100px;
537
+ margin: 1.5rem auto 0 auto;
538
+ padding: 1rem 2rem 0 2rem;
539
+ border-top: 1px solid #444444;
540
+ font-size: 0.78rem;
541
+ color: #888888;
542
+ text-align: center;
543
+ }
544
  </style>
545
  """,
546
  unsafe_allow_html=True,
547
  )
548
 
549
  # ---------------------------------------------------------------------------
550
+ # Language state use query param trick for the utility-bar lang switch
551
  # ---------------------------------------------------------------------------
552
+ if "lang" not in st.session_state:
553
+ st.session_state.lang = "da"
554
+
555
+
556
+ def _set_lang(code: str) -> None:
557
+ """Set the language in session state."""
558
+ st.session_state.lang = code
559
+
560
+
561
+ # A hidden selectbox drives the actual state; the visual switch is in the bar
562
+ _col_hidden_lang = st.columns([1])[0]
563
+ with _col_hidden_lang:
564
  lang = st.selectbox(
565
+ "lang_sel",
566
  options=["da", "en"],
567
  format_func=lambda c: "Dansk" if c == "da" else "English",
568
+ index=0 if st.session_state.lang == "da" else 1,
569
  label_visibility="collapsed",
570
+ key="lang_select",
571
  )
572
+ st.session_state.lang = lang
573
 
574
  t = TEXTS[lang]
575
 
576
+ # ---------------------------------------------------------------------------
577
+ # Top utility bar
578
+ # ---------------------------------------------------------------------------
579
+ st.markdown(
580
+ f"""
581
+ <div class="top-utility-bar">
582
+ <div class="top-utility-inner">
583
+ <span>{t["footer_service_status"]}</span>
584
+ <span>{t["footer_service_guide"]}</span>
585
+ <span class="utility-lang-switch">{"Dansk" if lang == "da" else "English"}</span>
586
+ </div>
587
+ </div>
588
+ """,
589
+ unsafe_allow_html=True,
590
+ )
591
+
592
+ # ---------------------------------------------------------------------------
593
+ # Main navigation bar
594
+ # ---------------------------------------------------------------------------
595
+ st.markdown(
596
+ f"""
597
+ <div class="main-nav-bar">
598
+ <div class="main-nav-inner">
599
+ <span class="nav-brand">Dokumentassistent</span>
600
+ <div class="nav-links">
601
+ <a href="#" class="active">{t["nav_home"]}</a>
602
+ <a href="#">{t["nav_docs"]}</a>
603
+ <a href="#">{t["nav_search"]}</a>
604
+ <a href="#">{t["nav_about"]}</a>
605
+ </div>
606
+ </div>
607
+ </div>
608
+ """,
609
+ unsafe_allow_html=True,
610
+ )
611
+
612
+ # ---------------------------------------------------------------------------
613
+ # Breadcrumbs
614
+ # ---------------------------------------------------------------------------
615
+ st.markdown(
616
+ f"""
617
+ <div class="breadcrumbs">
618
+ <a href="#">{t["breadcrumb_home"]}</a>
619
+ <span class="separator">&rsaquo;</span>
620
+ <a href="#">{t["nav_search"]}</a>
621
+ <span class="separator">&rsaquo;</span>
622
+ <span>{t["breadcrumb_current"]}</span>
623
+ </div>
624
+ """,
625
+ unsafe_allow_html=True,
626
+ )
627
+
628
  # ---------------------------------------------------------------------------
629
  # Sidebar
630
  # ---------------------------------------------------------------------------
631
  with st.sidebar:
632
  st.markdown(
633
+ f'<div class="sidebar-section-heading">{t["sidebar_heading"]}</div>',
634
  unsafe_allow_html=True,
635
  )
636
  st.markdown(t["sidebar_body"])
 
654
 
655
  st.markdown("---")
656
 
 
657
  try:
658
  _health = requests.get(f"{API_BASE}/health", timeout=5).json()
659
  _llm = _health.get("llm_model", "")
 
661
  _emb = _health.get("embedding_model", "")
662
  _emb_prov = _health.get("embedding_provider", "")
663
  st.markdown(
664
+ f'<div class="sidebar-section-heading">{t["model_heading"]}</div>',
665
  unsafe_allow_html=True,
666
  )
667
  st.markdown(
 
672
  st.caption(t["model_unavailable"])
673
 
674
  # ---------------------------------------------------------------------------
675
+ # Main content — page title area
676
  # ---------------------------------------------------------------------------
 
 
 
 
 
 
677
  st.markdown(
678
+ f"""
679
+ <div class="page-title-area">
680
+ <div class="page-title">{t["title"]}</div>
681
+ <p class="page-subtitle">{t["subtitle"]}</p>
682
+ </div>
683
+ """,
684
  unsafe_allow_html=True,
685
  )
686
 
 
785
 
786
  st.markdown("---")
787
 
788
+ def _truncate_doc_id(doc_id: str, max_len: int = 40) -> str:
789
+ """Truncate long document IDs for table display."""
790
+ if len(doc_id) <= max_len:
791
+ return doc_id
792
+ return doc_id[: max_len - 3] + "..."
793
+
794
  def _render_result_table(results: list[dict], label: str) -> None:
795
  """Render a ranked results table."""
796
  st.markdown(f"**{label}**")
 
799
  return
800
  header = f'| {t["pipeline_rank"]} | {t["pipeline_doc"]} | {t["pipeline_score"]} |\n|---|---|---|'
801
  rows = "\n".join(
802
+ f'| {i + 1} | {_truncate_doc_id(r.get("document_id", ""))} | {r.get("score", 0):.4f} |'
803
  for i, r in enumerate(results)
804
  )
805
  st.markdown(f"{header}\n{rows}")
 
843
  else:
844
  change = "-"
845
  rows_list.append(
846
+ f'| {i + 1} | {_truncate_doc_id(r.get("document_id", ""))} | {new_score:.4f} | {change} |'
847
  )
848
  st.markdown(f"{header}\n" + "\n".join(rows_list))
849
  else:
 
851
 
852
  elif search_clicked:
853
  st.warning(t["empty_warning"])
854
+
855
+ # ---------------------------------------------------------------------------
856
+ # Footer
857
+ # ---------------------------------------------------------------------------
858
+ st.markdown(
859
+ f"""
860
+ <div class="site-footer">
861
+ <div class="footer-inner">
862
+ <div class="footer-col">
863
+ <h4>Dokumentassistent</h4>
864
+ <p>{t["footer_contact_text"]}</p>
865
+ <p style="margin-top:0.5rem; color:#888;">RAG-pipeline &middot; Hybrid Search &middot; LLM</p>
866
+ </div>
867
+ <div class="footer-col">
868
+ <h4>{t["footer_services"]}</h4>
869
+ <a href="#">{t["footer_service_api"]}</a>
870
+ <a href="#">{t["footer_service_status"]}</a>
871
+ <a href="#">{t["footer_service_guide"]}</a>
872
+ </div>
873
+ <div class="footer-col">
874
+ <h4>{t["footer_about"]}</h4>
875
+ <a href="#">{t["footer_about_privacy"]}</a>
876
+ <a href="#">{t["footer_about_data"]}</a>
877
+ <a href="#">{t["footer_about_access"]}</a>
878
+ </div>
879
+ <div class="footer-col">
880
+ <h4>{t["footer_contact"]}</h4>
881
+ <p>support@example.dk</p>
882
+ <p>+45 00 00 00 00</p>
883
+ </div>
884
+ </div>
885
+ <div class="footer-bottom">
886
+ {t["footer_copyright"]}
887
+ </div>
888
+ </div>
889
+ """,
890
+ unsafe_allow_html=True,
891
+ )