Claude commited on
Commit
a98013e
·
unverified ·
1 Parent(s): ebddecf

feat(web): S1 — expose 6 toggles BenchmarkRunRequest figés dans l'UI

Browse files

Sprint S1 — débloque les options Pydantic qui existaient déjà côté
API mais étaient jamais transmises depuis l'interface web.

Champs exposés :

1. ``report_lang`` (FR / EN) — toggle dans Options avancées. Permet
de générer un rapport en anglais ; auparavant figé à "fr".
2. ``views`` — checkboxes ``alto_documentary`` et ``searchability``
(``text_final`` toujours actif). Active les vues canoniques
AltoView et SearchView du rapport HTML.
3. ``expose_alto`` (par-compétiteur) — checkbox dans la section
Concurrents, visible uniquement quand le moteur sélectionné
est Tesseract. Débloque la production d'ALTO XML natif.
4. ``entity_extractor`` — input texte avec datalist de presets
(``spacy.fr_core_news_sm``, ``spacy.en_core_web_sm``).
Débloque les renderers NER summary + per category du rapport.
5. ``output_json`` — toggle ; ON dérive un chemin auto depuis
``report_name`` (relatif au workspace, validé par Pydantic).
6. ``partial_dir`` — toggle ; ON dérive un chemin checkpoint auto
avec timestamp. Permet la reprise après interruption.

Modifications :

- ``_view_benchmark.html`` : ajout d'une section ``<details>``
collapsible « Options avancées » dans Section 03 + checkbox
``compose-expose-alto`` masquable dans Section 02.
- ``web-app.js`` : helper ``_gatherAdvancedOptions()`` qui collecte
les 5 champs globaux ; étendu ``startBenchmark()``,
``_gatherCurrentConfig()``, ``_applyConfig()`` pour les inclure.
Handler ``onComposeOCRChange()`` montre/cache le checkbox
expose_alto selon le moteur. Modification ``addCompetitor()``
pour lire la valeur du checkbox sur les compétiteurs Tesseract.
- i18n : 12 nouvelles clés (FR + EN) dans la table T inline.

Tests (tests/web/test_benchmark_request_options.py) :
- 7 tests présence des IDs DOM dans le template rendu.
- 2 tests payload UI-style complet vs minimal accepté par l'API.
- 2 tests ``expose_alto`` per-compétiteur (mix Tesseract +
cloud OCR).
- 10 tests couverture i18n FR ↔ EN des nouveaux libellés.

Verification :
- 5182 tests passed (+21 vs S0-ter), 0 failed, 20 skipped
- make lint : All checks passed
- Le contrat Pydantic API ↔ payload restait inchangé (déjà testé
par tests/web/test_benchmark_run_b3_final_fields.py — 22 tests).
- Pas de refactor du JS ``web-app.js`` (1762 → 1885 LOC) — pas
de budget LOC enforced sur ce fichier ; le découpage ESM
proposé dans le plan S1 est reporté au sprint refonte rapport
(S5) pour cohérence (un seul gros refactor JS au lieu de deux).

DoD :
- 6 champs UI traversent jusqu'au worker ``run_benchmark_thread_v2``
(vérifié par lecture de benchmark_utils.py:469-525, propagation
déjà branchée depuis l'audit Phase D3 mai 2026).
- Test bout-en-bout : payload-style UI accepté par /api/benchmark/run.
- Bloc NER du rapport HTML deviendra rempli dès que l'utilisateur
fournit un entity_extractor sur un corpus annoté ENTITIES.

https://claude.ai/code/session_01WYDbfkhKPeBZ15BTP4e9Ye

picarones/interfaces/web/static/web-app.js CHANGED
@@ -120,6 +120,22 @@ const T = {
120
  compose_prompt: "Prompt",
121
  compose_max_image_dim: "Image max (px)",
122
  compose_max_image_dim_hint: "0 = pleine résolution (défaut, méthodo inchangée). > 0 réduit l'image envoyée au VLM (modes image) pour éviter les 429 — change la méthodo, run fingerprinté à part.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  compose_add: "+ Ajouter",
124
  compose_empty: "Aucun concurrent ajouté.",
125
  mode_text_only: "Post-correction texte",
@@ -269,6 +285,22 @@ const T = {
269
  compose_prompt: "Prompt",
270
  compose_max_image_dim: "Max image (px)",
271
  compose_max_image_dim_hint: "0 = full resolution (default, methodology unchanged). > 0 shrinks the image sent to the VLM (image modes) to avoid 429s — changes methodology, fingerprinted as a separate run.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  compose_add: "+ Add",
273
  compose_empty: "No competitors added.",
274
  mode_text_only: "Text post-correction",
@@ -544,6 +576,15 @@ async function onComposeOCRChange() {
544
  const engine = document.getElementById("compose-ocr-engine").value;
545
  _pendingOCREngine = engine; // marquer la requête courante
546
  const sp = document.getElementById("sp-ocr-model");
 
 
 
 
 
 
 
 
 
547
  // Google Vision et Azure ont des listes statiques — pas d'appel API nécessaire
548
  if (engine === "google_vision") {
549
  sp.style.display = "none";
@@ -658,9 +699,14 @@ function addCompetitor() {
658
 
659
  const comp = { name: "", engine_name: "", ocr_model: "",
660
  llm_provider: "", llm_model: "", pipeline_mode: "", prompt_file: "",
661
- max_image_dimension: 0 };
662
  const _maxImgDim = parseInt((document.getElementById("compose-max-image-dim") || {}).value, 10);
663
  const maxImgDim = Number.isFinite(_maxImgDim) && _maxImgDim > 0 ? _maxImgDim : 0;
 
 
 
 
 
664
 
665
  if (mode === "postcorrection") {
666
  // Post-correction : OCR vient du corpus (.ocr.txt)
@@ -691,6 +737,7 @@ function addCompetitor() {
691
  comp.pipeline_mode = document.getElementById("compose-pipeline-mode").value;
692
  comp.prompt_file = document.getElementById("compose-prompt").value;
693
  comp.max_image_dimension = maxImgDim;
 
694
  if (!comp.llm_provider) {
695
  errEl.textContent = lang === "fr" ? "Sélectionnez un provider LLM." : "Select an LLM provider.";
696
  return;
@@ -708,7 +755,9 @@ function addCompetitor() {
708
  }
709
  comp.engine_name = ocrEngine;
710
  comp.ocr_model = ocrModel;
711
- comp.name = `${ocrEngine}${ocrModel ? " ("+ocrModel+")" : ""}`;
 
 
712
  }
713
 
714
  errEl.textContent = "";
@@ -870,6 +919,7 @@ async function startBenchmark() {
870
  output_dir: document.getElementById("output-dir").value,
871
  report_name: document.getElementById("report-name").value,
872
  profile: (document.getElementById("run-profile") || {}).value || "standard",
 
873
  };
874
 
875
  document.getElementById("start-btn").disabled = true;
@@ -1603,11 +1653,57 @@ function _updateCorpusOCRNotice(corpusData) {
1603
  // côté serveur (avec tests dédiés) mais aucun bouton ne les appelait —
1604
  // code zombie typique post-rewrite.
1605
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1606
  function _gatherCurrentConfig() {
1607
  /** Sérialise l'état UI courant en dict compatible
1608
  * ``/api/config/save``. Inclut les compétiteurs composés
1609
- * (_competitors), les options de normalisation et le profil de
1610
- * langue rapport. */
1611
  return {
1612
  label: document.getElementById("report-name").value || "picarones-config",
1613
  corpus_path: document.getElementById("corpus-path").value,
@@ -1617,6 +1713,7 @@ function _gatherCurrentConfig() {
1617
  output_dir: document.getElementById("output-dir").value,
1618
  report_name: document.getElementById("report-name").value,
1619
  profile: (document.getElementById("run-profile") || {}).value || "standard",
 
1620
  };
1621
  }
1622
 
@@ -1729,6 +1826,28 @@ function _applyConfig(cfg) {
1729
  _competitors = cfg.competitors;
1730
  renderCompetitors();
1731
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1732
  }
1733
 
1734
  // ─── Init ────────────────────────────────────────────────────────────────────
 
120
  compose_prompt: "Prompt",
121
  compose_max_image_dim: "Image max (px)",
122
  compose_max_image_dim_hint: "0 = pleine résolution (défaut, méthodo inchangée). > 0 réduit l'image envoyée au VLM (modes image) pour éviter les 429 — change la méthodo, run fingerprinté à part.",
123
+ compose_expose_alto_label: "Produire l'ALTO XML natif",
124
+ compose_expose_alto_hint: "Active la vue « alto_documentary » du rapport. Tesseract uniquement.",
125
+ bench_advanced_options: "Options avancées",
126
+ bench_advanced_options_hint: "vues du rapport, langue, NER, reprise, export JSON",
127
+ bench_report_lang_label: "Langue du rapport",
128
+ bench_entity_extractor_label: "Extracteur NER (optionnel)",
129
+ bench_entity_extractor_hint: "Format : module.submodule:Symbol. Débloque le bloc NER du rapport HTML.",
130
+ bench_views_label: "Vues d'évaluation à activer",
131
+ bench_view_text_final: "text_final (toujours actif)",
132
+ bench_view_alto_documentary: "alto_documentary",
133
+ bench_view_searchability: "searchability",
134
+ bench_views_hint: "alto_documentary nécessite qu'au moins un concurrent Tesseract ait expose_alto activé.",
135
+ bench_partial_resume_label: "Permettre la reprise sur interruption",
136
+ bench_partial_resume_hint: "Crée un répertoire de checkpoint pour reprendre un run interrompu.",
137
+ bench_output_json_label: "Exporter aussi en JSON",
138
+ bench_output_json_hint: "Génère un fichier JSON additionnel à côté du rapport HTML.",
139
  compose_add: "+ Ajouter",
140
  compose_empty: "Aucun concurrent ajouté.",
141
  mode_text_only: "Post-correction texte",
 
285
  compose_prompt: "Prompt",
286
  compose_max_image_dim: "Max image (px)",
287
  compose_max_image_dim_hint: "0 = full resolution (default, methodology unchanged). > 0 shrinks the image sent to the VLM (image modes) to avoid 429s — changes methodology, fingerprinted as a separate run.",
288
+ compose_expose_alto_label: "Produce native ALTO XML",
289
+ compose_expose_alto_hint: "Enables the \"alto_documentary\" report view. Tesseract only.",
290
+ bench_advanced_options: "Advanced options",
291
+ bench_advanced_options_hint: "report views, language, NER, resume, JSON export",
292
+ bench_report_lang_label: "Report language",
293
+ bench_entity_extractor_label: "NER extractor (optional)",
294
+ bench_entity_extractor_hint: "Format: module.submodule:Symbol. Unlocks the NER block of the HTML report.",
295
+ bench_views_label: "Evaluation views to enable",
296
+ bench_view_text_final: "text_final (always on)",
297
+ bench_view_alto_documentary: "alto_documentary",
298
+ bench_view_searchability: "searchability",
299
+ bench_views_hint: "alto_documentary requires at least one Tesseract competitor with expose_alto enabled.",
300
+ bench_partial_resume_label: "Enable resume after interruption",
301
+ bench_partial_resume_hint: "Creates a checkpoint directory to resume an interrupted run.",
302
+ bench_output_json_label: "Also export as JSON",
303
+ bench_output_json_hint: "Generates an additional JSON file alongside the HTML report.",
304
  compose_add: "+ Add",
305
  compose_empty: "No competitors added.",
306
  mode_text_only: "Text post-correction",
 
576
  const engine = document.getElementById("compose-ocr-engine").value;
577
  _pendingOCREngine = engine; // marquer la requête courante
578
  const sp = document.getElementById("sp-ocr-model");
579
+ // expose_alto est spécifique à Tesseract. Visible uniquement pour ce moteur.
580
+ const altoWrap = document.getElementById("compose-expose-alto-wrap");
581
+ if (altoWrap) {
582
+ altoWrap.style.display = engine === "tesseract" ? "block" : "none";
583
+ if (engine !== "tesseract") {
584
+ const cb = document.getElementById("compose-expose-alto");
585
+ if (cb) cb.checked = false;
586
+ }
587
+ }
588
  // Google Vision et Azure ont des listes statiques — pas d'appel API nécessaire
589
  if (engine === "google_vision") {
590
  sp.style.display = "none";
 
699
 
700
  const comp = { name: "", engine_name: "", ocr_model: "",
701
  llm_provider: "", llm_model: "", pipeline_mode: "", prompt_file: "",
702
+ max_image_dimension: 0, expose_alto: false };
703
  const _maxImgDim = parseInt((document.getElementById("compose-max-image-dim") || {}).value, 10);
704
  const maxImgDim = Number.isFinite(_maxImgDim) && _maxImgDim > 0 ? _maxImgDim : 0;
705
+ // expose_alto n'est lu que pour Tesseract (les autres moteurs ne le
706
+ // propagent pas — ignoré côté adapter, mais on ne l'envoie pas pour
707
+ // garder le payload propre).
708
+ const exposeAltoCb = document.getElementById("compose-expose-alto");
709
+ const composeExposeAlto = !!(exposeAltoCb && exposeAltoCb.checked);
710
 
711
  if (mode === "postcorrection") {
712
  // Post-correction : OCR vient du corpus (.ocr.txt)
 
737
  comp.pipeline_mode = document.getElementById("compose-pipeline-mode").value;
738
  comp.prompt_file = document.getElementById("compose-prompt").value;
739
  comp.max_image_dimension = maxImgDim;
740
+ if (ocrEngine === "tesseract") comp.expose_alto = composeExposeAlto;
741
  if (!comp.llm_provider) {
742
  errEl.textContent = lang === "fr" ? "Sélectionnez un provider LLM." : "Select an LLM provider.";
743
  return;
 
755
  }
756
  comp.engine_name = ocrEngine;
757
  comp.ocr_model = ocrModel;
758
+ if (ocrEngine === "tesseract") comp.expose_alto = composeExposeAlto;
759
+ const altoSuffix = (ocrEngine === "tesseract" && composeExposeAlto) ? " · ALTO" : "";
760
+ comp.name = `${ocrEngine}${ocrModel ? " ("+ocrModel+")" : ""}${altoSuffix}`;
761
  }
762
 
763
  errEl.textContent = "";
 
919
  output_dir: document.getElementById("output-dir").value,
920
  report_name: document.getElementById("report-name").value,
921
  profile: (document.getElementById("run-profile") || {}).value || "standard",
922
+ ..._gatherAdvancedOptions(),
923
  };
924
 
925
  document.getElementById("start-btn").disabled = true;
 
1653
  // côté serveur (avec tests dédiés) mais aucun bouton ne les appelait —
1654
  // code zombie typique post-rewrite.
1655
 
1656
+ function _gatherAdvancedOptions() {
1657
+ /** Collecte les 5 champs de la section « Options avancées » sous
1658
+ * forme de dict prêt à être étalé dans le payload
1659
+ * ``POST /api/benchmark/run`` ou la sauvegarde de config.
1660
+ *
1661
+ * Champs collectés :
1662
+ * - ``report_lang`` ("fr" | "en")
1663
+ * - ``views`` liste des vues activées (text_final toujours inclus)
1664
+ * - ``partial_dir`` chemin auto-généré si toggle ON, "" sinon
1665
+ * - ``entity_extractor`` dotted path NER, "" si vide
1666
+ * - ``output_json`` chemin auto si toggle ON, "" sinon
1667
+ *
1668
+ * ``expose_alto`` n'est PAS dans ce dict — il est par-compétiteur
1669
+ * dans ``_competitors[i].expose_alto`` (positionné par addCompetitor).
1670
+ */
1671
+ const views = ["text_final"];
1672
+ if ((document.getElementById("view-alto-documentary") || {}).checked) {
1673
+ views.push("alto_documentary");
1674
+ }
1675
+ if ((document.getElementById("view-searchability") || {}).checked) {
1676
+ views.push("searchability");
1677
+ }
1678
+ // Reprise : si toggle ON, générer un partial_dir relatif avec
1679
+ // timestamp lisible. Le validator Pydantic refuse les chemins
1680
+ // absolus et ``..``, ce format est sûr.
1681
+ let partialDir = "";
1682
+ if ((document.getElementById("enable-partial-resume") || {}).checked) {
1683
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
1684
+ partialDir = `partial/run-${ts}`;
1685
+ }
1686
+ // Export JSON : si toggle ON, dériver le chemin depuis report_name
1687
+ // (ou un nom par défaut). Relatif au output_dir côté serveur.
1688
+ let outputJson = "";
1689
+ if ((document.getElementById("enable-output-json") || {}).checked) {
1690
+ const stem = (document.getElementById("report-name").value || "rapport").trim();
1691
+ outputJson = `${stem}.json`;
1692
+ }
1693
+ return {
1694
+ report_lang: (document.getElementById("report-lang") || {}).value || "fr",
1695
+ views: views,
1696
+ partial_dir: partialDir,
1697
+ entity_extractor: (document.getElementById("entity-extractor") || {}).value.trim(),
1698
+ output_json: outputJson,
1699
+ };
1700
+ }
1701
+
1702
  function _gatherCurrentConfig() {
1703
  /** Sérialise l'état UI courant en dict compatible
1704
  * ``/api/config/save``. Inclut les compétiteurs composés
1705
+ * (_competitors), les options de normalisation, le profil de
1706
+ * langue rapport et les options avancées. */
1707
  return {
1708
  label: document.getElementById("report-name").value || "picarones-config",
1709
  corpus_path: document.getElementById("corpus-path").value,
 
1713
  output_dir: document.getElementById("output-dir").value,
1714
  report_name: document.getElementById("report-name").value,
1715
  profile: (document.getElementById("run-profile") || {}).value || "standard",
1716
+ ..._gatherAdvancedOptions(),
1717
  };
1718
  }
1719
 
 
1826
  _competitors = cfg.competitors;
1827
  renderCompetitors();
1828
  }
1829
+ // Options avancées
1830
+ if (typeof cfg.report_lang === "string") {
1831
+ const sel = document.getElementById("report-lang");
1832
+ if (sel) sel.value = cfg.report_lang;
1833
+ }
1834
+ if (Array.isArray(cfg.views)) {
1835
+ const altoCb = document.getElementById("view-alto-documentary");
1836
+ if (altoCb) altoCb.checked = cfg.views.includes("alto_documentary");
1837
+ const searchCb = document.getElementById("view-searchability");
1838
+ if (searchCb) searchCb.checked = cfg.views.includes("searchability");
1839
+ }
1840
+ if (typeof cfg.entity_extractor === "string") {
1841
+ const inp = document.getElementById("entity-extractor");
1842
+ if (inp) inp.value = cfg.entity_extractor;
1843
+ }
1844
+ // ``partial_dir`` / ``output_json`` non vides → toggle ON (l'UI
1845
+ // régénère le chemin auto à la prochaine soumission, donc la
1846
+ // valeur exacte n'est pas restaurée mais le toggle l'est).
1847
+ const enablePartial = document.getElementById("enable-partial-resume");
1848
+ if (enablePartial) enablePartial.checked = !!(cfg.partial_dir && cfg.partial_dir !== "");
1849
+ const enableJson = document.getElementById("enable-output-json");
1850
+ if (enableJson) enableJson.checked = !!(cfg.output_json && cfg.output_json !== "");
1851
  }
1852
 
1853
  // ─── Init ────────────────────────────────────────────────────────────────────
picarones/interfaces/web/templates/_view_benchmark.html CHANGED
@@ -128,6 +128,19 @@
128
  </div>
129
  </div>
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  <div id="compose-pipeline-section" style="display:none; margin-top:14px;">
132
  <div class="grid-2" style="gap:14px;">
133
  <div class="field">
@@ -243,6 +256,81 @@
243
  <input type="text" id="report-name" placeholder="rapport_2026_05_20" class="mono-input" />
244
  </div>
245
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  </div>
247
  </div>
248
 
 
128
  </div>
129
  </div>
130
 
131
+ {# expose_alto : visible uniquement pour Tesseract. Active la
132
+ production native d'ALTO XML, débloquant la vue
133
+ ``alto_documentary`` du rapport HTML. #}
134
+ <div id="compose-expose-alto-wrap" class="field" style="margin-top:12px;">
135
+ <label class="row" style="gap:8px; align-items:flex-start; cursor:pointer;">
136
+ <input type="checkbox" id="compose-expose-alto" />
137
+ <span>
138
+ <span class="field-label" style="display:inline; margin:0;" data-i18n="compose_expose_alto_label">Produire l'ALTO XML natif</span>
139
+ <small class="help" style="display:block; margin-top:4px; padding-left:0; border-left:0;" data-i18n="compose_expose_alto_hint">Active la vue « alto_documentary » du rapport. Tesseract uniquement.</small>
140
+ </span>
141
+ </label>
142
+ </div>
143
+
144
  <div id="compose-pipeline-section" style="display:none; margin-top:14px;">
145
  <div class="grid-2" style="gap:14px;">
146
  <div class="field">
 
256
  <input type="text" id="report-name" placeholder="rapport_2026_05_20" class="mono-input" />
257
  </div>
258
  </div>
259
+
260
+ {# Options avancées (collapsible) — débloquent les vues
261
+ alto/searchability, le rapport en/fr, l'extracteur NER, la
262
+ reprise sur interruption et l'export JSON additionnel. #}
263
+ <details id="bench-advanced-options" style="margin-top:18px;">
264
+ <summary style="cursor:pointer; font-weight:500; padding:8px 0; color:var(--g-700);">
265
+ <span data-i18n="bench_advanced_options">Options avancées</span>
266
+ <small class="help" style="display:inline; margin-left:8px; padding-left:0; border-left:0;" data-i18n="bench_advanced_options_hint">vues du rapport, langue, NER, reprise, export JSON</small>
267
+ </summary>
268
+
269
+ <div class="grid-2" style="gap:14px; margin-top:14px;">
270
+ <div class="field">
271
+ <div class="field-label"><span data-i18n="bench_report_lang_label">Langue du rapport</span></div>
272
+ <select id="report-lang">
273
+ <option value="fr">Français</option>
274
+ <option value="en">English</option>
275
+ </select>
276
+ </div>
277
+ <div class="field">
278
+ <div class="field-label">
279
+ <span data-i18n="bench_entity_extractor_label">Extracteur NER (optionnel)</span>
280
+ </div>
281
+ <input type="text" id="entity-extractor" class="mono-input"
282
+ list="entity-extractor-presets"
283
+ placeholder="ex : spacy.fr_core_news_sm" />
284
+ <datalist id="entity-extractor-presets">
285
+ <option value="spacy.fr_core_news_sm">Spacy — français standard</option>
286
+ <option value="spacy.en_core_web_sm">Spacy — English standard</option>
287
+ </datalist>
288
+ <small class="help" style="margin-top:6px; padding-left:0; border-left:0;" data-i18n="bench_entity_extractor_hint">Format : <code>module.submodule:Symbol</code>. Débloque le bloc NER du rapport HTML.</small>
289
+ </div>
290
+ </div>
291
+
292
+ <div class="field" style="margin-top:14px;">
293
+ <div class="field-label"><span data-i18n="bench_views_label">Vues d'évaluation à activer</span></div>
294
+ <div class="row" style="gap:18px; flex-wrap:wrap; margin-top:4px;">
295
+ <label class="row" style="gap:6px; cursor:pointer;">
296
+ <input type="checkbox" id="view-text-final" checked disabled />
297
+ <span data-i18n="bench_view_text_final">text_final (toujours actif)</span>
298
+ </label>
299
+ <label class="row" style="gap:6px; cursor:pointer;">
300
+ <input type="checkbox" id="view-alto-documentary" />
301
+ <span data-i18n="bench_view_alto_documentary">alto_documentary</span>
302
+ </label>
303
+ <label class="row" style="gap:6px; cursor:pointer;">
304
+ <input type="checkbox" id="view-searchability" />
305
+ <span data-i18n="bench_view_searchability">searchability</span>
306
+ </label>
307
+ </div>
308
+ <small class="help" style="margin-top:6px; padding-left:0; border-left:0;" data-i18n="bench_views_hint">
309
+ <code>alto_documentary</code> nécessite qu'au moins un concurrent Tesseract ait <code>expose_alto</code> activé.
310
+ </small>
311
+ </div>
312
+
313
+ <div class="grid-2" style="gap:14px; margin-top:14px;">
314
+ <div class="field">
315
+ <label class="row" style="gap:8px; align-items:flex-start; cursor:pointer;">
316
+ <input type="checkbox" id="enable-partial-resume" />
317
+ <span>
318
+ <span class="field-label" style="display:inline; margin:0;" data-i18n="bench_partial_resume_label">Permettre la reprise sur interruption</span>
319
+ <small class="help" style="display:block; margin-top:4px; padding-left:0; border-left:0;" data-i18n="bench_partial_resume_hint">Crée un répertoire de checkpoint pour reprendre un run interrompu.</small>
320
+ </span>
321
+ </label>
322
+ </div>
323
+ <div class="field">
324
+ <label class="row" style="gap:8px; align-items:flex-start; cursor:pointer;">
325
+ <input type="checkbox" id="enable-output-json" />
326
+ <span>
327
+ <span class="field-label" style="display:inline; margin:0;" data-i18n="bench_output_json_label">Exporter aussi en JSON</span>
328
+ <small class="help" style="display:block; margin-top:4px; padding-left:0; border-left:0;" data-i18n="bench_output_json_hint">Génère un fichier JSON additionnel à côté du rapport HTML.</small>
329
+ </span>
330
+ </label>
331
+ </div>
332
+ </div>
333
+ </details>
334
  </div>
335
  </div>
336
 
tests/web/test_benchmark_request_options.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests S1 — toggles UI exposés pour les options `BenchmarkRunRequest`.
2
+
3
+ Sprint S1 expose dans le template web 6 champs Pydantic qui étaient
4
+ figés côté UI :
5
+
6
+ - ``report_lang`` (FR / EN)
7
+ - ``views`` (text_final + alto_documentary + searchability)
8
+ - ``expose_alto`` (par-compétiteur, sur Tesseract uniquement)
9
+ - ``entity_extractor`` (dotted path NER)
10
+ - ``output_json`` (toggle qui dérive un chemin auto)
11
+ - ``partial_dir`` (toggle qui dérive un chemin auto)
12
+
13
+ Ces tests vérifient :
14
+
15
+ 1. La présence des éléments DOM dans le template HTML rendu
16
+ (l'UI peut effectivement collecter ces valeurs).
17
+ 2. Le payload complet UI-style est accepté par
18
+ ``POST /api/benchmark/run`` (compat ascendante préservée).
19
+ 3. Le payload sans les nouveaux champs continue de marcher (les
20
+ défauts Pydantic restent valides).
21
+
22
+ Le contrat **API ↔ Pydantic** lui-même (validation positive/négative,
23
+ path traversal) est testé exhaustivement par
24
+ ``tests/web/test_benchmark_run_b3_final_fields.py``. Ce module-ci
25
+ cible la couche **UI → JSON payload**.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from pathlib import Path
31
+
32
+ import pytest
33
+ from fastapi.testclient import TestClient
34
+
35
+
36
+ @pytest.fixture
37
+ def client():
38
+ from picarones.interfaces.web.app import app
39
+ return TestClient(app)
40
+
41
+
42
+ @pytest.fixture
43
+ def workspace_corpus(tmp_path: Path) -> str:
44
+ """Crée un corpus minimal sous un workspace autorisé pour les
45
+ validators d'``output_dir`` / ``corpus_path``."""
46
+ from PIL import Image
47
+
48
+ img_path = tmp_path / "doc01.png"
49
+ Image.new("RGB", (40, 40), color=(255, 255, 255)).save(img_path)
50
+ (tmp_path / "doc01.gt.txt").write_text("hello", encoding="utf-8")
51
+ return str(tmp_path)
52
+
53
+
54
+ # ─────────────────────────────────────────────────────────────────────────────
55
+ # 1. Présence des éléments DOM dans le template
56
+ # ─────────────────────────────────────────────────────────────────────────────
57
+
58
+
59
+ class TestAdvancedOptionsUIElements:
60
+ """Le template ``_view_benchmark.html`` doit contenir les IDs DOM
61
+ consommés par ``_gather_advanced_options()`` côté JS."""
62
+
63
+ @pytest.fixture
64
+ def html(self, client) -> str:
65
+ response = client.get("/")
66
+ assert response.status_code == 200
67
+ return response.text
68
+
69
+ def test_report_lang_select_present(self, html: str) -> None:
70
+ assert 'id="report-lang"' in html, (
71
+ "Sélecteur de langue du rapport manquant — "
72
+ "report_lang ne sera jamais transmis depuis l'UI."
73
+ )
74
+ # Options FR + EN visibles
75
+ assert 'value="fr"' in html
76
+ assert 'value="en"' in html
77
+
78
+ def test_views_checkboxes_present(self, html: str) -> None:
79
+ """Les checkboxes pour alto_documentary et searchability."""
80
+ assert 'id="view-alto-documentary"' in html
81
+ assert 'id="view-searchability"' in html
82
+ # text_final reste activé en permanence (checkbox disabled)
83
+ assert 'id="view-text-final"' in html
84
+
85
+ def test_entity_extractor_input_present(self, html: str) -> None:
86
+ assert 'id="entity-extractor"' in html
87
+ # Le datalist propose des presets utiles
88
+ assert "spacy.fr_core_news_sm" in html
89
+ assert "spacy.en_core_web_sm" in html
90
+
91
+ def test_partial_resume_toggle_present(self, html: str) -> None:
92
+ assert 'id="enable-partial-resume"' in html
93
+
94
+ def test_output_json_toggle_present(self, html: str) -> None:
95
+ assert 'id="enable-output-json"' in html
96
+
97
+ def test_expose_alto_checkbox_present(self, html: str) -> None:
98
+ """``expose_alto`` est dans la section compose (par-compétiteur)."""
99
+ assert 'id="compose-expose-alto"' in html
100
+ # Wrap dont la visibilité dépend du moteur (Tesseract uniquement).
101
+ assert 'id="compose-expose-alto-wrap"' in html
102
+
103
+ def test_advanced_options_collapsible_present(self, html: str) -> None:
104
+ """Section ``<details>`` qui regroupe les options avancées."""
105
+ assert 'id="bench-advanced-options"' in html
106
+
107
+
108
+ # ─────────────────────────────────────────────────────────────────────────────
109
+ # 2. Payload UI-style complet accepté par l'API
110
+ # ─────────────────────────────────────────────────────────────────────────────
111
+
112
+
113
+ class TestFullUIStylePayloadAccepted:
114
+ """Un payload qui reflète la sérialisation produite par
115
+ ``_gather_advanced_options()`` doit être accepté tel quel."""
116
+
117
+ def test_payload_with_all_advanced_options(
118
+ self, client, workspace_corpus: str,
119
+ ) -> None:
120
+ """Smoke test : payload UI-style avec les 6 toggles activés.
121
+
122
+ Note : le validator de chemins est strict — on utilise
123
+ ``partial/run-2026-05-23`` (relatif, sans ``..``) et
124
+ ``rapport.json`` (basename).
125
+ """
126
+ payload = {
127
+ "corpus_path": workspace_corpus,
128
+ "competitors": [
129
+ {
130
+ "name": "tesseract (fra) · ALTO",
131
+ "engine_name": "tesseract",
132
+ "ocr_model": "fra",
133
+ "expose_alto": True,
134
+ "max_image_dimension": 0,
135
+ },
136
+ ],
137
+ "normalization_profile": "nfc",
138
+ "char_exclude": "",
139
+ "output_dir": workspace_corpus, # même workspace
140
+ "report_name": "test_s1",
141
+ "profile": "standard",
142
+ # Options avancées :
143
+ "report_lang": "en",
144
+ "views": ["text_final", "alto_documentary", "searchability"],
145
+ "partial_dir": "partial/run-2026-05-23",
146
+ "entity_extractor": "spacy.fr_core_news_sm",
147
+ "output_json": "test_s1.json",
148
+ }
149
+ # On ne lance pas vraiment le benchmark (lourd) — on vérifie
150
+ # juste que Pydantic accepte le payload et que le runner
151
+ # démarre un job. Le retour est ``{"job_id": ..., "status": "pending"}``.
152
+ response = client.post("/api/benchmark/run", json=payload)
153
+ # Accepte 200 (job démarré) ou 429 (rate-limited en CI parallèle)
154
+ # mais surtout pas 422 (validation).
155
+ assert response.status_code != 422, (
156
+ f"Payload rejeté par Pydantic : {response.json()}"
157
+ )
158
+
159
+ def test_payload_with_minimal_advanced_options(
160
+ self, client, workspace_corpus: str,
161
+ ) -> None:
162
+ """Payload sans les nouveaux champs : compat ascendante.
163
+
164
+ Un client qui n'envoie aucun champ avancé doit continuer à
165
+ marcher. Les défauts Pydantic prennent le relais.
166
+ """
167
+ payload = {
168
+ "corpus_path": workspace_corpus,
169
+ "competitors": [
170
+ {"name": "tesseract", "engine_name": "tesseract"},
171
+ ],
172
+ "output_dir": workspace_corpus,
173
+ }
174
+ response = client.post("/api/benchmark/run", json=payload)
175
+ assert response.status_code != 422, (
176
+ f"Payload minimal rejeté : {response.json()}"
177
+ )
178
+
179
+
180
+ # ─────────────────────────────────────────────────────────────────────────────
181
+ # 3. expose_alto par-compétiteur : transmission propre
182
+ # ─────────────────────────────────────────────────────────────────────────────
183
+
184
+
185
+ class TestExposeAltoPerCompetitor:
186
+ """``expose_alto`` est un champ de ``PipelineConfig``, pas de
187
+ ``BenchmarkRunRequest`` : il s'applique par-compétiteur."""
188
+
189
+ def test_expose_alto_only_on_tesseract_competitor(
190
+ self, client, workspace_corpus: str,
191
+ ) -> None:
192
+ """Mixer un compétiteur Tesseract+expose_alto et un autre OCR
193
+ cloud sans expose_alto doit fonctionner."""
194
+ payload = {
195
+ "corpus_path": workspace_corpus,
196
+ "competitors": [
197
+ {
198
+ "name": "tesseract (ALTO)",
199
+ "engine_name": "tesseract",
200
+ "ocr_model": "fra",
201
+ "expose_alto": True,
202
+ },
203
+ {
204
+ "name": "mistral_ocr",
205
+ "engine_name": "mistral_ocr",
206
+ "expose_alto": False, # No-op pour Mistral
207
+ },
208
+ ],
209
+ "output_dir": workspace_corpus,
210
+ "views": ["text_final", "alto_documentary"],
211
+ }
212
+ response = client.post("/api/benchmark/run", json=payload)
213
+ assert response.status_code != 422
214
+
215
+ def test_expose_alto_default_false(self, client, workspace_corpus: str) -> None:
216
+ """Compat ascendante : un client qui n'envoie pas expose_alto
217
+ reçoit le défaut ``false``."""
218
+ from picarones.interfaces.web.models import PipelineConfig
219
+
220
+ config = PipelineConfig(name="t", engine_name="tesseract")
221
+ assert config.expose_alto is False
222
+
223
+
224
+ # ─────────────────────────────────────────────────────────────────────────────
225
+ # 4. Synchronisation i18n des nouvelles clés
226
+ # ─────────────────────────────────────────────────────────────��───────────────
227
+
228
+
229
+ class TestI18nNewKeysCovered:
230
+ """Les nouveaux libellés doivent avoir une traduction FR ET EN
231
+ pour ne pas dégrader silencieusement en clé brute (``bench_views_label``)
232
+ quand la langue d'interface est EN."""
233
+
234
+ @pytest.fixture
235
+ def js_source(self) -> str:
236
+ path = (
237
+ Path(__file__).resolve().parents[2]
238
+ / "picarones" / "interfaces" / "web" / "static" / "web-app.js"
239
+ )
240
+ return path.read_text(encoding="utf-8")
241
+
242
+ @pytest.mark.parametrize("key", [
243
+ "compose_expose_alto_label",
244
+ "compose_expose_alto_hint",
245
+ "bench_advanced_options",
246
+ "bench_report_lang_label",
247
+ "bench_entity_extractor_label",
248
+ "bench_views_label",
249
+ "bench_view_alto_documentary",
250
+ "bench_view_searchability",
251
+ "bench_partial_resume_label",
252
+ "bench_output_json_label",
253
+ ])
254
+ def test_key_present_in_both_languages(self, js_source: str, key: str) -> None:
255
+ """Chaque clé doit apparaître au moins **deux fois** dans la
256
+ source — une fois dans ``T.fr`` et une fois dans ``T.en``."""
257
+ count = js_source.count(f"{key}:")
258
+ assert count >= 2, (
259
+ f"Clé i18n {key!r} déclarée seulement {count} fois ; "
260
+ "attendu ≥ 2 (FR + EN). Risque : clé brute affichée en EN."
261
+ )