"""Rendu HTML server-side de la section NER (Sprint 41).
Suite directe des Sprints 38-40 : la couche de calcul, le backend
extracteur et le câblage runner sont en place ; ce module produit les
blocs HTML qui remontent ces données dans le rapport.
- ``build_ner_summary_html`` — encart factuel par moteur : F1 global,
precision/recall, total entités, hallucinations, missed.
- ``build_ner_per_category_html`` — table heatmap moteur × catégorie,
cellules colorées par F1 (rouge → vert).
Principe — cohérent avec ``inter_engine_render`` (Sprint 37) : rendu
server-side, pas de JavaScript, déterministe. Si aucun moteur n'a de
``aggregated_ner``, les fonctions retournent une chaîne vide — la vue
est silencieusement omise (rapport adaptatif).
Anti-injection : tous les noms de moteurs et catégories sont passés à
``html.escape`` avant insertion.
"""
from __future__ import annotations
from html import escape as _e
from typing import Optional
def _color_for_f1(f1: float) -> str:
"""Gradient rouge → jaune → vert proportionnel à ``f1`` ∈ [0, 1].
F1 = 0 → rouge clair, F1 = 0,5 → jaune pâle, F1 = 1 → vert clair.
"""
f = max(0.0, min(1.0, f1))
# Interpolation linéaire 2-segments :
# 0 → (220, 100, 100) (rouge), 0.5 → (240, 220, 130), 1 → (130, 200, 130) (vert)
if f <= 0.5:
ratio = f / 0.5
r = int(220 + (240 - 220) * ratio)
g = int(100 + (220 - 100) * ratio)
b = int(100 + (130 - 100) * ratio)
else:
ratio = (f - 0.5) / 0.5
r = int(240 + (130 - 240) * ratio)
g = int(220 + (200 - 220) * ratio)
b = int(130 + (130 - 130) * ratio)
return f"#{r:02x}{g:02x}{b:02x}"
def _engines_with_ner(engines_summary: list[dict]) -> list[dict]:
"""Filtre les moteurs qui ont une analyse NER agrégée."""
return [e for e in engines_summary if e.get("aggregated_ner")]
def build_ner_summary_html(
engines_summary: list[dict],
labels: Optional[dict[str, str]] = None,
) -> str:
"""Construit l'encart résumé NER : F1 global par moteur + totaux.
Parameters
----------
engines_summary:
Liste de dicts moteur (au moins ``name`` et ``aggregated_ner``).
labels:
Dict d'étiquettes i18n.
Returns
-------
str
HTML ``
...
`` ou ``""`` si aucun moteur n'a de NER.
"""
relevant = _engines_with_ner(engines_summary)
if not relevant:
return ""
labels = labels or {}
caption = labels.get("ner_summary_caption", "Précision sur entités nommées")
engine_label = labels.get("ner_engine_label", "Moteur")
f1_label = labels.get("ner_f1_label", "F1 global")
p_label = labels.get("ner_precision_label", "Précision")
r_label = labels.get("ner_recall_label", "Rappel")
docs_label = labels.get("ner_doc_count_label", "Docs évalués")
halluc_label = labels.get("ner_hallucinated_label", "Hallucinations")
missed_label = labels.get("ner_missed_label", "Entités manquées")
parts: list[str] = []
parts.append('')
parts.append(
f'
{_e(caption)}
'
)
parts.append(
'
'
)
parts.append("")
for hdr in (engine_label, f1_label, p_label, r_label,
docs_label, halluc_label, missed_label):
parts.append(
f'| '
f'{_e(hdr)} | '
)
parts.append("
")
for engine in relevant:
agg = engine["aggregated_ner"]
global_stats = agg.get("global", {}) or {}
f1 = float(global_stats.get("f1") or 0.0)
precision = float(global_stats.get("precision") or 0.0)
recall = float(global_stats.get("recall") or 0.0)
doc_count = int(agg.get("doc_count") or 0)
hallucinated = int(agg.get("hallucinated_total") or 0)
missed = int(agg.get("missed_total") or 0)
bg = _color_for_f1(f1)
parts.append("")
parts.append(
f'| '
f'{_e(engine.get("name", ""))} | '
)
parts.append(
f'{f1 * 100:.1f} % | '
)
parts.append(
f''
f'{precision * 100:.1f} % | '
)
parts.append(
f''
f'{recall * 100:.1f} % | '
)
parts.append(
f''
f'{doc_count} | '
)
parts.append(
f''
f'{hallucinated} | '
)
parts.append(
f''
f'{missed} | '
)
parts.append("
")
parts.append("
")
return "".join(parts)
def build_ner_per_category_html(
engines_summary: list[dict],
labels: Optional[dict[str, str]] = None,
) -> str:
"""Construit la heatmap NER moteur × catégorie d'entité.
Lignes = moteurs, colonnes = catégories (PER, LOC, ORG, DATE,
MISC…). Cellules colorées par F1 (rouge → vert). La cellule
affiche le F1 en pourcentage. Cellules vides quand la catégorie
n'a pas été observée pour le moteur.
Returns
-------
str
HTML ``...
`` ou ``""`` si pas de données.
"""
relevant = _engines_with_ner(engines_summary)
if not relevant:
return ""
# Catégories : union sur tous les moteurs, ordre alphabétique
all_categories: set[str] = set()
for engine in relevant:
per_cat = (engine["aggregated_ner"] or {}).get("per_category") or {}
all_categories.update(per_cat.keys())
if not all_categories:
return ""
categories = sorted(all_categories)
labels = labels or {}
caption = labels.get(
"ner_per_category_caption",
"F1 par catégorie d'entité (heatmap)",
)
engine_label = labels.get("ner_engine_label", "Moteur")
no_data = labels.get("ner_no_data_label", "—")
parts: list[str] = []
parts.append('')
parts.append(
f'
{_e(caption)}
'
)
parts.append(
'
'
)
parts.append("")
parts.append(
f'| {_e(engine_label)} | '
)
for cat in categories:
parts.append(
f'{_e(cat)} | '
)
parts.append("
")
for engine in relevant:
per_cat = (engine["aggregated_ner"] or {}).get("per_category") or {}
parts.append("")
parts.append(
f'| '
f'{_e(engine.get("name", ""))} | '
)
for cat in categories:
stats = per_cat.get(cat)
if not stats or int(stats.get("support", 0)) == 0:
parts.append(
f'{_e(no_data)} | '
)
else:
f1 = float(stats.get("f1") or 0.0)
support = int(stats.get("support", 0))
bg = _color_for_f1(f1)
parts.append(
f''
f'{f1 * 100:.1f} % | '
)
parts.append("
")
parts.append("
")
return "".join(parts)
__all__ = [
"build_ner_summary_html",
"build_ner_per_category_html",
]