"""Rendu HTML serveur-side de la section inter-moteurs (Sprint 37).
Suite des Sprints 35-36 : la couche de calcul (`inter_engine.py`) et le
câblage runner+narratif sont en place. Ce module produit les blocs HTML
qui remontent ces données dans le rapport :
- ``build_divergence_matrix_html`` — table HTML colorée (heatmap CSS
inline) qui montre la divergence taxonomique entre paires de moteurs.
- ``build_oracle_gap_html`` — encart factuel avec oracle_recall,
best_single_recall, gap absolu/relatif, top paires divergentes.
Principe — cohérent avec le CDD du Sprint 18 : rendu serveur-side, pas
de JavaScript, pas de dépendance Chart.js, déterministe. Si
``inter_engine_analysis`` est ``None`` ou incomplet, les fonctions
retournent une chaîne vide — le template Jinja2 masque la section
silencieusement (principe du rapport adaptatif).
"""
from __future__ import annotations
from html import escape as _e
from typing import Optional
from picarones.report.render_helpers import (
GRADIENT_TARGET_RED,
color_single_gradient,
)
def build_divergence_matrix_html(
inter_engine_analysis: Optional[dict],
labels: Optional[dict[str, str]] = None,
) -> str:
"""Construit une table HTML colorée représentant la matrice de divergence
taxonomique inter-moteurs.
Parameters
----------
inter_engine_analysis:
Dict produit par ``compute_inter_engine_analysis`` (Sprint 36).
``None`` ou sans ``taxonomy_divergence`` → chaîne vide.
labels:
Dict d'étiquettes i18n. Clés utilisées :
``"divergence_caption"``, ``"divergence_metric_label"``,
``"divergence_max_pair_label"``, ``"divergence_diagonal_label"``.
Fallback FR si manquantes.
Returns
-------
str
HTML complet (``
…
``) ou ``""`` si pas de
données disponibles.
"""
if not inter_engine_analysis:
return ""
div = inter_engine_analysis.get("taxonomy_divergence")
if not div or not div.get("matrix"):
return ""
matrix: dict[str, dict[str, float]] = div["matrix"]
metric: str = str(div.get("metric") or "js")
engines = sorted(matrix.keys())
if len(engines) < 2:
return ""
labels = labels or {}
caption = labels.get(
"divergence_caption",
"Divergence taxonomique entre moteurs",
)
metric_label = labels.get(
"divergence_metric_label",
"Métrique",
)
diag_label = labels.get(
"divergence_diagonal_label",
"(identité)",
)
# vmax = max hors diagonale (la diagonale est toujours 0)
off_diag_values = [
matrix[a][b] for a in engines for b in engines if a != b
]
vmax = max(off_diag_values) if off_diag_values else 0.0
parts: list[str] = []
parts.append('')
parts.append(
f'
'
f'{_e(caption)} '
f'({_e(metric_label)} : {_e(metric)})
'
)
parts.append('
')
# En-tête
parts.append(" | ")
for b in engines:
parts.append(
f'{_e(b)} | '
)
parts.append("
")
# Lignes
parts.append("")
for a in engines:
parts.append("")
parts.append(
f'| {_e(a)} | '
)
for b in engines:
v = float(matrix[a].get(b, 0.0))
if a == b:
parts.append(
f'{_e(diag_label)} | '
)
else:
bg = (
color_single_gradient(v, end_rgb=GRADIENT_TARGET_RED, max_value=vmax)
if vmax > 0 else "#ffffff"
)
# Texte sombre toujours lisible (pas de seuil fort sur le rouge clair).
parts.append(
f''
f'{v:.3f} | '
)
parts.append("
")
parts.append("
")
# Ligne « paire la plus divergente », si disponible et > 0
max_pair = div.get("max_pair")
if (
max_pair
and len(max_pair) >= 3
and isinstance(max_pair[2], (int, float))
and float(max_pair[2]) > 0
):
max_pair_label = labels.get(
"divergence_max_pair_label",
"Paire la plus divergente",
)
parts.append(
f'
'
f'{_e(max_pair_label)} : '
f'{_e(str(max_pair[0]))} ↔ '
f'{_e(str(max_pair[1]))} '
f'({_e(metric)} = {float(max_pair[2]):.3f})
'
)
parts.append("
")
return "".join(parts)
def build_oracle_gap_html(
inter_engine_analysis: Optional[dict],
labels: Optional[dict[str, str]] = None,
) -> str:
"""Construit l'encart factuel sur la complémentarité (oracle gap).
Parameters
----------
inter_engine_analysis:
Dict produit par ``compute_inter_engine_analysis``. ``None`` ou
sans ``complementarity`` → chaîne vide.
labels:
Dict d'étiquettes i18n. Clés :
``"oracle_caption"``, ``"oracle_best_engine"``,
``"oracle_best_recall"``, ``"oracle_recall"``, ``"oracle_gap"``,
``"oracle_doc_count"``, ``"oracle_explanation"``.
Returns
-------
str
HTML ``...
`` ou ``""`` si pas de données.
"""
if not inter_engine_analysis:
return ""
comp = inter_engine_analysis.get("complementarity")
if not comp:
return ""
labels = labels or {}
caption = labels.get(
"oracle_caption",
"Complémentarité — gain potentiel d'un voting majoritaire",
)
best_engine_label = labels.get("oracle_best_engine", "Meilleur moteur seul")
best_recall_label = labels.get("oracle_best_recall", "Tokens préservés")
oracle_label = labels.get("oracle_recall", "Oracle (au moins un moteur)")
gap_label = labels.get("oracle_gap", "Gain potentiel d'un ensemble")
doc_count_label = labels.get("oracle_doc_count", "Documents évalués")
explanation = labels.get(
"oracle_explanation",
"L'oracle est la borne supérieure du recall token-level atteignable "
"par un voting majoritaire entre les moteurs (proxy bag-of-words).",
)
best_engine = str(comp.get("best_engine") or "")
best_recall_pct = round(float(comp.get("best_single_recall") or 0.0) * 100, 1)
oracle_recall_pct = round(float(comp.get("oracle_recall") or 0.0) * 100, 1)
absolute_gap_pct = round(float(comp.get("absolute_gap") or 0.0) * 100, 1)
relative_gap_pct = round(float(comp.get("relative_gap") or 0.0) * 100, 1)
doc_count = int(comp.get("doc_count") or 0)
parts: list[str] = []
parts.append('')
parts.append(
f'
'
f'{_e(caption)}
'
)
parts.append('
')
def _row(label: str, value: str) -> str:
return (
f'- {_e(label)}
'
f'- {_e(value)}
'
)
parts.append(_row(best_engine_label, best_engine or "—"))
parts.append(_row(best_recall_label, f"{best_recall_pct} %"))
parts.append(_row(oracle_label, f"{oracle_recall_pct} %"))
parts.append(
_row(
gap_label,
f"+{absolute_gap_pct} pts ({relative_gap_pct} % "
+ labels.get("oracle_recoverable", "récupérable")
+ ")",
)
)
parts.append(_row(doc_count_label, str(doc_count)))
parts.append("
")
parts.append(
f'
'
f'{_e(explanation)}
'
)
parts.append("
")
return "".join(parts)
__all__ = [
"build_divergence_matrix_html",
"build_oracle_gap_html",
]