polyglot-rag / core /ui /components.py
nvikou's picture
Deploy PolyglotRAG v1.1
2697b4c verified
Raw History Blame Contribute Delete
17.2 kB
"""
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": '<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
"file": '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/>',
"gauge": '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
"clock": '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
"zap": '<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/>',
"shield": '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/>',
"alert": '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
"layers": '<path d="m12 2 10 5-10 5L2 7l10-5z"/><path d="m2 17 10 5 10-5"/><path d="m2 12 10 5 10-5"/>',
"database": '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>',
"search": '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
"cpu": '<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M15 2v2M15 20v2M2 15h2M2 9h2M20 15h2M20 9h2M9 2v2M9 20v2"/>',
"scissors": '<circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.12 15.88"/><path d="M14.47 14.48 20 20"/><path d="M8.12 8.12 12 12"/>',
"tag": '<path d="M12 2H2v10l9.29 9.29a1 1 0 0 0 1.41 0l8.59-8.59a1 1 0 0 0 0-1.41L12 2z"/><path d="M7 7h.01"/>',
"message": '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
"sparkles": '<path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3L12 3z"/>',
"key": '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
"server": '<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><path d="M6 6h.01M6 18h.01"/>',
"repeat": '<path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/>',
"languages": '<path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/>',
"activity": '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
}
def icon(name: str, size: int = 16, cls: str = "pg-icon") -> str:
path = _ICON_PATHS.get(name, "")
return (
f'<svg class="{cls}" width="{size}" height="{size}" viewBox="0 0 24 24" fill="none" '
f'stroke="currentColor" stroke-width="1.75" stroke-linecap="round" '
f'stroke-linejoin="round" aria-hidden="true">{path}</svg>'
)
LOGO_SVG = (
'<svg class="pg-logo" width="36" height="36" viewBox="0 0 40 40" aria-hidden="true">'
'<defs><linearGradient id="pgLogoGrad" x1="0" y1="0" x2="1" y2="1">'
'<stop offset="0" stop-color="#6D7CFF"/><stop offset="1" stop-color="#22D3EE"/>'
"</linearGradient></defs>"
'<rect width="40" height="40" rx="11" fill="url(#pgLogoGrad)"/>'
'<circle cx="20" cy="20" r="10.5" fill="none" stroke="#fff" stroke-width="2"/>'
'<path d="M9.5 20h21M20 9.5c3 3 4.4 6.6 4.4 10.5S23 27.5 20 30.5M20 9.5c-3 3-4.4 6.6-4.4 10.5S17 27.5 20 30.5" '
'fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"/>'
"</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'<bdi dir="ltr">{_e(text)}</bdi>'
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'<span class="pg-lang-badge" data-lang="{_e(code)}">{_e(code.upper())}</span>'
# --------------------------------------------------------------------------- #
# En-tête / pied de page
# --------------------------------------------------------------------------- #
def render_brand(lang: str) -> str:
return f"""
<div class="pg-brand">
{LOGO_SVG}
<div class="pg-brand-text">
<div class="pg-brand-title">PolyglotRAG <span class="pg-brand-tag">{_e(t(lang, "app_tagline"))}</span></div>
<div class="pg-brand-sub">{_e(t(lang, "app_subtitle"))}</div>
</div>
</div>"""
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'<span class="pg-chip-lang">{_e(code.upper())}</span>' for code in SUPPORTED_LANGUAGES
)
return f"""
<div class="pg-status-wrap">
<span class="pg-status pg-status-{cls}"><span class="pg-dot"></span>{_e(label)}</span>
<span class="pg-status-langs">{langs}</span>
</div>"""
def render_config_banner(lang: str, config_ok: bool) -> str:
if config_ok:
return ""
return f'<div class="pg-banner pg-banner-warn">{icon("alert", 18)}<span>{_e(t(lang, "config_warning"))}</span></div>'
def render_footer(lang: str) -> str:
return f'<div class="pg-footer">{icon("sparkles", 14)}<span>{_e(t(lang, "footer"))}</span></div>'
def render_section_title(lang: str, key: str, icon_name: str) -> str:
return f'<div class="pg-section-title">{icon(icon_name, 16)}<span>{_e(t(lang, key))}</span></div>'
def render_welcome(lang: str) -> str:
"""Contenu affiché dans la zone de conversation tant qu'elle est vide."""
return (
f'<div class="pg-welcome">{LOGO_SVG}'
f"<h3>{_e(t(lang, 'welcome_title'))}</h3>"
f"<p>{_e(t(lang, 'welcome_text'))}</p></div>"
)
# --------------------------------------------------------------------------- #
# 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'<div class="pg-meter pg-meter-{tone}"><span style="width:{pct:.0f}%"></span></div>'
def render_insights(lang: str, data: dict | None) -> str:
title = render_section_title(lang, "insights_title", "activity")
if not data:
return f'<div class="pg-card">{title}<p class="pg-muted">{_e(t(lang, "insights_empty"))}</p></div>'
grounded = not data.get("used_fallback")
status = (
f'<span class="pg-pill pg-pill-ok">{icon("shield", 13)}{_e(t(lang, "grounded"))}</span>'
if grounded
else f'<span class="pg-pill pg-pill-warn">{icon("alert", 13)}{_e(t(lang, "abstained"))}</span>'
)
cache = (
f'<span class="pg-pill pg-pill-info">{icon("zap", 13)}{_e(t(lang, "cached"))}</span>'
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"""
<div class="pg-card">
{title}
<div class="pg-pills">{status}{cache}</div>
<dl class="pg-stats">
<div><dt>{_e(t(lang, "detected_lang"))}</dt>
<dd>{lang_badge(data.get("query_language"))} {_e(lang_name(data.get("query_language")))}
<small>{_n(f"{qconf:.0%}")}</small></dd></div>
<div><dt>{_e(t(lang, "answer_lang"))}</dt>
<dd>{lang_badge(data.get("target_language"))} {_e(lang_name(data.get("target_language")))}</dd></div>
<div><dt>{_e(t(lang, "confidence"))}</dt>
<dd class="pg-num">{_n(f"{conf:.2f}")}</dd>
{_meter(conf, "ok" if grounded else "warn")}</div>
<div><dt>{_e(t(lang, "latency"))}</dt>
<dd class="pg-num">{_n(f"{latency:,.0f} ms")}</dd></div>
</dl>
</div>"""
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'<div class="pg-card">{title}<p class="pg-muted">{_e(t(lang, "no_sources"))}</p></div>'
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"""
<li class="pg-source">
<span class="pg-source-idx">{i}</span>
<div class="pg-source-body">
<div class="pg-source-file">{icon("file", 14)}<span dir="auto">{_e(s.get("file"))}</span></div>
{f'<div class="pg-source-title" dir="auto">{_e(doc_title)}</div>' if doc_title else ""}
<div class="pg-source-meta">{lang_badge(s.get("language"))}
<span>{_e(t(lang, "page"))} {_e(s.get("page"))}</span>
<span class="pg-sep">·</span>
<span>{_e(t(lang, "score"))} <b class="pg-num">{_n(f"{score:.2f}")}</b></span>
</div>
{_meter(score, "ok")}
</div>
</li>"""
)
return f'<div class="pg-card">{title}<ol class="pg-sources">{"".join(items)}</ol></div>'
# --------------------------------------------------------------------------- #
# Administration
# --------------------------------------------------------------------------- #
def _kpi(label: str, value: str, icon_name: str, hint: str = "") -> str:
hint_html = f'<div class="pg-kpi-hint">{_e(hint)}</div>' if hint else ""
return (
f'<div class="pg-kpi"><div class="pg-kpi-head">{icon(icon_name, 15)}'
f"<span>{_e(label)}</span></div>"
f'<div class="pg-kpi-value pg-num">{_n(value)}</div>{hint_html}</div>'
)
def _bars(lang: str, title_key: str, data: dict) -> str:
title = f'<div class="pg-chart-title">{_e(t(lang, title_key))}</div>'
clean = {k: v for k, v in (data or {}).items() if v}
if not clean:
return f'<div class="pg-card pg-chart">{title}<p class="pg-muted">{_e(t(lang, "no_data"))}</p></div>'
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"""
<div class="pg-bar-row">
<div class="pg-bar-label">{lang_badge(code)}<span>{_e(lang_name(code))}</span></div>
<div class="pg-bar-track"><span style="width:{width:.1f}%"></span></div>
<div class="pg-bar-value pg-num">{_n(f"{value:,}")} <small>{_n(f"{share:.0%}")}</small></div>
</div>"""
)
return f'<div class="pg-card pg-chart">{title}{"".join(rows)}</div>'
def _recent_table(lang: str, rows: list[dict]) -> str:
title = render_section_title(lang, "recent_title", "message")
if not rows:
return f'<div class="pg-card">{title}<p class="pg-muted">{_e(t(lang, "no_data"))}</p></div>'
head = "".join(
f"<th>{_e(t(lang, k))}</th>"
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'<span class="pg-pill pg-pill-warn">{_e(t(lang, "status_abst"))}</span>'
if abst
else f'<span class="pg-pill pg-pill-ok">{_e(t(lang, "status_ok"))}</span>'
)
conf = float(r.get("confidence_score") or 0.0)
lat = float(r.get("total_latency_ms") or 0.0)
body.append(
f"<tr><td class='pg-td-q' dir='auto' title='{_e(question)}'>{_e(short)}</td>"
f"<td>{lang_badge(r.get('query_language'))}</td>"
f"<td>{lang_badge(r.get('target_language'))}</td>"
f"<td class='pg-num'>{_n(f'{conf:.2f}')}</td><td>{status}</td>"
f"<td class='pg-num'>{_n(f'{lat:,.0f} ms')}</td></tr>"
)
return (
f'<div class="pg-card">{title}<div class="pg-table-wrap"><table class="pg-table">'
f"<thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody></table></div></div>"
)
def render_admin_placeholder(lang: str) -> str:
return f'<div class="pg-card pg-empty-state">{icon("key", 22)}<p>{_e(t(lang, "admin_hint"))}</p></div>'
def render_admin_denied(lang: str) -> str:
return f'<div class="pg-banner pg-banner-error">{icon("alert", 18)}<span>{_e(t(lang, "denied"))}</span></div>'
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"""
<div class="pg-admin">
{render_section_title(lang, "section_quality", "activity")}
<div class="pg-kpi-grid">{quality}</div>
{render_section_title(lang, "section_corpus", "server")}
<div class="pg-kpi-grid">{corpus}</div>
<div class="pg-chart-grid">
{_bars(lang, "chart_queries", m.get("by_query_language", {}))}
{_bars(lang, "chart_corpus", c.get("by_language", {}))}
</div>
{_recent_table(lang, data.get("recent", []))}
</div>"""
# --------------------------------------------------------------------------- #
# Architecture
# --------------------------------------------------------------------------- #
def _step(n: int, lang: str, key: str, icon_name: str) -> str:
return f"""
<div class="pg-step">
<div class="pg-step-icon">{icon(icon_name, 18)}</div>
<div class="pg-step-n">{n:02d}</div>
<div class="pg-step-t">{_e(t(lang, key + "_t"))}</div>
<div class="pg-step-d">{_e(t(lang, key + "_d"))}</div>
</div>"""
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'<div class="pg-why">{icon(ic, 18)}<div><b>{_e(t(lang, k + "_t"))}</b>'
f"<p>{_e(t(lang, k + '_d'))}</p></div></div>"
for k, ic in [("w1", "globe"), ("w2", "key"), ("w3", "server"), ("w4", "shield")]
)
return f"""
<div class="pg-arch">
<div class="pg-arch-hero">
<h2>{_e(t(lang, "arch_title"))}</h2>
<p>{_e(t(lang, "arch_intro"))}</p>
</div>
<div class="pg-lane">
<div class="pg-lane-head"><span class="pg-lane-dot pg-lane-a"></span>{_e(t(lang, "lane_ingest"))}</div>
<div class="pg-steps">{ingest}</div>
</div>
<div class="pg-lane">
<div class="pg-lane-head"><span class="pg-lane-dot pg-lane-b"></span>{_e(t(lang, "lane_query"))}</div>
<div class="pg-steps">{query}</div>
</div>
{render_section_title(lang, "why_title", "layers")}
<div class="pg-why-grid">{why}</div>
</div>"""
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