"""
Fragments HTML de l'interface (en-tête, panneaux d'analyse, KPI, graphiques,
schéma d'architecture…).
Tout est rendu côté serveur en HTML + CSS (voir `theme.py`) : le rendu est
identique quelle que soit la version de Gradio, entièrement traduisible, et
s'adapte automatiquement au mode clair/sombre et au sens d'écriture (RTL).
Toute donnée dynamique est échappée avec `html.escape`.
"""
from __future__ import annotations
import html
import math
from config import SUPPORTED_LANGUAGES
from core.ui.i18n import t
# --------------------------------------------------------------------------- #
# Icônes (traits 1.75px, style « lucide »)
# --------------------------------------------------------------------------- #
_ICON_PATHS = {
"globe": ' ',
"file": ' ',
"gauge": ' ',
"clock": ' ',
"zap": ' ',
"shield": ' ',
"alert": ' ',
"layers": ' ',
"database": ' ',
"search": ' ',
"cpu": ' ',
"scissors": ' ',
"tag": ' ',
"message": ' ',
"sparkles": ' ',
"key": ' ',
"server": ' ',
"repeat": ' ',
"languages": ' ',
"activity": ' ',
}
def icon(name: str, size: int = 16, cls: str = "pg-icon") -> str:
path = _ICON_PATHS.get(name, "")
return (
f'{path} '
)
LOGO_SVG = (
''
''
' '
" "
' '
' '
' '
" "
)
def _e(value) -> str:
return html.escape("" if value is None else str(value))
def _n(text) -> str:
"""Valeur numérique isolée en LTR (évite « ms 1,240 » ou « 512 / 37 » en arabe)."""
return f'{_e(text)} '
def lang_name(code: str | None) -> str:
info = SUPPORTED_LANGUAGES.get(code or "")
return info["name"] if info else (code or "—")
def lang_badge(code: str | None) -> str:
code = code or "?"
return f'{_e(code.upper())} '
# --------------------------------------------------------------------------- #
# En-tête / pied de page
# --------------------------------------------------------------------------- #
def render_brand(lang: str) -> str:
return f"""
{LOGO_SVG}
PolyglotRAG {_e(t(lang, "app_tagline"))}
{_e(t(lang, "app_subtitle"))}
"""
def render_status(lang: str, corpus: dict, config_ok: bool) -> str:
if not config_ok:
cls, label = "warn", t(lang, "status_config")
elif corpus.get("total_chunks", 0) > 0:
cls = "ok"
label = t(lang, "status_ready", chunks=corpus["total_chunks"], docs=corpus.get("documents", 0))
else:
cls, label = "warn", t(lang, "status_empty")
langs = "".join(
f'{_e(code.upper())} ' for code in SUPPORTED_LANGUAGES
)
return f"""
{_e(label)}
{langs}
"""
def render_config_banner(lang: str, config_ok: bool) -> str:
if config_ok:
return ""
return f'{icon("alert", 18)}{_e(t(lang, "config_warning"))}
'
def render_footer(lang: str) -> str:
return f''
def render_section_title(lang: str, key: str, icon_name: str) -> str:
return f'{icon(icon_name, 16)}{_e(t(lang, key))}
'
def render_welcome(lang: str) -> str:
"""Contenu affiché dans la zone de conversation tant qu'elle est vide."""
return (
f'{LOGO_SVG}'
f"
{_e(t(lang, 'welcome_title'))} "
f"
{_e(t(lang, 'welcome_text'))}
"
)
# --------------------------------------------------------------------------- #
# Panneau « Analyse de la réponse » + sources
# --------------------------------------------------------------------------- #
def _meter(value: float, tone: str) -> str:
pct = max(0.0, min(1.0, float(value or 0.0))) * 100
return f'
'
def render_insights(lang: str, data: dict | None) -> str:
title = render_section_title(lang, "insights_title", "activity")
if not data:
return f'{title}
{_e(t(lang, "insights_empty"))}
'
grounded = not data.get("used_fallback")
status = (
f'{icon("shield", 13)}{_e(t(lang, "grounded"))} '
if grounded
else f'{icon("alert", 13)}{_e(t(lang, "abstained"))} '
)
cache = (
f'{icon("zap", 13)}{_e(t(lang, "cached"))} '
if data.get("from_cache")
else ""
)
conf = float(data.get("confidence", 0.0) or 0.0)
qconf = float(data.get("query_language_confidence", 0.0) or 0.0)
latency = float(data.get("latency_ms", 0.0) or 0.0)
return f"""
{title}
{status}{cache}
{_e(t(lang, "detected_lang"))}
{lang_badge(data.get("query_language"))} {_e(lang_name(data.get("query_language")))}
{_n(f"{qconf:.0%}")}
{_e(t(lang, "answer_lang"))}
{lang_badge(data.get("target_language"))} {_e(lang_name(data.get("target_language")))}
{_e(t(lang, "confidence"))}
{_n(f"{conf:.2f}")}
{_meter(conf, "ok" if grounded else "warn")}
{_e(t(lang, "latency"))}
{_n(f"{latency:,.0f} ms")}
"""
def render_sources(lang: str, sources: list[dict] | None, visible: bool = True) -> str:
title = render_section_title(lang, "sources_title", "file")
if not visible:
return ""
if not sources:
return f'{title}
{_e(t(lang, "no_sources"))}
'
items = []
for i, s in enumerate(sources, start=1):
doc_title = s.get("title")
if not isinstance(doc_title, str) or not doc_title.strip():
doc_title = ""
score = float(s.get("score", 0.0) or 0.0)
items.append(
f"""
{i}
{icon("file", 14)}{_e(s.get("file"))}
{f'
{_e(doc_title)}
' if doc_title else ""}
{lang_badge(s.get("language"))}
{_e(t(lang, "page"))} {_e(s.get("page"))}
·
{_e(t(lang, "score"))} {_n(f"{score:.2f}")}
{_meter(score, "ok")}
"""
)
return f''
# --------------------------------------------------------------------------- #
# Administration
# --------------------------------------------------------------------------- #
def _kpi(label: str, value: str, icon_name: str, hint: str = "") -> str:
hint_html = f'{_e(hint)}
' if hint else ""
return (
f'{icon(icon_name, 15)}'
f"{_e(label)}
"
f'
{_n(value)}
{hint_html}
'
)
def _bars(lang: str, title_key: str, data: dict) -> str:
title = f'{_e(t(lang, title_key))}
'
clean = {k: v for k, v in (data or {}).items() if v}
if not clean:
return f'{title}
{_e(t(lang, "no_data"))}
'
total = sum(clean.values())
peak = max(clean.values())
rows = []
for code, value in sorted(clean.items(), key=lambda kv: kv[1], reverse=True):
width = (value / peak) * 100 if peak else 0
share = value / total if total else 0
rows.append(
f"""
{lang_badge(code)}{_e(lang_name(code))}
{_n(f"{value:,}")} {_n(f"{share:.0%}")}
"""
)
return f'{title}{"".join(rows)}
'
def _recent_table(lang: str, rows: list[dict]) -> str:
title = render_section_title(lang, "recent_title", "message")
if not rows:
return f'{title}
{_e(t(lang, "no_data"))}
'
head = "".join(
f"{_e(t(lang, k))} "
for k in ("col_question", "col_qlang", "col_alang", "col_conf", "col_status", "col_latency")
)
body = []
for r in rows:
question = str(r.get("question") or "")
short = question if len(question) <= 110 else question[:107] + "…"
abst = bool(r.get("used_fallback"))
status = (
f'{_e(t(lang, "status_abst"))} '
if abst
else f'{_e(t(lang, "status_ok"))} '
)
conf = float(r.get("confidence_score") or 0.0)
lat = float(r.get("total_latency_ms") or 0.0)
body.append(
f"{_e(short)} "
f"{lang_badge(r.get('query_language'))} "
f"{lang_badge(r.get('target_language'))} "
f"{_n(f'{conf:.2f}')} {status} "
f"{_n(f'{lat:,.0f} ms')} "
)
return (
f'{title}
'
f"{head} {''.join(body)}
"
)
def render_admin_placeholder(lang: str) -> str:
return f'{icon("key", 22)}
{_e(t(lang, "admin_hint"))}
'
def render_admin_denied(lang: str) -> str:
return f'{icon("alert", 18)}{_e(t(lang, "denied"))}
'
def render_admin_dashboard(lang: str, data: dict) -> str:
m = data["metrics"]
c = data["corpus"]
ac = data["answer_cache"]
ec = data["embedding_cache"]
def cache_value(stats: dict) -> str:
return f"{stats['size']} / {stats['maxsize']}" if stats.get("enabled") else t(lang, "disabled")
quality = "".join(
[
_kpi(t(lang, "kpi_queries"), f"{m['total_queries']:,}", "message"),
_kpi(t(lang, "kpi_abstention"), f"{m['abstention_rate']:.0%}", "shield"),
_kpi(t(lang, "kpi_confidence"), f"{m['avg_confidence']:.2f}", "gauge"),
_kpi(t(lang, "kpi_latency"), f"{m['avg_latency_ms']:,.0f} ms", "clock"),
_kpi(t(lang, "kpi_crossling"), f"{m.get('cross_lingual_rate', 0.0):.0%}", "languages"),
]
)
corpus = "".join(
[
_kpi(t(lang, "kpi_chunks"), f"{c['total_chunks']:,}", "layers"),
_kpi(t(lang, "kpi_docs"), f"{c['documents']:,}", "file"),
_kpi(t(lang, "kpi_index"), str(c.get("ann_index_type", "—")).upper(), "database"),
_kpi(t(lang, "kpi_answer_cache"), cache_value(ac), "zap"),
_kpi(t(lang, "kpi_embed_cache"), cache_value(ec), "cpu"),
]
)
return f"""
{render_section_title(lang, "section_quality", "activity")}
{quality}
{render_section_title(lang, "section_corpus", "server")}
{corpus}
{_bars(lang, "chart_queries", m.get("by_query_language", {}))}
{_bars(lang, "chart_corpus", c.get("by_language", {}))}
{_recent_table(lang, data.get("recent", []))}
"""
# --------------------------------------------------------------------------- #
# Architecture
# --------------------------------------------------------------------------- #
def _step(n: int, lang: str, key: str, icon_name: str) -> str:
return f"""
{icon(icon_name, 18)}
{n:02d}
{_e(t(lang, key + "_t"))}
{_e(t(lang, key + "_d"))}
"""
def render_architecture(lang: str) -> str:
ingest = "".join(
_step(i, lang, k, ic)
for i, (k, ic) in enumerate(
[("s1", "file"), ("s2", "tag"), ("s3", "scissors"), ("s4", "cpu"), ("s5", "database")], start=1
)
)
query = "".join(
_step(i, lang, k, ic)
for i, (k, ic) in enumerate(
[("q1", "languages"), ("q2", "search"), ("q3", "shield"), ("q4", "sparkles"), ("q5", "zap")], start=1
)
)
why = "".join(
f'{icon(ic, 18)}
{_e(t(lang, k + "_t"))} '
f"
{_e(t(lang, k + '_d'))}
"
for k, ic in [("w1", "globe"), ("w2", "key"), ("w3", "server"), ("w4", "shield")]
)
return f"""
{_e(t(lang, "arch_title"))}
{_e(t(lang, "arch_intro"))}
{_e(t(lang, "lane_ingest"))}
{ingest}
{_e(t(lang, "lane_query"))}
{query}
{render_section_title(lang, "why_title", "layers")}
{why}
"""
def safe_number(value, default: float = 0.0) -> float:
try:
v = float(value)
return default if math.isnan(v) else v
except (TypeError, ValueError):
return default