"""Rendu de l'encart « Ce corpus est-il habituel ? » — Sprint 74. A.I.3 chantier 1 du plan d'évolution 2026. Suite directe Sprint 73 (couche calcul + détecteur narratif). Ce sprint livre le rendu HTML qui place la difficulté du corpus courant dans la distribution des corpus précédents stockés en SQLite (Sprint 8) — un mini-boxplot horizontal en SVG avec un point pour la position du corpus courant, accompagné d'une phrase factuelle. Pattern identique aux autres rendus (Sprints 41/43/62/67/72) : **server-side**, pas de JavaScript, anti-injection systématique via ``html.escape``. Sortie typique -------------- Un encart court (~80px de haut) à insérer en tête du rapport, sous la synthèse factuelle : Difficulté observée 0,62 — au 88ᵉ percentile des 47 corpus précédents de votre institution. Ce corpus est plus difficile que la moyenne. [boxplot SVG horizontal avec point courant coloré] Si moins de ``min_runs`` runs historiques ont une difficulté enregistrée, ``compute_corpus_difficulty_percentile`` retourne ``None`` et le rendu retourne ``""`` (rapport adaptatif). """ from __future__ import annotations import statistics from html import escape as _e from typing import Optional def _quantiles(values: list[float]) -> tuple[float, float, float, float, float]: """Retourne (min, Q1, median, Q3, max).""" if not values: return (0.0, 0.0, 0.0, 0.0, 0.0) sorted_v = sorted(values) n = len(sorted_v) if n == 1: v = sorted_v[0] return (v, v, v, v, v) median = statistics.median(sorted_v) # Calcul des quartiles avec interpolation linéaire (méthode # « inclusive » : Q1 = médiane de la moitié inférieure # incluant la médiane si N impair). half = n // 2 if n % 2 == 0: lower = sorted_v[:half] upper = sorted_v[half:] else: lower = sorted_v[: half + 1] upper = sorted_v[half:] q1 = statistics.median(lower) q3 = statistics.median(upper) return (sorted_v[0], q1, median, q3, sorted_v[-1]) def _build_difficulty_boxplot_svg( historical_values: list[float], current: float, *, width: int = 480, height: int = 80, ) -> str: """Construit un boxplot horizontal SVG avec point courant. Le SVG est autonome (pas de CSS externe) et utilise des coordonnées explicites — sûr à intégrer dans n'importe quel document HTML. """ if not historical_values: return "" min_v, q1, median, q3, max_v = _quantiles(historical_values) # Borne du domaine : on inclut le point courant pour qu'il soit # visible même s'il dépasse les valeurs historiques. domain_min = min(min_v, current) domain_max = max(max_v, current) if domain_max == domain_min: # Cas dégénéré : tous les points superposés domain_min -= 0.01 domain_max += 0.01 margin_x = 30 margin_y = 10 plot_w = width - 2 * margin_x plot_h = height - 2 * margin_y - 14 # 14px pour le label cy = margin_y + plot_h // 2 box_top = cy - plot_h // 4 box_bottom = cy + plot_h // 4 whisker_top = cy - plot_h // 6 whisker_bottom = cy + plot_h // 6 def x(v: float) -> float: return margin_x + (v - domain_min) / (domain_max - domain_min) * plot_w # Le point courant : couleur selon position if current < q1: point_color = "#3b87d8" # bleu — plus facile que d'habitude elif current > q3: point_color = "#d8553b" # rouge — plus difficile else: point_color = "#5fa860" # vert — habituel parts = [ f'", ] return "".join(parts) def build_corpus_difficulty_baseline_html( percentile_data: Optional[dict], historical_values: Optional[list[float]] = None, labels: Optional[dict[str, str]] = None, ) -> str: """Construit l'encart « Ce corpus est-il habituel ? ». Parameters ---------- percentile_data: Sortie de ``picarones.measurements.baseline_comparison.compute_corpus_difficulty_percentile``. Si ``None``, retourne ``""`` (rapport adaptatif — historique trop court ou difficulté absente). historical_values: Liste des difficultés historiques pour le boxplot. Si ``None`` ou vide, le boxplot est omis et seule la phrase factuelle apparaît. labels: Map i18n. Returns ------- str HTML de l'encart, ou ``""`` si rien à afficher. """ if not percentile_data: return "" labels = labels or {} title = labels.get( "baseline_corpus_title", "Ce corpus est-il habituel ?", ) template_harder = labels.get( "baseline_corpus_harder", "Difficulté observée {current:.2f} — au {percentile:.0f}ᵉ " "percentile des {n_runs} corpus précédents de votre institution. " "Ce corpus est plus difficile que la moyenne.", ) template_easier = labels.get( "baseline_corpus_easier", "Difficulté observée {current:.2f} — au {percentile:.0f}ᵉ " "percentile des {n_runs} corpus précédents. Ce corpus est " "plus facile que la moyenne.", ) template_usual = labels.get( "baseline_corpus_usual", "Difficulté observée {current:.2f} — au {percentile:.0f}ᵉ " "percentile des {n_runs} corpus précédents. Ce corpus est " "dans la moyenne.", ) current = float(percentile_data.get("current_difficulty", 0.0)) percentile = float(percentile_data.get("percentile", 0.0)) n_runs = int(percentile_data.get("n_runs", 0)) if percentile_data.get("harder_than_usual"): phrase_template = template_harder elif percentile_data.get("easier_than_usual"): phrase_template = template_easier else: phrase_template = template_usual phrase = phrase_template.format( current=current, percentile=percentile, n_runs=n_runs, ) svg = "" if historical_values: svg = _build_difficulty_boxplot_svg( list(historical_values), current, ) parts = [ '