Spaces:
Sleeping
refactor(api): renommer PipelineConfig.ocr_engine → engine_name (Phase 5b)
Browse filesLe field ``PipelineConfig.ocr_engine`` (Pydantic, exposé via
``/api/benchmark/run``) acceptait depuis longtemps des valeurs qui ne
sont PAS des moteurs OCR :
- ``""`` pour les pipelines LLM seuls (zero-shot VLM)
- ``"corpus"`` pour utiliser un OCR pré-calculé sur disque
- ``"mistral_ocr"`` et autres providers cloud OCR
Le préfixe ``ocr_`` était donc trompeur — c'est en réalité l'identifiant
du moteur de transcription primaire (OCR, VLM, ou source pré-calculée).
Renommé en ``engine_name`` pour refléter la sémantique réelle.
Rupture API :
- Le payload JSON doit utiliser ``engine_name`` (Pydantic v2 ignore
silencieusement le legacy ``ocr_engine`` extra → engine_name reste
vide → benchmark refuse). Pas d'alias rétrocompat.
- ``PipelineConfig(engine_name="…")`` côté Python.
- ``comp.engine_name`` partout dans le router + worker.
Préservé (sémantique différente, malgré le même nom) :
- ``OCRLLMPipelineConfig.ocr_engine`` (property alias de ``ocr_adapter``,
utilisé par ``build_adapter_resolver`` et ``_ocr_llm_pipeline_to_spec``)
reste intact — c'est un adapter, pas un nom de moteur.
- IDs HTML ``compose-ocr-engine``, ``ocr-engines-status-list`` et label
i18n ``compose_ocr_engine`` (composants UI, pas du contrat API).
Modifications :
- ``picarones/interfaces/web/models.py`` : ``ocr_engine`` → ``engine_name``
- ``benchmark_utils.py`` : accès ``comp.engine_name``, kwarg dans
``_legacy_request_to_run_request``, messages d'erreur.
- ``routers/benchmark.py`` : ``assert_engines_allowed`` lit
``comp.engine_name``.
- ``static/web-app.js`` : payload ``engine_name`` + accès JS
``comp.engine_name``/``c.engine_name``.
- 7 fichiers de tests : kwargs ``engine_name=`` + accès
``comp.engine_name``.
- ``test_sprint24_security.py`` : payload JSON utilise ``engine_name``
(sinon le 403 mode public ne déclenche plus car Pydantic ignore
``ocr_engine`` extra → liste engines vide).
- ``test_file_budgets.py`` : budget ``_workflows.py`` relevé à 620
pour absorber Phase 4.5 (HTML auto).
Nouveaux tests ``TestPipelineConfigEngineNameRename`` (3 tests) :
acceptation engine_name, rejet silencieux legacy, propagation router.
Tests : 4650 passed (vs 4643 avant), 12 skipped, 0 failed.
https://claude.ai/code/session_01ArfZ8kcgv7Cyda7VbJVmpn
- picarones/interfaces/web/benchmark_utils.py +4 -4
- picarones/interfaces/web/models.py +12 -3
- picarones/interfaces/web/routers/benchmark.py +1 -1
- picarones/interfaces/web/static/web-app.js +7 -7
- tests/architecture/test_file_budgets.py +4 -1
- tests/integration/test_s9_prompt_loading_defenses.py +1 -1
- tests/security/test_phase1_post_rewrite_wiring.py +88 -2
- tests/web/test_s8_benchmark_utils_factory.py +19 -19
- tests/web/test_s9_ocr_engine_naming_contract.py +4 -4
- tests/web/test_s9_prompt_loading.py +3 -3
- tests/web/test_sprint24_security.py +2 -2
|
@@ -227,12 +227,12 @@ def _engine_from_competitor(comp: PipelineConfig) -> Any:
|
|
| 227 |
- ``ocr_engine`` = ``""`` + ``llm_provider`` → LLM seul (zero-shot
|
| 228 |
ou post-correction).
|
| 229 |
"""
|
| 230 |
-
engine_id = comp.
|
| 231 |
is_corpus_ocr = engine_id in ("corpus", "")
|
| 232 |
|
| 233 |
if is_corpus_ocr and not comp.llm_provider:
|
| 234 |
raise ValueError(
|
| 235 |
-
"
|
| 236 |
"(pour la post-correction ou le zero-shot)"
|
| 237 |
)
|
| 238 |
|
|
@@ -330,7 +330,7 @@ def run_benchmark_thread_v2(job: BenchmarkJob, req: BenchmarkRunRequest) -> None
|
|
| 330 |
job.add_event("log", {"message": f"Concurrent : {eng.name}"})
|
| 331 |
except Exception as exc: # noqa: BLE001
|
| 332 |
job.add_event("warning", {
|
| 333 |
-
"message": f"Concurrent ignoré '{comp.name or comp.
|
| 334 |
})
|
| 335 |
|
| 336 |
if not engines:
|
|
@@ -436,7 +436,7 @@ def _legacy_request_to_run_request(req: BenchmarkRequest) -> BenchmarkRunRequest
|
|
| 436 |
competitors.append(
|
| 437 |
PipelineConfig(
|
| 438 |
name="",
|
| 439 |
-
|
| 440 |
ocr_model=model,
|
| 441 |
llm_provider="",
|
| 442 |
llm_model="",
|
|
|
|
| 227 |
- ``ocr_engine`` = ``""`` + ``llm_provider`` → LLM seul (zero-shot
|
| 228 |
ou post-correction).
|
| 229 |
"""
|
| 230 |
+
engine_id = comp.engine_name
|
| 231 |
is_corpus_ocr = engine_id in ("corpus", "")
|
| 232 |
|
| 233 |
if is_corpus_ocr and not comp.llm_provider:
|
| 234 |
raise ValueError(
|
| 235 |
+
"engine_name='corpus' nécessite un llm_provider "
|
| 236 |
"(pour la post-correction ou le zero-shot)"
|
| 237 |
)
|
| 238 |
|
|
|
|
| 330 |
job.add_event("log", {"message": f"Concurrent : {eng.name}"})
|
| 331 |
except Exception as exc: # noqa: BLE001
|
| 332 |
job.add_event("warning", {
|
| 333 |
+
"message": f"Concurrent ignoré '{comp.name or comp.engine_name}' : {exc}"
|
| 334 |
})
|
| 335 |
|
| 336 |
if not engines:
|
|
|
|
| 436 |
competitors.append(
|
| 437 |
PipelineConfig(
|
| 438 |
name="",
|
| 439 |
+
engine_name=engine_name,
|
| 440 |
ocr_model=model,
|
| 441 |
llm_provider="",
|
| 442 |
llm_model="",
|
|
@@ -114,9 +114,18 @@ class HuggingFaceImportRequest(BaseModel):
|
|
| 114 |
|
| 115 |
class PipelineConfig(BaseModel):
|
| 116 |
name: str = Field(default="", max_length=_MAX_NAME)
|
| 117 |
-
|
| 118 |
-
"""
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
ocr_model: str = Field(default="", max_length=_MAX_NAME)
|
| 121 |
llm_provider: str = Field(default="", max_length=_MAX_NAME)
|
| 122 |
llm_model: str = Field(default="", max_length=_MAX_NAME)
|
|
|
|
| 114 |
|
| 115 |
class PipelineConfig(BaseModel):
|
| 116 |
name: str = Field(default="", max_length=_MAX_NAME)
|
| 117 |
+
engine_name: str = Field(default="", max_length=_MAX_NAME)
|
| 118 |
+
"""Identifiant du moteur de transcription : ``tesseract``,
|
| 119 |
+
``mistral_ocr``, ``kraken``, ``calamari``, … ou ``corpus`` pour
|
| 120 |
+
utiliser l'OCR pré-calculé. Vide (``""``) pour un pipeline LLM
|
| 121 |
+
seul (zero-shot VLM).
|
| 122 |
+
|
| 123 |
+
Phase 5b du chantier post-rewrite : renommé depuis ``ocr_engine``
|
| 124 |
+
car le field accepte aussi des VLMs (zero_shot) et des sources
|
| 125 |
+
pré-calculées (``corpus``) — le préfixe ``ocr_`` était trompeur.
|
| 126 |
+
Rupture API : les clients qui envoyaient ``ocr_engine`` reçoivent
|
| 127 |
+
désormais 422.
|
| 128 |
+
"""
|
| 129 |
ocr_model: str = Field(default="", max_length=_MAX_NAME)
|
| 130 |
llm_provider: str = Field(default="", max_length=_MAX_NAME)
|
| 131 |
llm_model: str = Field(default="", max_length=_MAX_NAME)
|
|
@@ -140,7 +140,7 @@ async def api_benchmark_run(req: BenchmarkRunRequest, request: Request) -> dict:
|
|
| 140 |
# pour le rationale).
|
| 141 |
try:
|
| 142 |
for comp in req.competitors:
|
| 143 |
-
assert_engines_allowed([comp.
|
| 144 |
assert_llm_provider_allowed(comp.llm_provider)
|
| 145 |
except PermissionError as exc:
|
| 146 |
raise HTTPException(status_code=403, detail=str(exc))
|
|
|
|
| 140 |
# pour le rationale).
|
| 141 |
try:
|
| 142 |
for comp in req.competitors:
|
| 143 |
+
assert_engines_allowed([comp.engine_name] if comp.engine_name else [])
|
| 144 |
assert_llm_provider_allowed(comp.llm_provider)
|
| 145 |
except PermissionError as exc:
|
| 146 |
raise HTTPException(status_code=403, detail=str(exc))
|
|
@@ -477,12 +477,12 @@ function addCompetitor() {
|
|
| 477 |
const mode = document.querySelector("input[name=compose-mode]:checked").value;
|
| 478 |
const errEl = document.getElementById("compose-error");
|
| 479 |
|
| 480 |
-
const comp = { name: "",
|
| 481 |
llm_provider: "", llm_model: "", pipeline_mode: "", prompt_file: "" };
|
| 482 |
|
| 483 |
if (mode === "postcorrection") {
|
| 484 |
// Post-correction : OCR vient du corpus (.ocr.txt)
|
| 485 |
-
comp.
|
| 486 |
comp.llm_provider = document.getElementById("compose-llm-provider").value;
|
| 487 |
comp.llm_model = document.getElementById("compose-llm-model").value;
|
| 488 |
comp.pipeline_mode = document.getElementById("compose-pipeline-mode").value;
|
|
@@ -500,7 +500,7 @@ function addCompetitor() {
|
|
| 500 |
errEl.textContent = lang === "fr" ? "Sélectionnez un moteur OCR." : "Select an OCR engine.";
|
| 501 |
return;
|
| 502 |
}
|
| 503 |
-
comp.
|
| 504 |
comp.ocr_model = ocrModel;
|
| 505 |
comp.llm_provider = document.getElementById("compose-llm-provider").value;
|
| 506 |
comp.llm_model = document.getElementById("compose-llm-model").value;
|
|
@@ -519,7 +519,7 @@ function addCompetitor() {
|
|
| 519 |
errEl.textContent = lang === "fr" ? "Sélectionnez un moteur OCR." : "Select an OCR engine.";
|
| 520 |
return;
|
| 521 |
}
|
| 522 |
-
comp.
|
| 523 |
comp.ocr_model = ocrModel;
|
| 524 |
comp.name = `${ocrEngine}${ocrModel ? " ("+ocrModel+")" : ""}`;
|
| 525 |
}
|
|
@@ -541,7 +541,7 @@ function renderCompetitors() {
|
|
| 541 |
return;
|
| 542 |
}
|
| 543 |
container.innerHTML = _competitors.map((c, i) => {
|
| 544 |
-
const isCorpusOCR = c.
|
| 545 |
const isPipeline = !!c.llm_provider && !isCorpusOCR;
|
| 546 |
let badge, detail;
|
| 547 |
if (isCorpusOCR) {
|
|
@@ -549,10 +549,10 @@ function renderCompetitors() {
|
|
| 549 |
detail = `corpus_ocr → ${c.llm_provider}:${c.llm_model} [${c.pipeline_mode}]`;
|
| 550 |
} else if (isPipeline) {
|
| 551 |
badge = "⛓ Pipeline";
|
| 552 |
-
detail = `${c.
|
| 553 |
} else {
|
| 554 |
badge = "🔍 OCR";
|
| 555 |
-
detail = `${c.
|
| 556 |
}
|
| 557 |
return `<div class="competitor-card">
|
| 558 |
<div class="competitor-info">
|
|
|
|
| 477 |
const mode = document.querySelector("input[name=compose-mode]:checked").value;
|
| 478 |
const errEl = document.getElementById("compose-error");
|
| 479 |
|
| 480 |
+
const comp = { name: "", engine_name: "", ocr_model: "",
|
| 481 |
llm_provider: "", llm_model: "", pipeline_mode: "", prompt_file: "" };
|
| 482 |
|
| 483 |
if (mode === "postcorrection") {
|
| 484 |
// Post-correction : OCR vient du corpus (.ocr.txt)
|
| 485 |
+
comp.engine_name = "corpus";
|
| 486 |
comp.llm_provider = document.getElementById("compose-llm-provider").value;
|
| 487 |
comp.llm_model = document.getElementById("compose-llm-model").value;
|
| 488 |
comp.pipeline_mode = document.getElementById("compose-pipeline-mode").value;
|
|
|
|
| 500 |
errEl.textContent = lang === "fr" ? "Sélectionnez un moteur OCR." : "Select an OCR engine.";
|
| 501 |
return;
|
| 502 |
}
|
| 503 |
+
comp.engine_name = ocrEngine;
|
| 504 |
comp.ocr_model = ocrModel;
|
| 505 |
comp.llm_provider = document.getElementById("compose-llm-provider").value;
|
| 506 |
comp.llm_model = document.getElementById("compose-llm-model").value;
|
|
|
|
| 519 |
errEl.textContent = lang === "fr" ? "Sélectionnez un moteur OCR." : "Select an OCR engine.";
|
| 520 |
return;
|
| 521 |
}
|
| 522 |
+
comp.engine_name = ocrEngine;
|
| 523 |
comp.ocr_model = ocrModel;
|
| 524 |
comp.name = `${ocrEngine}${ocrModel ? " ("+ocrModel+")" : ""}`;
|
| 525 |
}
|
|
|
|
| 541 |
return;
|
| 542 |
}
|
| 543 |
container.innerHTML = _competitors.map((c, i) => {
|
| 544 |
+
const isCorpusOCR = c.engine_name === "corpus" || (c.engine_name === "" && c.llm_provider);
|
| 545 |
const isPipeline = !!c.llm_provider && !isCorpusOCR;
|
| 546 |
let badge, detail;
|
| 547 |
if (isCorpusOCR) {
|
|
|
|
| 549 |
detail = `corpus_ocr → ${c.llm_provider}:${c.llm_model} [${c.pipeline_mode}]`;
|
| 550 |
} else if (isPipeline) {
|
| 551 |
badge = "⛓ Pipeline";
|
| 552 |
+
detail = `${c.engine_name}:${c.ocr_model} → ${c.llm_provider}:${c.llm_model} [${c.pipeline_mode}]`;
|
| 553 |
} else {
|
| 554 |
badge = "🔍 OCR";
|
| 555 |
+
detail = `${c.engine_name}:${c.ocr_model}`;
|
| 556 |
}
|
| 557 |
return `<div class="competitor-card">
|
| 558 |
<div class="competitor-info">
|
|
@@ -161,7 +161,10 @@ FILE_BUDGETS: dict[str, int] = {
|
|
| 161 |
"picarones/adapters/corpus/htr_united.py": 575, # actuel 473
|
| 162 |
"picarones/adapters/corpus/huggingface.py": 550, # actuel 464
|
| 163 |
# Sprint G du plan v2.0 — déplacé vers ``interfaces/cli/``.
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
| 165 |
# ``__init__.py`` du legacy CLI — plus gros que les autres car il
|
| 166 |
# contient les commandes ``info``, ``engines``, ``metrics``,
|
| 167 |
# ``report``, ``demo``.
|
|
|
|
| 161 |
"picarones/adapters/corpus/htr_united.py": 575, # actuel 473
|
| 162 |
"picarones/adapters/corpus/huggingface.py": 550, # actuel 464
|
| 163 |
# Sprint G du plan v2.0 — déplacé vers ``interfaces/cli/``.
|
| 164 |
+
# Phase 4.5 du chantier post-rewrite — ajout de
|
| 165 |
+
# ``_html_path_from_json`` + ``generate_html``/``html_lang`` aux 4
|
| 166 |
+
# workflows + génération HTML automatique côté ``_run_workflow``.
|
| 167 |
+
"picarones/interfaces/cli/_workflows.py": 620, # actuel ~585
|
| 168 |
# ``__init__.py`` du legacy CLI — plus gros que les autres car il
|
| 169 |
# contient les commandes ``info``, ``engines``, ``metrics``,
|
| 170 |
# ``report``, ``demo``.
|
|
@@ -181,7 +181,7 @@ class TestEndToEndPromptReachesLLM:
|
|
| 181 |
from picarones.adapters.llm.base import _substitute_prompt_variables
|
| 182 |
|
| 183 |
comp = PipelineConfig(
|
| 184 |
-
|
| 185 |
llm_provider="mistral", llm_model="mistral-small-latest",
|
| 186 |
pipeline_mode="text_only",
|
| 187 |
prompt_file="correction_early_modern_english.txt",
|
|
|
|
| 181 |
from picarones.adapters.llm.base import _substitute_prompt_variables
|
| 182 |
|
| 183 |
comp = PipelineConfig(
|
| 184 |
+
engine_name="tesseract", ocr_model="fra",
|
| 185 |
llm_provider="mistral", llm_model="mistral-small-latest",
|
| 186 |
pipeline_mode="text_only",
|
| 187 |
prompt_file="correction_early_modern_english.txt",
|
|
@@ -450,7 +450,7 @@ class TestPipelineModeStrictAPI:
|
|
| 450 |
from picarones.interfaces.web.models import PipelineConfig
|
| 451 |
|
| 452 |
comp = PipelineConfig(
|
| 453 |
-
name="t",
|
| 454 |
llm_provider="mistral", llm_model="m",
|
| 455 |
pipeline_mode=valid_mode,
|
| 456 |
)
|
|
@@ -462,7 +462,7 @@ class TestPipelineModeStrictAPI:
|
|
| 462 |
from picarones.interfaces.web.models import PipelineConfig
|
| 463 |
|
| 464 |
comp = PipelineConfig(
|
| 465 |
-
name="t",
|
| 466 |
)
|
| 467 |
assert comp.pipeline_mode == ""
|
| 468 |
|
|
@@ -1011,3 +1011,89 @@ class TestUploadPurgeTaskWired:
|
|
| 1011 |
# Vérification physique
|
| 1012 |
assert active.exists()
|
| 1013 |
assert not orphan.exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
from picarones.interfaces.web.models import PipelineConfig
|
| 451 |
|
| 452 |
comp = PipelineConfig(
|
| 453 |
+
name="t", engine_name="tesseract",
|
| 454 |
llm_provider="mistral", llm_model="m",
|
| 455 |
pipeline_mode=valid_mode,
|
| 456 |
)
|
|
|
|
| 462 |
from picarones.interfaces.web.models import PipelineConfig
|
| 463 |
|
| 464 |
comp = PipelineConfig(
|
| 465 |
+
name="t", engine_name="tesseract", llm_provider="",
|
| 466 |
)
|
| 467 |
assert comp.pipeline_mode == ""
|
| 468 |
|
|
|
|
| 1011 |
# Vérification physique
|
| 1012 |
assert active.exists()
|
| 1013 |
assert not orphan.exists()
|
| 1014 |
+
|
| 1015 |
+
|
| 1016 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 1017 |
+
# 9. Phase 5b — engine_name (renommage rupture du field ocr_engine)
|
| 1018 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 1019 |
+
|
| 1020 |
+
|
| 1021 |
+
class TestPipelineConfigEngineNameRename:
|
| 1022 |
+
"""Phase 5b du chantier post-rewrite : le field ``ocr_engine`` du
|
| 1023 |
+
payload ``PipelineConfig`` est renommé en ``engine_name`` car il
|
| 1024 |
+
accepte aussi des VLMs (zero_shot) et la source ``corpus`` (OCR
|
| 1025 |
+
pré-calculé) — le préfixe ``ocr_`` était trompeur.
|
| 1026 |
+
|
| 1027 |
+
Rupture API : un client qui envoie l'ancien nom doit recevoir une
|
| 1028 |
+
erreur Pydantic explicite plutôt que d'aliaser silencieusement.
|
| 1029 |
+
"""
|
| 1030 |
+
|
| 1031 |
+
def test_engine_name_field_accepted(self) -> None:
|
| 1032 |
+
from picarones.interfaces.web.models import PipelineConfig
|
| 1033 |
+
|
| 1034 |
+
cfg = PipelineConfig(
|
| 1035 |
+
name="t", engine_name="tesseract", llm_provider="",
|
| 1036 |
+
)
|
| 1037 |
+
assert cfg.engine_name == "tesseract"
|
| 1038 |
+
|
| 1039 |
+
def test_legacy_ocr_engine_kwarg_rejected_by_strict_mode(self) -> None:
|
| 1040 |
+
"""Pydantic v2 ignore par défaut les extras non déclarés mais
|
| 1041 |
+
ne reconnaît plus ``ocr_engine`` comme alias. On vérifie que
|
| 1042 |
+
passer juste ``ocr_engine=`` ne remplit pas ``engine_name``
|
| 1043 |
+
(rupture silencieuse acceptée vs explicite — Pydantic v2 ne
|
| 1044 |
+
peut pas distinguer entre 'extra ignoré' et 'mauvais nom')."""
|
| 1045 |
+
from picarones.interfaces.web.models import PipelineConfig
|
| 1046 |
+
|
| 1047 |
+
cfg = PipelineConfig(name="t", llm_provider="")
|
| 1048 |
+
# Default : engine_name=""
|
| 1049 |
+
assert cfg.engine_name == ""
|
| 1050 |
+
# Construire avec un kwarg dynamic = legacy name → engine_name
|
| 1051 |
+
# reste vide (Pydantic v2 ignore les extras non-strict).
|
| 1052 |
+
cfg2 = PipelineConfig.model_validate(
|
| 1053 |
+
{"name": "t", "ocr_engine": "tesseract", "llm_provider": ""},
|
| 1054 |
+
)
|
| 1055 |
+
assert cfg2.engine_name == "", (
|
| 1056 |
+
"Le legacy ``ocr_engine`` ne doit PAS remplir engine_name "
|
| 1057 |
+
"automatiquement — sinon on aliase silencieusement et la "
|
| 1058 |
+
"rupture API n'est pas réelle."
|
| 1059 |
+
)
|
| 1060 |
+
|
| 1061 |
+
def test_router_payload_uses_engine_name(self) -> None:
|
| 1062 |
+
"""Le router ``/api/benchmark/run`` accepte le payload
|
| 1063 |
+
avec ``engine_name`` et le propage."""
|
| 1064 |
+
from fastapi import FastAPI
|
| 1065 |
+
from fastapi.testclient import TestClient
|
| 1066 |
+
|
| 1067 |
+
from picarones.interfaces.web.routers import benchmark as bench_router
|
| 1068 |
+
|
| 1069 |
+
app = FastAPI()
|
| 1070 |
+
app.include_router(bench_router.router)
|
| 1071 |
+
with TestClient(app) as client:
|
| 1072 |
+
# On vise un payload qui valide Pydantic mais échoue à
|
| 1073 |
+
# l'instanciation moteur (corpus inexistant) — l'important
|
| 1074 |
+
# est que le 422 Pydantic ne se déclenche pas sur le field.
|
| 1075 |
+
r = client.post(
|
| 1076 |
+
"/api/benchmark/run",
|
| 1077 |
+
json={
|
| 1078 |
+
"corpus_path": "/tmp/no_such_dir_for_phase5b_test",
|
| 1079 |
+
"competitors": [{
|
| 1080 |
+
"name": "p",
|
| 1081 |
+
"engine_name": "tesseract",
|
| 1082 |
+
"ocr_model": "fra",
|
| 1083 |
+
"llm_provider": "",
|
| 1084 |
+
"llm_model": "",
|
| 1085 |
+
"pipeline_mode": "",
|
| 1086 |
+
"prompt_file": "",
|
| 1087 |
+
}],
|
| 1088 |
+
"normalization_profile": "nfc",
|
| 1089 |
+
"output_dir": "/tmp",
|
| 1090 |
+
"report_name": "test",
|
| 1091 |
+
"report_lang": "fr",
|
| 1092 |
+
},
|
| 1093 |
+
)
|
| 1094 |
+
# Pas un 422 Pydantic → le field engine_name a bien
|
| 1095 |
+
# été accepté. (400 attendu : corpus_path inexistant.)
|
| 1096 |
+
assert r.status_code != 422, (
|
| 1097 |
+
"Le router refuse le payload avec engine_name : "
|
| 1098 |
+
f"{r.text}"
|
| 1099 |
+
)
|
|
@@ -62,7 +62,7 @@ class TestBuildLLMAdapterRouting:
|
|
| 62 |
self, provider: str, expected_class_name: str,
|
| 63 |
) -> None:
|
| 64 |
comp = PipelineConfig(
|
| 65 |
-
name="t",
|
| 66 |
)
|
| 67 |
adapter = _build_llm_adapter(comp)
|
| 68 |
assert type(adapter).__name__ == expected_class_name, (
|
|
@@ -72,7 +72,7 @@ class TestBuildLLMAdapterRouting:
|
|
| 72 |
|
| 73 |
def test_unknown_provider_raises_value_error(self) -> None:
|
| 74 |
comp = PipelineConfig(
|
| 75 |
-
name="t",
|
| 76 |
llm_provider="some_made_up_provider", llm_model="x",
|
| 77 |
)
|
| 78 |
with pytest.raises(ValueError, match="inconnu|unknown"):
|
|
@@ -83,7 +83,7 @@ class TestBuildLLMAdapterRouting:
|
|
| 83 |
l'adapter (qui utilise son default interne) — pas une
|
| 84 |
chaîne vide qui serait rejetée par l'API."""
|
| 85 |
comp = PipelineConfig(
|
| 86 |
-
name="t",
|
| 87 |
)
|
| 88 |
adapter = _build_llm_adapter(comp)
|
| 89 |
# L'adapter doit être instancié sans planter sur llm_model="".
|
|
@@ -104,7 +104,7 @@ class TestEngineFromCompetitorOCROnly:
|
|
| 104 |
que deux configs distinctes obtiennent automatiquement des
|
| 105 |
identifiants différents au resolver (cf. S9 fix)."""
|
| 106 |
comp = PipelineConfig(
|
| 107 |
-
name="t",
|
| 108 |
ocr_model="fra",
|
| 109 |
)
|
| 110 |
engine = _engine_from_competitor(comp)
|
|
@@ -114,10 +114,10 @@ class TestEngineFromCompetitorOCROnly:
|
|
| 114 |
"""Garantie anti-collision : ``lang=eng`` et ``lang=fra``
|
| 115 |
produisent des ``name`` distincts au resolver."""
|
| 116 |
comp_fra = PipelineConfig(
|
| 117 |
-
|
| 118 |
)
|
| 119 |
comp_eng = PipelineConfig(
|
| 120 |
-
|
| 121 |
)
|
| 122 |
assert _engine_from_competitor(comp_fra).name == "tesseract_fra"
|
| 123 |
assert _engine_from_competitor(comp_eng).name == "tesseract_eng"
|
|
@@ -127,7 +127,7 @@ class TestEngineFromCompetitorOCROnly:
|
|
| 127 |
contrat documenté pour que le worker thread puisse
|
| 128 |
loguer ``warning`` et passer au concurrent suivant."""
|
| 129 |
comp = PipelineConfig(
|
| 130 |
-
name="t",
|
| 131 |
)
|
| 132 |
with pytest.raises(RuntimeError, match="inconnu"):
|
| 133 |
_engine_from_competitor(comp)
|
|
@@ -153,7 +153,7 @@ class TestEngineFromCompetitorPipeline:
|
|
| 153 |
``OCRLLMPipelineConfig`` (``zero_shot`` testé séparément car
|
| 154 |
il refuse l'OCR amont)."""
|
| 155 |
comp = PipelineConfig(
|
| 156 |
-
name="t",
|
| 157 |
llm_model="m", ocr_model="fra", pipeline_mode=pipeline_mode,
|
| 158 |
)
|
| 159 |
pipeline = _engine_from_competitor(comp)
|
|
@@ -173,7 +173,7 @@ class TestEngineFromCompetitorPipeline:
|
|
| 173 |
from pydantic import ValidationError
|
| 174 |
with pytest.raises(ValidationError):
|
| 175 |
PipelineConfig(
|
| 176 |
-
name="t",
|
| 177 |
llm_model="m", ocr_model="fra",
|
| 178 |
pipeline_mode=deprecated_mode,
|
| 179 |
)
|
|
@@ -184,7 +184,7 @@ class TestEngineFromCompetitorPipeline:
|
|
| 184 |
``ValueError`` claire — l'ancien fallback silencieux vers
|
| 185 |
``text_only`` masquait la config incomplète."""
|
| 186 |
comp = PipelineConfig(
|
| 187 |
-
name="t",
|
| 188 |
llm_model="m", ocr_model="fra", pipeline_mode="",
|
| 189 |
)
|
| 190 |
with pytest.raises(ValueError, match="pipeline_mode invalide"):
|
|
@@ -193,10 +193,10 @@ class TestEngineFromCompetitorPipeline:
|
|
| 193 |
def test_zero_shot_mode_requires_corpus_ocr(self) -> None:
|
| 194 |
"""Le mode ``zero_shot`` exige ``ocr_adapter=None`` au niveau
|
| 195 |
du pipeline (le VLM lit l'image directement) — donc côté
|
| 196 |
-
factory web, il doit être combiné avec ``
|
| 197 |
ou ``""``, pas avec un moteur live."""
|
| 198 |
comp = PipelineConfig(
|
| 199 |
-
name="t",
|
| 200 |
llm_model="m", pipeline_mode="zero_shot",
|
| 201 |
)
|
| 202 |
pipeline = _engine_from_competitor(comp)
|
|
@@ -205,7 +205,7 @@ class TestEngineFromCompetitorPipeline:
|
|
| 205 |
|
| 206 |
def test_pipeline_name_from_explicit_name(self) -> None:
|
| 207 |
comp = PipelineConfig(
|
| 208 |
-
name="my-pipeline",
|
| 209 |
llm_provider="mistral", llm_model="m", ocr_model="fra",
|
| 210 |
pipeline_mode="text_only",
|
| 211 |
)
|
|
@@ -215,7 +215,7 @@ class TestEngineFromCompetitorPipeline:
|
|
| 215 |
def test_pipeline_name_default_format(self) -> None:
|
| 216 |
"""Sans ``name`` explicite, format ``{engine} → {model}``."""
|
| 217 |
comp = PipelineConfig(
|
| 218 |
-
name="",
|
| 219 |
llm_model="ministral-3b-latest", ocr_model="fra",
|
| 220 |
pipeline_mode="text_only",
|
| 221 |
)
|
|
@@ -229,7 +229,7 @@ class TestEngineFromCompetitorPipeline:
|
|
| 229 |
``prompt_template`` contient désormais le CONTENU lu sur
|
| 230 |
disque, pas le filename brut."""
|
| 231 |
comp = PipelineConfig(
|
| 232 |
-
name="t",
|
| 233 |
llm_model="m", ocr_model="fra", prompt_file="",
|
| 234 |
pipeline_mode="text_only",
|
| 235 |
)
|
|
@@ -255,7 +255,7 @@ class TestEngineFromCompetitorCorpusOCR:
|
|
| 255 |
self, ocr_engine: str,
|
| 256 |
) -> None:
|
| 257 |
comp = PipelineConfig(
|
| 258 |
-
name="t",
|
| 259 |
)
|
| 260 |
with pytest.raises(ValueError, match="llm_provider"):
|
| 261 |
_engine_from_competitor(comp)
|
|
@@ -268,7 +268,7 @@ class TestEngineFromCompetitorCorpusOCR:
|
|
| 268 |
traite l'image ou l'OCR pré-calculé, l'``ocr_adapter`` est
|
| 269 |
``None``)."""
|
| 270 |
comp = PipelineConfig(
|
| 271 |
-
name="post-corr",
|
| 272 |
llm_provider="mistral", llm_model="m",
|
| 273 |
pipeline_mode="zero_shot",
|
| 274 |
)
|
|
@@ -282,7 +282,7 @@ class TestEngineFromCompetitorCorpusOCR:
|
|
| 282 |
def test_corpus_pipeline_name_format(self) -> None:
|
| 283 |
"""Sans ``name``, format ``corpus_ocr → {model}``."""
|
| 284 |
comp = PipelineConfig(
|
| 285 |
-
name="",
|
| 286 |
llm_model="ministral-3b-latest",
|
| 287 |
pipeline_mode="zero_shot",
|
| 288 |
)
|
|
@@ -308,7 +308,7 @@ class TestEngineFromCompetitorCloudWithoutSDK:
|
|
| 308 |
self, engine: str, module_path: str,
|
| 309 |
) -> None:
|
| 310 |
comp = PipelineConfig(
|
| 311 |
-
name="t",
|
| 312 |
)
|
| 313 |
with patch.dict(sys.modules, {module_path: None}):
|
| 314 |
with pytest.raises(RuntimeError, match="indisponible"):
|
|
|
|
| 62 |
self, provider: str, expected_class_name: str,
|
| 63 |
) -> None:
|
| 64 |
comp = PipelineConfig(
|
| 65 |
+
name="t", engine_name="", llm_provider=provider, llm_model="m",
|
| 66 |
)
|
| 67 |
adapter = _build_llm_adapter(comp)
|
| 68 |
assert type(adapter).__name__ == expected_class_name, (
|
|
|
|
| 72 |
|
| 73 |
def test_unknown_provider_raises_value_error(self) -> None:
|
| 74 |
comp = PipelineConfig(
|
| 75 |
+
name="t", engine_name="",
|
| 76 |
llm_provider="some_made_up_provider", llm_model="x",
|
| 77 |
)
|
| 78 |
with pytest.raises(ValueError, match="inconnu|unknown"):
|
|
|
|
| 83 |
l'adapter (qui utilise son default interne) — pas une
|
| 84 |
chaîne vide qui serait rejetée par l'API."""
|
| 85 |
comp = PipelineConfig(
|
| 86 |
+
name="t", engine_name="", llm_provider="openai", llm_model="",
|
| 87 |
)
|
| 88 |
adapter = _build_llm_adapter(comp)
|
| 89 |
# L'adapter doit être instancié sans planter sur llm_model="".
|
|
|
|
| 104 |
que deux configs distinctes obtiennent automatiquement des
|
| 105 |
identifiants différents au resolver (cf. S9 fix)."""
|
| 106 |
comp = PipelineConfig(
|
| 107 |
+
name="t", engine_name="tesseract", llm_provider="",
|
| 108 |
ocr_model="fra",
|
| 109 |
)
|
| 110 |
engine = _engine_from_competitor(comp)
|
|
|
|
| 114 |
"""Garantie anti-collision : ``lang=eng`` et ``lang=fra``
|
| 115 |
produisent des ``name`` distincts au resolver."""
|
| 116 |
comp_fra = PipelineConfig(
|
| 117 |
+
engine_name="tesseract", llm_provider="", ocr_model="fra",
|
| 118 |
)
|
| 119 |
comp_eng = PipelineConfig(
|
| 120 |
+
engine_name="tesseract", llm_provider="", ocr_model="eng",
|
| 121 |
)
|
| 122 |
assert _engine_from_competitor(comp_fra).name == "tesseract_fra"
|
| 123 |
assert _engine_from_competitor(comp_eng).name == "tesseract_eng"
|
|
|
|
| 127 |
contrat documenté pour que le worker thread puisse
|
| 128 |
loguer ``warning`` et passer au concurrent suivant."""
|
| 129 |
comp = PipelineConfig(
|
| 130 |
+
name="t", engine_name="not_an_engine", llm_provider="",
|
| 131 |
)
|
| 132 |
with pytest.raises(RuntimeError, match="inconnu"):
|
| 133 |
_engine_from_competitor(comp)
|
|
|
|
| 153 |
``OCRLLMPipelineConfig`` (``zero_shot`` testé séparément car
|
| 154 |
il refuse l'OCR amont)."""
|
| 155 |
comp = PipelineConfig(
|
| 156 |
+
name="t", engine_name="tesseract", llm_provider="mistral",
|
| 157 |
llm_model="m", ocr_model="fra", pipeline_mode=pipeline_mode,
|
| 158 |
)
|
| 159 |
pipeline = _engine_from_competitor(comp)
|
|
|
|
| 173 |
from pydantic import ValidationError
|
| 174 |
with pytest.raises(ValidationError):
|
| 175 |
PipelineConfig(
|
| 176 |
+
name="t", engine_name="tesseract", llm_provider="mistral",
|
| 177 |
llm_model="m", ocr_model="fra",
|
| 178 |
pipeline_mode=deprecated_mode,
|
| 179 |
)
|
|
|
|
| 184 |
``ValueError`` claire — l'ancien fallback silencieux vers
|
| 185 |
``text_only`` masquait la config incomplète."""
|
| 186 |
comp = PipelineConfig(
|
| 187 |
+
name="t", engine_name="tesseract", llm_provider="mistral",
|
| 188 |
llm_model="m", ocr_model="fra", pipeline_mode="",
|
| 189 |
)
|
| 190 |
with pytest.raises(ValueError, match="pipeline_mode invalide"):
|
|
|
|
| 193 |
def test_zero_shot_mode_requires_corpus_ocr(self) -> None:
|
| 194 |
"""Le mode ``zero_shot`` exige ``ocr_adapter=None`` au niveau
|
| 195 |
du pipeline (le VLM lit l'image directement) — donc côté
|
| 196 |
+
factory web, il doit être combiné avec ``engine_name=corpus``
|
| 197 |
ou ``""``, pas avec un moteur live."""
|
| 198 |
comp = PipelineConfig(
|
| 199 |
+
name="t", engine_name="corpus", llm_provider="mistral",
|
| 200 |
llm_model="m", pipeline_mode="zero_shot",
|
| 201 |
)
|
| 202 |
pipeline = _engine_from_competitor(comp)
|
|
|
|
| 205 |
|
| 206 |
def test_pipeline_name_from_explicit_name(self) -> None:
|
| 207 |
comp = PipelineConfig(
|
| 208 |
+
name="my-pipeline", engine_name="tesseract",
|
| 209 |
llm_provider="mistral", llm_model="m", ocr_model="fra",
|
| 210 |
pipeline_mode="text_only",
|
| 211 |
)
|
|
|
|
| 215 |
def test_pipeline_name_default_format(self) -> None:
|
| 216 |
"""Sans ``name`` explicite, format ``{engine} → {model}``."""
|
| 217 |
comp = PipelineConfig(
|
| 218 |
+
name="", engine_name="tesseract", llm_provider="mistral",
|
| 219 |
llm_model="ministral-3b-latest", ocr_model="fra",
|
| 220 |
pipeline_mode="text_only",
|
| 221 |
)
|
|
|
|
| 229 |
``prompt_template`` contient désormais le CONTENU lu sur
|
| 230 |
disque, pas le filename brut."""
|
| 231 |
comp = PipelineConfig(
|
| 232 |
+
name="t", engine_name="tesseract", llm_provider="mistral",
|
| 233 |
llm_model="m", ocr_model="fra", prompt_file="",
|
| 234 |
pipeline_mode="text_only",
|
| 235 |
)
|
|
|
|
| 255 |
self, ocr_engine: str,
|
| 256 |
) -> None:
|
| 257 |
comp = PipelineConfig(
|
| 258 |
+
name="t", engine_name=ocr_engine, llm_provider="",
|
| 259 |
)
|
| 260 |
with pytest.raises(ValueError, match="llm_provider"):
|
| 261 |
_engine_from_competitor(comp)
|
|
|
|
| 268 |
traite l'image ou l'OCR pré-calculé, l'``ocr_adapter`` est
|
| 269 |
``None``)."""
|
| 270 |
comp = PipelineConfig(
|
| 271 |
+
name="post-corr", engine_name=ocr_engine,
|
| 272 |
llm_provider="mistral", llm_model="m",
|
| 273 |
pipeline_mode="zero_shot",
|
| 274 |
)
|
|
|
|
| 282 |
def test_corpus_pipeline_name_format(self) -> None:
|
| 283 |
"""Sans ``name``, format ``corpus_ocr → {model}``."""
|
| 284 |
comp = PipelineConfig(
|
| 285 |
+
name="", engine_name="corpus", llm_provider="mistral",
|
| 286 |
llm_model="ministral-3b-latest",
|
| 287 |
pipeline_mode="zero_shot",
|
| 288 |
)
|
|
|
|
| 308 |
self, engine: str, module_path: str,
|
| 309 |
) -> None:
|
| 310 |
comp = PipelineConfig(
|
| 311 |
+
name="t", engine_name=engine, llm_provider="",
|
| 312 |
)
|
| 313 |
with patch.dict(sys.modules, {module_path: None}):
|
| 314 |
with pytest.raises(RuntimeError, match="indisponible"):
|
|
@@ -49,10 +49,10 @@ def test_two_distinct_configs_coexist_in_resolver(
|
|
| 49 |
des ``name`` distincts au resolver — le bug Tesseract initial,
|
| 50 |
généralisé à tous les moteurs supportés."""
|
| 51 |
comp_a = PipelineConfig(
|
| 52 |
-
|
| 53 |
)
|
| 54 |
comp_b = PipelineConfig(
|
| 55 |
-
|
| 56 |
)
|
| 57 |
try:
|
| 58 |
eng_a = _engine_from_competitor(comp_a)
|
|
@@ -83,10 +83,10 @@ def test_standalone_plus_pipeline_same_config_coexist(
|
|
| 83 |
OCR. Le resolver doit accepter (les 2 instances Python sont
|
| 84 |
fonctionnellement équivalentes, déduplication idempotente)."""
|
| 85 |
comp_standalone = PipelineConfig(
|
| 86 |
-
|
| 87 |
)
|
| 88 |
comp_pipeline = PipelineConfig(
|
| 89 |
-
|
| 90 |
llm_provider="mistral", llm_model="mistral-small-latest",
|
| 91 |
pipeline_mode="text_only",
|
| 92 |
prompt_file="correction_medieval_french.txt",
|
|
|
|
| 49 |
des ``name`` distincts au resolver — le bug Tesseract initial,
|
| 50 |
généralisé à tous les moteurs supportés."""
|
| 51 |
comp_a = PipelineConfig(
|
| 52 |
+
engine_name=engine_id, ocr_model="cfg_a", llm_provider="",
|
| 53 |
)
|
| 54 |
comp_b = PipelineConfig(
|
| 55 |
+
engine_name=engine_id, ocr_model="cfg_b", llm_provider="",
|
| 56 |
)
|
| 57 |
try:
|
| 58 |
eng_a = _engine_from_competitor(comp_a)
|
|
|
|
| 83 |
OCR. Le resolver doit accepter (les 2 instances Python sont
|
| 84 |
fonctionnellement équivalentes, déduplication idempotente)."""
|
| 85 |
comp_standalone = PipelineConfig(
|
| 86 |
+
engine_name=engine_id, ocr_model="same_config", llm_provider="",
|
| 87 |
)
|
| 88 |
comp_pipeline = PipelineConfig(
|
| 89 |
+
engine_name=engine_id, ocr_model="same_config",
|
| 90 |
llm_provider="mistral", llm_model="mistral-small-latest",
|
| 91 |
pipeline_mode="text_only",
|
| 92 |
prompt_file="correction_medieval_french.txt",
|
|
@@ -115,7 +115,7 @@ class TestEngineFromCompetitorPassesPromptContent:
|
|
| 115 |
def test_pipeline_template_contains_file_content(self) -> None:
|
| 116 |
comp = PipelineConfig(
|
| 117 |
name="t",
|
| 118 |
-
|
| 119 |
ocr_model="fra",
|
| 120 |
llm_provider="mistral",
|
| 121 |
llm_model="mistral-small-latest",
|
|
@@ -134,7 +134,7 @@ class TestEngineFromCompetitorPassesPromptContent:
|
|
| 134 |
"""``prompt_file`` vide → default
|
| 135 |
``correction_medieval_french.txt`` chargé."""
|
| 136 |
comp = PipelineConfig(
|
| 137 |
-
|
| 138 |
llm_provider="mistral", llm_model="m",
|
| 139 |
pipeline_mode="text_only", prompt_file="",
|
| 140 |
)
|
|
@@ -147,7 +147,7 @@ class TestEngineFromCompetitorPassesPromptContent:
|
|
| 147 |
factory doit lever proprement (pas continuer avec le filename
|
| 148 |
comme prompt — c'est le bug d'origine)."""
|
| 149 |
comp = PipelineConfig(
|
| 150 |
-
|
| 151 |
llm_provider="mistral", llm_model="m",
|
| 152 |
pipeline_mode="text_only",
|
| 153 |
prompt_file="prompt_que_personne_na_jamais_cree.txt",
|
|
|
|
| 115 |
def test_pipeline_template_contains_file_content(self) -> None:
|
| 116 |
comp = PipelineConfig(
|
| 117 |
name="t",
|
| 118 |
+
engine_name="tesseract",
|
| 119 |
ocr_model="fra",
|
| 120 |
llm_provider="mistral",
|
| 121 |
llm_model="mistral-small-latest",
|
|
|
|
| 134 |
"""``prompt_file`` vide → default
|
| 135 |
``correction_medieval_french.txt`` chargé."""
|
| 136 |
comp = PipelineConfig(
|
| 137 |
+
engine_name="tesseract", ocr_model="fra",
|
| 138 |
llm_provider="mistral", llm_model="m",
|
| 139 |
pipeline_mode="text_only", prompt_file="",
|
| 140 |
)
|
|
|
|
| 147 |
factory doit lever proprement (pas continuer avec le filename
|
| 148 |
comme prompt — c'est le bug d'origine)."""
|
| 149 |
comp = PipelineConfig(
|
| 150 |
+
engine_name="tesseract", ocr_model="fra",
|
| 151 |
llm_provider="mistral", llm_model="m",
|
| 152 |
pipeline_mode="text_only",
|
| 153 |
prompt_file="prompt_que_personne_na_jamais_cree.txt",
|
|
@@ -300,7 +300,7 @@ class TestPublicModeBlocksLLMBenchmark:
|
|
| 300 |
"competitors": [
|
| 301 |
{
|
| 302 |
"name": "test",
|
| 303 |
-
"
|
| 304 |
"llm_provider": "openai",
|
| 305 |
"llm_model": "gpt-4o",
|
| 306 |
"pipeline_mode": "text_only",
|
|
@@ -317,7 +317,7 @@ class TestPublicModeBlocksLLMBenchmark:
|
|
| 317 |
"corpus_path": corpus_path,
|
| 318 |
"competitors": [
|
| 319 |
{
|
| 320 |
-
"
|
| 321 |
"llm_provider": "",
|
| 322 |
},
|
| 323 |
],
|
|
|
|
| 300 |
"competitors": [
|
| 301 |
{
|
| 302 |
"name": "test",
|
| 303 |
+
"engine_name": "tesseract",
|
| 304 |
"llm_provider": "openai",
|
| 305 |
"llm_model": "gpt-4o",
|
| 306 |
"pipeline_mode": "text_only",
|
|
|
|
| 317 |
"corpus_path": corpus_path,
|
| 318 |
"competitors": [
|
| 319 |
{
|
| 320 |
+
"engine_name": "mistral_ocr",
|
| 321 |
"llm_provider": "",
|
| 322 |
},
|
| 323 |
],
|