File size: 8,499 Bytes
b0b2691
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
979f3c3
b0b2691
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
"""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'<svg xmlns="http://www.w3.org/2000/svg" '
        f'width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
        f'role="img" aria-label="Distribution de difficulté historique">',
        # Ligne de moustache (min → max)
        f'<line x1="{x(min_v):.1f}" y1="{cy}" x2="{x(max_v):.1f}" '
        f'y2="{cy}" stroke="#999" stroke-width="1"/>',
        # Moustache verticale gauche (min)
        f'<line x1="{x(min_v):.1f}" y1="{whisker_top}" '
        f'x2="{x(min_v):.1f}" y2="{whisker_bottom}" '
        f'stroke="#999" stroke-width="1"/>',
        # Moustache verticale droite (max)
        f'<line x1="{x(max_v):.1f}" y1="{whisker_top}" '
        f'x2="{x(max_v):.1f}" y2="{whisker_bottom}" '
        f'stroke="#999" stroke-width="1"/>',
        # Boîte Q1 → Q3
        f'<rect x="{x(q1):.1f}" y="{box_top}" '
        f'width="{x(q3) - x(q1):.1f}" height="{box_bottom - box_top}" '
        f'fill="#e8e8e8" stroke="#666" stroke-width="1"/>',
        # Médiane
        f'<line x1="{x(median):.1f}" y1="{box_top}" '
        f'x2="{x(median):.1f}" y2="{box_bottom}" '
        f'stroke="#333" stroke-width="2"/>',
        # Point courant (cercle plus grand que les autres marqueurs)
        f'<circle cx="{x(current):.1f}" cy="{cy}" r="6" '
        f'fill="{point_color}" stroke="#000" stroke-width="1"/>',
        # Étiquettes min / max
        f'<text x="{x(min_v):.1f}" y="{height - 2}" '
        f'font-size="10" fill="#666" text-anchor="middle">'
        f'{min_v:.2f}</text>',
        f'<text x="{x(max_v):.1f}" y="{height - 2}" '
        f'font-size="10" fill="#666" text-anchor="middle">'
        f'{max_v:.2f}</text>',
        # Étiquette du point courant
        f'<text x="{x(current):.1f}" y="{margin_y + 8}" '
        f'font-size="11" fill="{point_color}" '
        f'text-anchor="middle" font-weight="600">'
        f'{current:.2f}</text>',
        "</svg>",
    ]
    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 = [
        '<div class="baseline-corpus" '
        'style="margin:1rem 0;padding:.75rem;'
        'background:var(--bg-secondary,#f7f7f7);border-radius:6px">',
        f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
        f'<div style="font-size:.9rem;margin-bottom:.5rem">{_e(phrase)}</div>',
    ]
    if svg:
        parts.append(svg)
    parts.append("</div>")
    return "".join(parts)


__all__ = [
    "build_corpus_difficulty_baseline_html",
]