Claude commited on
Commit
d4d4112
·
unverified ·
1 Parent(s): c21d686

feat: audit S59 institutionnel — 2 BLOCKER + 4 HIGH + 3 MEDIUM corrigés

Browse files

Audit institutionnel post-S58 (cible : release BnF/LoC/BL). L'agent a
relevé 11 findings : 2 BLOCKER, 4 HIGH, 3 MEDIUM actionnables, 2 MEDIUM
documentés non-bloquants. Tous traités sauf M1/M5 (acceptables) et M4
(refactor majeur, doc + reverse proxy recommandés).

BLOCKER
=======

B1 — RunManifest reproductibilité illusoire
Le manifest documentait la promesse "à code_version + corpus +
specs + dependencies_lock identiques, ré-exécuter doit donner les
mêmes résultats" mais NE LA TENAIT PAS :
- dependencies_lock jamais peuplé (RunOrchestrator ne le passait pas).
- pipeline_names: tuple[str, ...] portait juste les noms ; les
PipelineSpec complets (steps, params, inputs_from) absents du
manifest. Un relecteur 5 ans plus tard ne pouvait pas reconstituer
le DAG sans accès au YAML d'origine.
Fix :
- Nouveau picarones/app/services/dependencies.py avec
capture_dependencies_lock() via importlib.metadata. Capturé
systématiquement par RunOrchestrator.
- RunManifest.pipeline_specs: tuple[PipelineSpec, ...] remplace
pipeline_names (qui devient @computed_field dérivé pour les
lecteurs JSON).
- RunManifest.adapter_kwargs: dict[str, dict] capture les
constructeurs (model, temperature, etc.).
- model_validator(mode='before') accepte pipeline_names comme
alias déprécié au constructeur (rétrocompat tests + round-trip
JSON sans incohérence avec computed_field).
- tests/architecture/test_manifest_reproducibility.py : 4 tests
verrouillent le contrat (lock non vide trié, déterminisme,
extras forbid).

B2 — Suppression de symboles publics sans deprecation period
S57 avait supprimé picarones.pipeline.spec, BaseLLMAdapter.
DEFAULT_CORRECTION_PROMPT, BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPT
'parce qu'aucun caller interne ne les lisait'. Mais les callers
EXTERNES (espaces HF, scripts BnF, notebooks d'articles) n'ont pas
été consultés. Pour SemVer institutionnel, suppression d'un
symbole exporté = release MAJEURE + deprecation period documentée.
Fix :
- picarones/pipeline/spec.py restauré comme shim avec
DeprecationWarning à l'import.
- Nouveau descripteur _DeprecatedAttribute dans llm/base.py.
DEFAULT_CORRECTION_PROMPT/DEFAULT_TRANSCRIPTION_PROMPT restaurés
en attribut de classe descripteur qui émet DeprecationWarning
à l'accès, retournent la valeur FR (comportement S45).
- tests/api_stability/test_deprecated_aliases.py : 4 tests vérifient
le warning ET la cohérence valeur retournée.
Suppression effective prévue 2.0.

HIGH
====

H1 — PipelineExecutor ne filtrait pas outputs sur step.output_types
Doc Tesseract.output_types affirmait 'l'executor filtre' mais le
code ne faisait que valider la PRÉSENCE des types déclarés. Si
Tesseract émettait CONFIDENCES non déclarés au YAML, ils
propageaient en aval — bug subtil de DAG branchant.
Fix : executor.py:425 filtre outputs sur set(step.output_types)
avant persistance + retour.

H2 — Aucun test sur le parsing XFF (fix sécuritaire S58 #4)
Le commit S58 corrigeait l'IP-spoofing mais aucun test ne couvrait
_extract_ip avec les divers cas de chaîne XFF.
Fix : tests/interfaces/web/test_rate_limit_xff.py — 7 tests
(trust_proxy_count=0/1/2, chaîne plus courte, IP spoof ignorée,
whitespace, no client).

H3 — CHANGELOG ne documentait pas S58
S58 a introduit un breaking change (trust_x_forwarded_for →
trust_proxy_count) et plusieurs ajouts (ArtifactStoreError,
_MIGRATIONS, ReportRenderer alias) non listés.
Fix : section dédiée "audit institutionnel S58-S59" en tête
de CHANGELOG.md avec table des breaking changes et migrations.

H4 — Aucun retry/backoff sur les 4 OCR cloud
BaseLLMAdapter avait une logique privée de retry exponentiel.
Mistral/Google/Azure/Pero adapters n'avaient rien. Pour un bench
BnF de 5000 documents face à un service cloud, un 503 transitoire
fait planter l'OCR sur ce doc → résultat partiel non reproductible.
Fix :
- Nouveau picarones/adapters/_retry.py partagé avec
is_retryable() + call_with_retry() (3 retries, backoff 2/4/8s,
sur 429+5xx+TimeoutError+ConnectionError+URLError).
- BaseLLMAdapter délègue désormais au helper unifié.
- MistralOCRAdapter (native + chat), GoogleVisionAdapter,
AzureDocIntelAdapter wrappent leurs appels via call_with_retry.

MEDIUM
======

M2 — Audit trail manquant sur les mutations de jobs
POST/DELETE /api/jobs sans logger.info structuré pour la
traçabilité institutionnelle (création de job consomme du quota
cloud, annulation détruit des résultats partiels — actions
sensibles RGPD).
Fix : log INFO [audit] avec job_id + IP source via
request.client.host pour les deux endpoints.

M3 — DocumentRef.id autorisait les segments '..'
Le pattern _DOC_ID_RE = r'^[A-Za-z0-9_.\-/]+$' acceptait '..'.
Un caller qui construit DocumentRef(id='../../etc/passwd')
programmatiquement contournait la sandbox de resolve_output_path.
Fix : validateur Pydantic rejette tout segment '..' avec
CorpusSpecError explicite.

M6 — Lang fallback FR silencieux pour code langue inconnu
config['lang']='de' faisait fallback FR sans log. Un scientifique
BnF travaillant sur un corpus allemand ne le voyait pas.
Fix : logger.warning avec liste des langues supportées et
suggestion de fournir custom_prompt explicite. Appliqué à
BaseLLMAdapter et BaseVLMAdapter.

NON traités
===========
- M1 : tests JobStore migrations utilisent state mutation in-test.
Acceptable tant que SCHEMA_VERSION=1 ; à reprendre lors de la
première vraie migration.
- M4 : BodySizeLimitMiddleware vulnérable au Transfer-Encoding chunked
bypass. Refactor majeur (pure ASGI middleware). Nginx
client_max_body_size recommandé en amont (manuel d'opération).
- M5 : test_output_paths_uniformity utilise regex syntaxique (AST plus
robuste). Le risque de contournement est faible et ciblé.

Tests : 5010 passed (+14 nouveaux), 11 skipped, 0 failed.
Lint : ruff check picarones/ tests/ clean.

CHANGELOG.md CHANGED
@@ -7,6 +7,108 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
7
 
8
  ---
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ## [Unreleased] — rewrite A14 (S27-S46) + audit remediation (S47-S57) — 2026-05
11
 
12
  > Cette section couvre la phase **rewrite ciblé** (S27-S46) puis les
 
7
 
8
  ---
9
 
10
+ ## [Unreleased] — audit institutionnel S58-S59 (post-S57) — 2026-05
11
+
12
+ ### ⚠️ BREAKING CHANGES (déprécations en cours, suppression en 2.0)
13
+
14
+ Trois symboles supprimés au S57 sont **restaurés en S59** comme alias
15
+ dépréciés avec `DeprecationWarning` à l'accès. Ils seront supprimés
16
+ en version 2.0. Une release institutionnelle ne peut pas casser un
17
+ caller externe (espaces HuggingFace tiers, scripts BnF, notebooks de
18
+ chercheurs cités dans des articles) sans deprecation period.
19
+
20
+ | Symbole | Statut | Cible canonique |
21
+ |---------|--------|-----------------|
22
+ | `picarones.pipeline.spec` (module) | déprécié | `picarones.domain.pipeline_spec` |
23
+ | `BaseLLMAdapter.DEFAULT_CORRECTION_PROMPT` (singulier) | déprécié | `DEFAULT_CORRECTION_PROMPTS[lang]` |
24
+ | `BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPT` (singulier) | déprécié | `DEFAULT_TRANSCRIPTION_PROMPTS[lang]` |
25
+
26
+ L'argument `RateLimitMiddleware.trust_x_forwarded_for: bool` a été
27
+ **renommé en `trust_proxy_count: int`** au S58 (sémantique
28
+ sécurisée — lecture du Nème IP en partant de la fin de la chaîne XFF
29
+ au lieu du premier). Le paramètre du `create_app` correspondant
30
+ s'appelle désormais `rate_limit_trust_proxy_count`. Pas d'alias
31
+ rétrocompat — la nouvelle sémantique est incompatible avec l'ancienne.
32
+
33
+ ### REPRODUCTIBILITÉ — `RunManifest` complet (B1)
34
+
35
+ Le `RunManifest` documente la promesse *« à code_version + corpus +
36
+ specs + dependencies_lock identiques, ré-exécuter doit donner les
37
+ mêmes résultats »*. Avant S59, deux gaps majeurs :
38
+
39
+ 1. `dependencies_lock` n'était jamais peuplé — `RunOrchestrator`
40
+ appelait `bench.run(...)` sans le passer.
41
+ 2. `pipeline_names: tuple[str, ...]` ne portait que les noms ; les
42
+ `PipelineSpec` complets (steps, params, inputs_from) n'étaient
43
+ nulle part dans le manifest. Un relecteur 5 ans plus tard ne
44
+ pouvait pas reconstituer le DAG sans accès au YAML d'origine.
45
+
46
+ S59 :
47
+
48
+ - Nouveau module `picarones.app.services.dependencies` —
49
+ `capture_dependencies_lock()` via `importlib.metadata`.
50
+ `RunOrchestrator` capture systématiquement.
51
+ - `RunManifest.pipeline_specs: tuple[PipelineSpec, ...]` remplace
52
+ l'ancien `pipeline_names` (qui devient une property dérivée pour
53
+ rétrocompat des lecteurs).
54
+ - `RunManifest.adapter_kwargs: dict[str, dict]` capture les
55
+ constructeurs (model, temperature, etc.) — permet de reconstituer
56
+ `OpenAIAdapter(model="gpt-4o-2024-08-06", temperature=0.0)`.
57
+ - Test architectural `test_manifest_reproducibility.py` verrouille
58
+ le contrat : sérialisation déterministe, lock non vide trié,
59
+ rejet des champs extras.
60
+
61
+ ### FILTRAGE OUTPUTS DE STEP (H1)
62
+
63
+ `PipelineExecutor` filtre désormais le dict de retour d'`execute()`
64
+ sur `step.output_types`. Sans ça, un adapter qui produit des types
65
+ non déclarés au YAML (ex. Tesseract avec `expose_confidences=True`
66
+ mais step déclarant seulement `[raw_text]`) propageait silencieusement
67
+ des artefacts en aval — bug subtil de DAG branchant.
68
+
69
+ ### RETRY EXPONENTIEL UNIFIÉ (H4)
70
+
71
+ Nouveau module partagé `picarones.adapters._retry` avec `is_retryable`
72
+ et `call_with_retry(fn, max_retries=3, backoff_base=2.0)`. Adopté par :
73
+
74
+ - `BaseLLMAdapter.complete` (déjà avait sa logique privée — désormais
75
+ délègue au helper unique).
76
+ - `MistralOCRAdapter._call_native_ocr_api` + `_call_chat_vision_api`
77
+ - `GoogleVisionAdapter._call_via_rest`
78
+ - `AzureDocumentIntelligenceAdapter` (POST initial)
79
+
80
+ Politique : 3 retries, backoff 2/4/8s, sur 429 + 5xx + erreurs
81
+ réseau (TimeoutError, ConnectionError, URLError).
82
+
83
+ ### SÉCURITÉ ET TRAÇABILITÉ
84
+
85
+ - **Path traversal (M3)** : `DocumentRef._validate_doc_id` rejette
86
+ désormais tout segment `..` dans l'`id`. Défense en profondeur
87
+ contre un caller qui construirait `DocumentRef(id="../../etc/...")`
88
+ programmatiquement.
89
+ - **Audit trail (M2)** : `POST /api/jobs` et `DELETE /api/jobs/{id}`
90
+ émettent un log INFO `[audit]` avec l'IP source pour la traçabilité
91
+ institutionnelle (création de job consomme du quota cloud,
92
+ annulation détruit des résultats partiels — actions sensibles).
93
+ - **Test XFF (H2)** : 7 tests verrouillent le parsing
94
+ `X-Forwarded-For` du `RateLimitMiddleware` (trust_proxy_count=0/1/2,
95
+ chaîne plus courte que prévu, IP spoof tentée, whitespace, no
96
+ client).
97
+ - **Lang fallback (M6)** : `BaseLLMAdapter` et `BaseVLMAdapter`
98
+ émettent un `logger.warning` quand `config["lang"]` n'est pas dans
99
+ `DEFAULT_*_PROMPTS` et fallback silencieusement à FR — un
100
+ scientifique BnF travaillant sur un corpus allemand voit le
101
+ message dans ses logs.
102
+
103
+ ### Infrastructure de test
104
+
105
+ - `tests/api_stability/test_deprecated_aliases.py` : 4 tests sur les
106
+ alias dépréciés.
107
+ - `tests/architecture/test_manifest_reproducibility.py` : 4 tests.
108
+ - `tests/interfaces/web/test_rate_limit_xff.py` : 7 tests.
109
+
110
+ ---
111
+
112
  ## [Unreleased] — rewrite A14 (S27-S46) + audit remediation (S47-S57) — 2026-05
113
 
114
  > Cette section couvre la phase **rewrite ciblé** (S27-S46) puis les
README.md CHANGED
@@ -396,7 +396,7 @@ ruff check picarones/ tests/
396
  python -m mypy picarones/core/
397
  ```
398
 
399
- **Test suite**: ~5020 tests, ~3 min on a modern laptop. Coverage
400
  floor at 85% (currently ~87%). The `network` marker excludes tests
401
  requiring live HTTP. A handful of tests depend on optional engines
402
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
 
396
  python -m mypy picarones/core/
397
  ```
398
 
399
+ **Test suite**: ~5030 tests, ~3 min on a modern laptop. Coverage
400
  floor at 85% (currently ~87%). The `network` marker excludes tests
401
  requiring live HTTP. A handful of tests depend on optional engines
402
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
picarones/adapters/_retry.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Retry exponentiel partagé par les adapters cloud (OCR + LLM).
2
+
3
+ Pour une release institutionnelle (BnF, LoC, BL), un benchmark de
4
+ N milliers de documents face à un service cloud (Google Vision,
5
+ Azure Document Intelligence, Mistral OCR, Anthropic, OpenAI) doit
6
+ absorber les erreurs transitoires (429, 5xx, timeout réseau) sans
7
+ faire échouer le doc — sinon les résultats partiels ne sont pas
8
+ reproductibles d'un run à l'autre.
9
+
10
+ Ce module fournit la politique commune. Il vit au top du package
11
+ ``adapters/`` (et non sous ``llm/`` ou ``ocr/``) parce qu'il est
12
+ consommé par les deux familles indistinctement.
13
+
14
+ API
15
+ ---
16
+ - ``is_retryable(exc)`` : True si l'exception est typique d'un
17
+ problème transitoire.
18
+ - ``call_with_retry(callable, max_retries, backoff_base, label)`` :
19
+ exécute le callable, retry exponentiel jusqu'à ``max_retries``
20
+ tentatives. Lève la dernière exception si épuisé.
21
+
22
+ Politique
23
+ ---------
24
+ - ``max_retries=3`` (4 tentatives au total : 0 + 1 + 2 + 3 retries).
25
+ - ``backoff_base=2.0`` → 2s, 4s, 8s entre les retries (16s cumul max).
26
+ - Logs WARNING à chaque retry avec contexte.
27
+
28
+ Anti-sur-ingénierie
29
+ -------------------
30
+ - Pas de jitter randomisé : pas indispensable à ce volume ; ajouter
31
+ si un caller en a concrètement besoin.
32
+ - Pas de circuit breaker : un caller qui voit 100 % d'échec sur 5000
33
+ documents arrête le run lui-même.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ import time
40
+ from typing import Callable, TypeVar
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ DEFAULT_MAX_RETRIES = 3
45
+ DEFAULT_BACKOFF_BASE = 2.0 # secondes : 2, 4, 8
46
+
47
+ T = TypeVar("T")
48
+
49
+
50
+ def is_retryable(exc: Exception) -> bool:
51
+ """``True`` si l'exception est typique d'un problème transitoire.
52
+
53
+ Détection sur trois axes :
54
+
55
+ 1. Code HTTP exposé par les SDK cloud (``status_code`` ou
56
+ ``http_status``) : 429 (rate limit) et tout 5xx.
57
+ 2. Type d'exception réseau : ``TimeoutError``, ``ConnectionError``,
58
+ ``URLError`` (urllib).
59
+ 3. Heuristique sur le message (fallback pour les SDK qui ne
60
+ structurent pas) : présence des codes 429/502/503 ou des
61
+ motifs ``rate limit``, ``timeout``, ``connection``.
62
+ """
63
+ status = (
64
+ getattr(exc, "status_code", None)
65
+ or getattr(exc, "http_status", None)
66
+ )
67
+ if status is not None:
68
+ return status == 429 or status >= 500
69
+
70
+ exc_name = type(exc).__name__
71
+ if exc_name in ("TimeoutError", "ConnectionError", "URLError"):
72
+ return True
73
+
74
+ msg = str(exc).lower()
75
+ if "rate" in msg and "limit" in msg:
76
+ return True
77
+ if "timeout" in msg or "connection" in msg:
78
+ return True
79
+ if "429" in msg or "503" in msg or "502" in msg:
80
+ return True
81
+
82
+ return False
83
+
84
+
85
+ def call_with_retry(
86
+ fn: Callable[[], T],
87
+ *,
88
+ max_retries: int = DEFAULT_MAX_RETRIES,
89
+ backoff_base: float = DEFAULT_BACKOFF_BASE,
90
+ label: str = "adapter",
91
+ ) -> T:
92
+ """Exécute ``fn`` avec retry exponentiel sur erreurs retryables.
93
+
94
+ Parameters
95
+ ----------
96
+ fn:
97
+ Callable sans argument qui retourne le résultat ou lève.
98
+ max_retries:
99
+ Nombre de retries après la première tentative. ``0`` =
100
+ une seule tentative (pas de retry).
101
+ backoff_base:
102
+ Base de l'attente exponentielle. Tentative ``i`` → attente
103
+ ``backoff_base ** (i + 1)`` secondes avant retry.
104
+ label:
105
+ Étiquette du caller pour le logging (typiquement
106
+ ``self.name`` de l'adapter).
107
+
108
+ Returns
109
+ -------
110
+ Résultat de ``fn``.
111
+
112
+ Raises
113
+ ------
114
+ Exception
115
+ La dernière exception levée si tous les retries sont
116
+ épuisés ou si l'erreur n'est pas retryable.
117
+ """
118
+ last_exc: Exception | None = None
119
+ for attempt in range(max_retries + 1):
120
+ try:
121
+ return fn()
122
+ except Exception as exc: # noqa: BLE001
123
+ last_exc = exc
124
+ if attempt < max_retries and is_retryable(exc):
125
+ wait = backoff_base ** (attempt + 1)
126
+ logger.warning(
127
+ "[%s] erreur retryable (tentative %d/%d, "
128
+ "attente %.1fs) : %s",
129
+ label, attempt + 1, max_retries + 1, wait, exc,
130
+ )
131
+ time.sleep(wait)
132
+ else:
133
+ break
134
+ assert last_exc is not None
135
+ raise last_exc
136
+
137
+
138
+ __all__ = [
139
+ "DEFAULT_BACKOFF_BASE",
140
+ "DEFAULT_MAX_RETRIES",
141
+ "call_with_retry",
142
+ "is_retryable",
143
+ ]
picarones/adapters/llm/base.py CHANGED
@@ -4,39 +4,50 @@ from __future__ import annotations
4
 
5
  import logging
6
  import time
 
7
  from abc import ABC, abstractmethod
8
  from dataclasses import dataclass
9
- from typing import Any, Optional
10
 
11
  logger = logging.getLogger(__name__)
12
 
13
- # Paramètres de retry par défaut
14
- _DEFAULT_MAX_RETRIES = 3
15
- _DEFAULT_BACKOFF_BASE = 2.0 # secondes : 2, 4, 8
16
 
 
17
 
18
- def _is_retryable(exc: Exception) -> bool:
19
- """Détermine si une exception est retryable (429, 5xx, timeout réseau)."""
20
- # HTTP status codes retryables
21
- status = getattr(exc, "status_code", None) or getattr(exc, "http_status", None)
22
- if status is not None:
23
- return status == 429 or status >= 500
24
 
25
- # Erreurs réseau / timeout
26
- exc_name = type(exc).__name__
27
- if exc_name in ("TimeoutError", "ConnectionError", "URLError"):
28
- return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- # Messages d'erreur courants
31
- msg = str(exc).lower()
32
- if "rate" in msg and "limit" in msg:
33
- return True
34
- if "timeout" in msg or "connection" in msg:
35
- return True
36
- if "429" in msg or "503" in msg or "502" in msg:
37
- return True
38
 
39
- return False
 
 
 
 
 
 
 
 
40
 
41
 
42
  def normalize_llm_content(raw: Any) -> str:
@@ -245,6 +256,10 @@ class BaseLLMAdapter(ABC):
245
  #: Prompts de post-correction par défaut, indexés par code langue
246
  #: ISO-639-1 (``fr``, ``en``, ``la``). Sélection via
247
  #: ``config["lang"]`` ; fallback FR si la langue est absente.
 
 
 
 
248
  DEFAULT_CORRECTION_PROMPTS: dict[str, str] = {
249
  "fr": (
250
  "Corrige les erreurs OCR dans le texte suivant en "
@@ -266,6 +281,16 @@ class BaseLLMAdapter(ABC):
266
  ),
267
  }
268
 
 
 
 
 
 
 
 
 
 
 
269
  def __init__(
270
  self,
271
  model: Optional[str] = None,
@@ -409,6 +434,15 @@ class BaseLLMAdapter(ABC):
409
  prompt_template = custom_prompt
410
  else:
411
  lang = (self.config.get("lang") or "fr").lower()
 
 
 
 
 
 
 
 
 
412
  prompt_template = self.DEFAULT_CORRECTION_PROMPTS.get(
413
  lang, self.DEFAULT_CORRECTION_PROMPTS["fr"],
414
  )
 
4
 
5
  import logging
6
  import time
7
+ import warnings
8
  from abc import ABC, abstractmethod
9
  from dataclasses import dataclass
10
+ from typing import Any, Generic, Optional, TypeVar
11
 
12
  logger = logging.getLogger(__name__)
13
 
 
 
 
14
 
15
+ T = TypeVar("T")
16
 
 
 
 
 
 
 
17
 
18
+ class _DeprecatedAttribute(Generic[T]):
19
+ """Descripteur class-level qui émet ``DeprecationWarning`` à l'accès.
20
+
21
+ Permet de retirer en deux temps une constante de classe sans
22
+ casser les callers externes : phase 1, le descripteur retourne
23
+ l'ancienne valeur avec un warning ; phase 2 (version majeure
24
+ suivante), le descripteur est supprimé.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ value: T,
30
+ message: str,
31
+ ) -> None:
32
+ self._value = value
33
+ self._message = message
34
+
35
+ def __set_name__(self, owner: type, name: str) -> None:
36
+ self._name = name
37
 
38
+ def __get__(self, instance: Any, owner: type | None = None) -> T:
39
+ warnings.warn(self._message, DeprecationWarning, stacklevel=2)
40
+ return self._value
 
 
 
 
 
41
 
42
+ from picarones.adapters._retry import (
43
+ DEFAULT_BACKOFF_BASE as _DEFAULT_BACKOFF_BASE,
44
+ )
45
+ from picarones.adapters._retry import (
46
+ DEFAULT_MAX_RETRIES as _DEFAULT_MAX_RETRIES,
47
+ )
48
+ from picarones.adapters._retry import (
49
+ is_retryable as _is_retryable,
50
+ )
51
 
52
 
53
  def normalize_llm_content(raw: Any) -> str:
 
256
  #: Prompts de post-correction par défaut, indexés par code langue
257
  #: ISO-639-1 (``fr``, ``en``, ``la``). Sélection via
258
  #: ``config["lang"]`` ; fallback FR si la langue est absente.
259
+ #:
260
+ #: ``DEFAULT_CORRECTION_PROMPT`` (singulier, FR) reste exposé en
261
+ #: ``_DeprecatedAttribute`` pour les sous-classes externes qui
262
+ #: lisaient l'ancienne API ; suppression prévue en 2.0.
263
  DEFAULT_CORRECTION_PROMPTS: dict[str, str] = {
264
  "fr": (
265
  "Corrige les erreurs OCR dans le texte suivant en "
 
281
  ),
282
  }
283
 
284
+ #: Alias rétrocompat (FR uniquement) pour les sous-classes
285
+ #: externes qui lisaient l'ancienne API singulière. L'accès
286
+ #: déclenche un ``DeprecationWarning``. Sera supprimé en 2.0.
287
+ DEFAULT_CORRECTION_PROMPT = _DeprecatedAttribute(
288
+ DEFAULT_CORRECTION_PROMPTS["fr"],
289
+ "BaseLLMAdapter.DEFAULT_CORRECTION_PROMPT is deprecated and "
290
+ "will be removed in 2.0. Use "
291
+ "DEFAULT_CORRECTION_PROMPTS[lang] (lang ∈ {fr, en, la}).",
292
+ )
293
+
294
  def __init__(
295
  self,
296
  model: Optional[str] = None,
 
434
  prompt_template = custom_prompt
435
  else:
436
  lang = (self.config.get("lang") or "fr").lower()
437
+ if lang not in self.DEFAULT_CORRECTION_PROMPTS:
438
+ logger.warning(
439
+ "[%s] lang=%r non supportée par "
440
+ "DEFAULT_CORRECTION_PROMPTS (%s) — fallback FR. "
441
+ "Pour un corpus dans cette langue, fournir "
442
+ "config['correction_prompt'] explicite.",
443
+ self.name, lang,
444
+ sorted(self.DEFAULT_CORRECTION_PROMPTS.keys()),
445
+ )
446
  prompt_template = self.DEFAULT_CORRECTION_PROMPTS.get(
447
  lang, self.DEFAULT_CORRECTION_PROMPTS["fr"],
448
  )
picarones/adapters/ocr/azure_doc_intel.py CHANGED
@@ -67,6 +67,7 @@ import urllib.request
67
  from pathlib import Path
68
  from typing import Any
69
 
 
70
  from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
71
  from picarones.adapters.output_paths import resolve_output_path
72
  from picarones.domain.artifacts import Artifact, ArtifactType
@@ -296,9 +297,12 @@ class AzureDocIntelAdapter(BaseOCRAdapter):
296
  "Content-Type": "application/octet-stream",
297
  },
298
  )
299
- try:
300
  with urllib.request.urlopen(req, timeout=self._timeout) as resp:
301
- operation_url = resp.headers.get("Operation-Location", "")
 
 
 
302
  except urllib.error.HTTPError as exc:
303
  body = ""
304
  try:
 
67
  from pathlib import Path
68
  from typing import Any
69
 
70
+ from picarones.adapters._retry import call_with_retry
71
  from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
72
  from picarones.adapters.output_paths import resolve_output_path
73
  from picarones.domain.artifacts import Artifact, ArtifactType
 
297
  "Content-Type": "application/octet-stream",
298
  },
299
  )
300
+ def _do_post() -> str:
301
  with urllib.request.urlopen(req, timeout=self._timeout) as resp:
302
+ return resp.headers.get("Operation-Location", "")
303
+
304
+ try:
305
+ operation_url = call_with_retry(_do_post, label=self.name)
306
  except urllib.error.HTTPError as exc:
307
  body = ""
308
  try:
picarones/adapters/ocr/google_vision.py CHANGED
@@ -51,6 +51,7 @@ import urllib.request
51
  from pathlib import Path
52
  from typing import Any
53
 
 
54
  from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
55
  from picarones.adapters.output_paths import resolve_output_path
56
  from picarones.domain.artifacts import Artifact, ArtifactType
@@ -264,9 +265,12 @@ class GoogleVisionAdapter(BaseOCRAdapter):
264
  "X-Goog-Api-Key": api_key,
265
  },
266
  )
267
- try:
268
  with urllib.request.urlopen(req, timeout=self._timeout) as resp:
269
- result = json.loads(resp.read().decode("utf-8"))
 
 
 
270
  except urllib.error.HTTPError as exc:
271
  body = ""
272
  try:
 
51
  from pathlib import Path
52
  from typing import Any
53
 
54
+ from picarones.adapters._retry import call_with_retry
55
  from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
56
  from picarones.adapters.output_paths import resolve_output_path
57
  from picarones.domain.artifacts import Artifact, ArtifactType
 
265
  "X-Goog-Api-Key": api_key,
266
  },
267
  )
268
+ def _do_call() -> dict:
269
  with urllib.request.urlopen(req, timeout=self._timeout) as resp:
270
+ return json.loads(resp.read().decode("utf-8"))
271
+
272
+ try:
273
+ result = call_with_retry(_do_call, label=self.name)
274
  except urllib.error.HTTPError as exc:
275
  body = ""
276
  try:
picarones/adapters/ocr/mistral_ocr.py CHANGED
@@ -63,6 +63,7 @@ import urllib.request
63
  from pathlib import Path
64
  from typing import Any
65
 
 
66
  from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
67
  from picarones.adapters.output_paths import resolve_output_path
68
  from picarones.domain.artifacts import Artifact, ArtifactType
@@ -264,9 +265,12 @@ class MistralOCRAdapter(BaseOCRAdapter):
264
  },
265
  method="POST",
266
  )
267
- try:
268
  with urllib.request.urlopen(req, timeout=self._timeout) as resp:
269
- data = json.loads(resp.read().decode())
 
 
 
270
  except Exception as exc:
271
  raise OCRAdapterError(
272
  f"{self.name} : erreur API Mistral /v1/ocr : "
@@ -290,8 +294,9 @@ class MistralOCRAdapter(BaseOCRAdapter):
290
  ) from exc
291
 
292
  client = Mistral(api_key=api_key)
293
- try:
294
- response = client.chat.complete(
 
295
  model=self._model,
296
  messages=[
297
  {
@@ -304,6 +309,9 @@ class MistralOCRAdapter(BaseOCRAdapter):
304
  ],
305
  max_tokens=self._max_tokens,
306
  )
 
 
 
307
  except Exception as exc:
308
  raise OCRAdapterError(
309
  f"{self.name} : erreur API Mistral chat : "
 
63
  from pathlib import Path
64
  from typing import Any
65
 
66
+ from picarones.adapters._retry import call_with_retry
67
  from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
68
  from picarones.adapters.output_paths import resolve_output_path
69
  from picarones.domain.artifacts import Artifact, ArtifactType
 
265
  },
266
  method="POST",
267
  )
268
+ def _do_call() -> dict:
269
  with urllib.request.urlopen(req, timeout=self._timeout) as resp:
270
+ return json.loads(resp.read().decode())
271
+
272
+ try:
273
+ data = call_with_retry(_do_call, label=self.name)
274
  except Exception as exc:
275
  raise OCRAdapterError(
276
  f"{self.name} : erreur API Mistral /v1/ocr : "
 
294
  ) from exc
295
 
296
  client = Mistral(api_key=api_key)
297
+
298
+ def _do_chat() -> Any:
299
+ return client.chat.complete(
300
  model=self._model,
301
  messages=[
302
  {
 
309
  ],
310
  max_tokens=self._max_tokens,
311
  )
312
+
313
+ try:
314
+ response = call_with_retry(_do_chat, label=self.name)
315
  except Exception as exc:
316
  raise OCRAdapterError(
317
  f"{self.name} : erreur API Mistral chat : "
picarones/adapters/vlm/base.py CHANGED
@@ -32,7 +32,7 @@ import logging
32
  from pathlib import Path
33
  from typing import Any
34
 
35
- from picarones.adapters.llm.base import BaseLLMAdapter
36
  from picarones.domain.artifacts import Artifact, ArtifactType
37
  from picarones.domain.errors import AdapterStepError
38
 
@@ -149,6 +149,16 @@ class BaseVLMAdapter(BaseLLMAdapter):
149
  ),
150
  }
151
 
 
 
 
 
 
 
 
 
 
 
152
  def execute(
153
  self,
154
  inputs: dict,
@@ -188,6 +198,15 @@ class BaseVLMAdapter(BaseLLMAdapter):
188
  prompt = custom
189
  else:
190
  lang = (self.config.get("lang") or "fr").lower()
 
 
 
 
 
 
 
 
 
191
  prompt = self.DEFAULT_TRANSCRIPTION_PROMPTS.get(
192
  lang, self.DEFAULT_TRANSCRIPTION_PROMPTS["fr"],
193
  )
 
32
  from pathlib import Path
33
  from typing import Any
34
 
35
+ from picarones.adapters.llm.base import BaseLLMAdapter, _DeprecatedAttribute
36
  from picarones.domain.artifacts import Artifact, ArtifactType
37
  from picarones.domain.errors import AdapterStepError
38
 
 
149
  ),
150
  }
151
 
152
+ #: Alias rétrocompat (FR uniquement) pour les sous-classes
153
+ #: externes qui lisaient l'ancienne API singulière. L'accès
154
+ #: déclenche un ``DeprecationWarning``. Sera supprimé en 2.0.
155
+ DEFAULT_TRANSCRIPTION_PROMPT = _DeprecatedAttribute(
156
+ DEFAULT_TRANSCRIPTION_PROMPTS["fr"],
157
+ "BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPT is deprecated "
158
+ "and will be removed in 2.0. Use "
159
+ "DEFAULT_TRANSCRIPTION_PROMPTS[lang] (lang ∈ {fr, en, la}).",
160
+ )
161
+
162
  def execute(
163
  self,
164
  inputs: dict,
 
198
  prompt = custom
199
  else:
200
  lang = (self.config.get("lang") or "fr").lower()
201
+ if lang not in self.DEFAULT_TRANSCRIPTION_PROMPTS:
202
+ logger.warning(
203
+ "[%s] lang=%r non supportée par "
204
+ "DEFAULT_TRANSCRIPTION_PROMPTS (%s) — fallback FR. "
205
+ "Pour un corpus dans cette langue, fournir "
206
+ "config['transcription_prompt'] explicite.",
207
+ self.name, lang,
208
+ sorted(self.DEFAULT_TRANSCRIPTION_PROMPTS.keys()),
209
+ )
210
  prompt = self.DEFAULT_TRANSCRIPTION_PROMPTS.get(
211
  lang, self.DEFAULT_TRANSCRIPTION_PROMPTS["fr"],
212
  )
picarones/app/services/benchmark_service.py CHANGED
@@ -42,7 +42,7 @@ from __future__ import annotations
42
  import json
43
  import logging
44
  from pathlib import Path
45
- from typing import Callable, Iterable
46
 
47
  from picarones.domain.artifacts import Artifact, ArtifactType
48
  from picarones.domain.corpus import CorpusSpec
@@ -121,6 +121,7 @@ class BenchmarkService:
121
  context_factory: ContextFactory,
122
  run_id: str | None = None,
123
  dependencies_lock: dict[str, str] | None = None,
 
124
  metadata: dict[str, str] | None = None,
125
  ) -> RunResult:
126
  """Exécute un benchmark complet et retourne le ``RunResult``.
@@ -189,7 +190,8 @@ class BenchmarkService:
189
  run_id=run_id or _default_run_id(corpus.name, started_at),
190
  corpus_name=corpus.name,
191
  n_documents=len(documents),
192
- pipeline_names=tuple(spec.name for spec in pipelines_list),
 
193
  view_specs=tuple(views_list),
194
  code_version=self._code_version,
195
  started_at=started_at,
 
42
  import json
43
  import logging
44
  from pathlib import Path
45
+ from typing import Any, Callable, Iterable
46
 
47
  from picarones.domain.artifacts import Artifact, ArtifactType
48
  from picarones.domain.corpus import CorpusSpec
 
121
  context_factory: ContextFactory,
122
  run_id: str | None = None,
123
  dependencies_lock: dict[str, str] | None = None,
124
+ adapter_kwargs: dict[str, dict[str, Any]] | None = None,
125
  metadata: dict[str, str] | None = None,
126
  ) -> RunResult:
127
  """Exécute un benchmark complet et retourne le ``RunResult``.
 
190
  run_id=run_id or _default_run_id(corpus.name, started_at),
191
  corpus_name=corpus.name,
192
  n_documents=len(documents),
193
+ pipeline_specs=tuple(pipelines_list),
194
+ adapter_kwargs=dict(adapter_kwargs or {}),
195
  view_specs=tuple(views_list),
196
  code_version=self._code_version,
197
  started_at=started_at,
picarones/app/services/dependencies.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Capture du verrou des dépendances au moment d'un run.
2
+
3
+ Le ``RunManifest`` documente la promesse *« à code_version + corpus +
4
+ specs + dependencies_lock identiques, ré-exécuter doit donner les
5
+ mêmes résultats »*. Ce module fournit la capture canonique du
6
+ ``dependencies_lock``.
7
+
8
+ Approche
9
+ --------
10
+ ``importlib.metadata.distributions()`` retourne tous les paquets
11
+ installés dans l'environnement Python courant — c'est l'API standard
12
+ Python (PEP 566) plutôt que d'invoquer ``pip freeze`` en sous-process.
13
+ Chaque ``Distribution`` fournit ``name`` + ``version`` ; on en fait
14
+ un dict ordonné par ``name`` minuscule pour le déterminisme du
15
+ manifest.
16
+
17
+ Anti-sur-ingénierie
18
+ -------------------
19
+ - Pas de capture des hashes de wheel : si la BnF veut une preuve
20
+ d'intégrité supply-chain, elle utilise un lockfile Poetry/uv en
21
+ amont — on ne refait pas le travail.
22
+ - Pas de capture des binaires système (Tesseract version, libcuda,
23
+ fonts) : reporté à un sprint dédié si une ré-exécution échoue
24
+ pour cette raison. Le hash du wheel ``pytesseract`` capture déjà
25
+ la couche Python.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from importlib.metadata import distributions
31
+
32
+
33
+ def capture_dependencies_lock() -> dict[str, str]:
34
+ """Retourne un dict ``{nom_package: version}`` trié par nom.
35
+
36
+ Tri lexicographique sur ``name.lower()`` pour produire des
37
+ manifests bit-for-bit identiques à environnement constant
38
+ (l'ordre d'itération de ``distributions()`` n'est pas spécifié).
39
+ """
40
+ lock: dict[str, str] = {}
41
+ for dist in distributions():
42
+ name = dist.metadata["Name"]
43
+ version = dist.version
44
+ if name and version:
45
+ lock[name] = version
46
+ return dict(sorted(lock.items(), key=lambda kv: kv[0].lower()))
47
+
48
+
49
+ __all__ = ["capture_dependencies_lock"]
picarones/app/services/run_orchestrator.py CHANGED
@@ -45,6 +45,7 @@ from typing import Any, Callable
45
  from picarones.app.results import ReportRenderer, RunResult
46
  from picarones.app.schemas import RunSpec, resolve_adapter_class
47
  from picarones.app.services.benchmark_service import BenchmarkService
 
48
  from picarones.app.services.corpus_service import (
49
  CorpusImportError,
50
  CorpusService,
@@ -167,8 +168,10 @@ class RunOrchestrator:
167
  # 2. Registres.
168
  registries = RegistryService.bootstrap_defaults()
169
 
170
- # 3. Pipelines + resolver d'adapters.
171
- pipeline_specs, adapter_resolver = self._build_pipelines(spec)
 
 
172
 
173
  # 4. Vues canoniques.
174
  views = self._build_views(spec.views)
@@ -180,6 +183,9 @@ class RunOrchestrator:
180
  code_version=spec.code_version,
181
  )
182
 
 
 
 
183
  result = bench.run(
184
  corpus=corpus_spec,
185
  pipelines=pipeline_specs,
@@ -187,6 +193,8 @@ class RunOrchestrator:
187
  ground_truth_factory=_default_gt_factory,
188
  pipeline_inputs_factory=_default_inputs_factory,
189
  context_factory=_make_context_factory(spec.code_version),
 
 
190
  metadata={"orchestrator": "picarones.app.services.run_orchestrator"},
191
  )
192
 
@@ -257,7 +265,11 @@ class RunOrchestrator:
257
  @staticmethod
258
  def _build_pipelines(
259
  spec: RunSpec,
260
- ) -> tuple[list[PipelineSpec], Callable[[str], Any]]:
 
 
 
 
261
  """Construit les ``PipelineSpec`` + un resolver d'adapters.
262
 
263
  Disambiguation des steps :
@@ -316,7 +328,12 @@ class RunOrchestrator:
316
  instance_cache[name] = cls(**kwargs)
317
  return instance_cache[name]
318
 
319
- return pipeline_specs, resolver
 
 
 
 
 
320
 
321
  @staticmethod
322
  def _build_views(view_names: tuple[str, ...]) -> list[Any]:
 
45
  from picarones.app.results import ReportRenderer, RunResult
46
  from picarones.app.schemas import RunSpec, resolve_adapter_class
47
  from picarones.app.services.benchmark_service import BenchmarkService
48
+ from picarones.app.services.dependencies import capture_dependencies_lock
49
  from picarones.app.services.corpus_service import (
50
  CorpusImportError,
51
  CorpusService,
 
168
  # 2. Registres.
169
  registries = RegistryService.bootstrap_defaults()
170
 
171
+ # 3. Pipelines + resolver d'adapters + dump des kwargs pour le manifest.
172
+ pipeline_specs, adapter_resolver, adapter_kwargs = (
173
+ self._build_pipelines(spec)
174
+ )
175
 
176
  # 4. Vues canoniques.
177
  views = self._build_views(spec.views)
 
183
  code_version=spec.code_version,
184
  )
185
 
186
+ # 6. Capture du verrou de dépendances pour la reproductibilité.
187
+ deps_lock = capture_dependencies_lock()
188
+
189
  result = bench.run(
190
  corpus=corpus_spec,
191
  pipelines=pipeline_specs,
 
193
  ground_truth_factory=_default_gt_factory,
194
  pipeline_inputs_factory=_default_inputs_factory,
195
  context_factory=_make_context_factory(spec.code_version),
196
+ adapter_kwargs=adapter_kwargs,
197
+ dependencies_lock=deps_lock,
198
  metadata={"orchestrator": "picarones.app.services.run_orchestrator"},
199
  )
200
 
 
265
  @staticmethod
266
  def _build_pipelines(
267
  spec: RunSpec,
268
+ ) -> tuple[
269
+ list[PipelineSpec],
270
+ Callable[[str], Any],
271
+ dict[str, dict[str, Any]],
272
+ ]:
273
  """Construit les ``PipelineSpec`` + un resolver d'adapters.
274
 
275
  Disambiguation des steps :
 
328
  instance_cache[name] = cls(**kwargs)
329
  return instance_cache[name]
330
 
331
+ # Copie défensive — le manifest doit recevoir un snapshot
332
+ # immuable, pas la map vivante du resolver.
333
+ adapter_kwargs_dump = {
334
+ name: dict(kwargs) for name, kwargs in name_to_kwargs.items()
335
+ }
336
+ return pipeline_specs, resolver, adapter_kwargs_dump
337
 
338
  @staticmethod
339
  def _build_views(view_names: tuple[str, ...]) -> list[Any]:
picarones/domain/documents.py CHANGED
@@ -90,6 +90,18 @@ class DocumentRef(BaseModel):
90
  f"document id invalide : {v!r}. "
91
  f"Doit matcher {_DOC_ID_RE.pattern!r}."
92
  )
 
 
 
 
 
 
 
 
 
 
 
 
93
  return v
94
 
95
  @field_validator("ground_truths")
 
90
  f"document id invalide : {v!r}. "
91
  f"Doit matcher {_DOC_ID_RE.pattern!r}."
92
  )
93
+ # Défense en profondeur path-traversal : ``..`` comme segment
94
+ # de chemin permet d'écrire hors workspace via
95
+ # ``resolve_output_path``. Le seul rempart au niveau supérieur
96
+ # est l'extraction ZIP (zip-slip protection) — un caller qui
97
+ # construit ``DocumentRef(id="../../etc/passwd")``
98
+ # programmatiquement contournait tout.
99
+ if ".." in v.split("/"):
100
+ from picarones.domain.errors import CorpusSpecError
101
+ raise CorpusSpecError(
102
+ f"document id contient un segment '..' : {v!r}. "
103
+ "Path traversal rejeté."
104
+ )
105
  return v
106
 
107
  @field_validator("ground_truths")
picarones/domain/run_manifest.py CHANGED
@@ -40,11 +40,14 @@ Anti-sur-ingénierie
40
 
41
  from __future__ import annotations
42
 
 
43
  from datetime import datetime, timezone
 
44
 
45
- from pydantic import BaseModel, ConfigDict, Field
46
 
47
  from picarones.domain.evaluation_spec import EvaluationView
 
48
 
49
 
50
  class RunManifest(BaseModel):
@@ -66,11 +69,18 @@ class RunManifest(BaseModel):
66
  Nom du corpus traité (cf. ``CorpusSpec.name``).
67
  n_documents:
68
  Nombre de documents du corpus.
69
- pipeline_names:
70
- Noms des pipelines exécutées (un par pipeline). Ne porte
71
- PAS la spec complète pour rester compact dans le manifest
72
- la spec YAML est citée par référence
73
- (``pipeline_specs_uri``).
 
 
 
 
 
 
 
74
  view_specs:
75
  Vues d'évaluation appliquées. Portées intégralement
76
  (frozen pydantic) parce qu'elles sont déclaratives et
@@ -81,10 +91,13 @@ class RunManifest(BaseModel):
81
  started_at, completed_at:
82
  Wall-clock UTC de début et fin du run.
83
  dependencies_lock:
84
- Snapshot des dépendances installées au moment du run
85
- (typiquement ``pip freeze`` ou ``poetry lock`` digéré).
86
- Format libre — un dict ``{package: version}`` est
87
- idiomatique mais pas imposé.
 
 
 
88
  metadata:
89
  Dict libre pour notes utilisateur, etc. Ne doit pas
90
  contenir d'info qui devrait être dans un autre champ.
@@ -95,7 +108,8 @@ class RunManifest(BaseModel):
95
  run_id: str = Field(min_length=1, max_length=256)
96
  corpus_name: str = Field(min_length=1, max_length=128)
97
  n_documents: int = Field(ge=0)
98
- pipeline_names: tuple[str, ...] = Field(default_factory=tuple)
 
99
  view_specs: tuple[EvaluationView, ...] = Field(default_factory=tuple)
100
  code_version: str = Field(min_length=1, max_length=128)
101
  started_at: datetime
@@ -103,6 +117,75 @@ class RunManifest(BaseModel):
103
  dependencies_lock: dict[str, str] = Field(default_factory=dict)
104
  metadata: dict[str, str] = Field(default_factory=dict)
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  @property
107
  def duration_seconds(self) -> float:
108
  """Durée wall-clock du run en secondes."""
 
40
 
41
  from __future__ import annotations
42
 
43
+ import warnings
44
  from datetime import datetime, timezone
45
+ from typing import Any
46
 
47
+ from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator
48
 
49
  from picarones.domain.evaluation_spec import EvaluationView
50
+ from picarones.domain.pipeline_spec import PipelineSpec
51
 
52
 
53
  class RunManifest(BaseModel):
 
69
  Nom du corpus traité (cf. ``CorpusSpec.name``).
70
  n_documents:
71
  Nombre de documents du corpus.
72
+ pipeline_specs:
73
+ Spécifications **complètes** des pipelines exécutées (steps,
74
+ adapter_name par step, params, inputs_from, output_types).
75
+ Inclus intégralement dans le manifest pour reproductibilité
76
+ un relecteur peut reconstituer le DAG sans accès au YAML
77
+ d'origine.
78
+ adapter_kwargs:
79
+ Map ``{adapter_name: kwargs}`` capturée pour chaque adapter
80
+ instancié. Permet de reconstituer ``OpenAIAdapter(model=
81
+ "gpt-4o-2024-08-06", temperature=0.0)`` à l'identique.
82
+ Les valeurs sensibles (``api_key``) ne doivent pas y figurer
83
+ — elles viennent toujours de variables d'environnement.
84
  view_specs:
85
  Vues d'évaluation appliquées. Portées intégralement
86
  (frozen pydantic) parce qu'elles sont déclaratives et
 
91
  started_at, completed_at:
92
  Wall-clock UTC de début et fin du run.
93
  dependencies_lock:
94
+ Snapshot ``{package: version}`` de l'environnement Python
95
+ au moment du run. Capturé via
96
+ ``picarones.app.services.dependencies.capture_dependencies_lock``.
97
+ Indispensable pour la promesse de reproductibilité — sans
98
+ lui, un changement de version d'un parser XML ou d'une
99
+ lib statistique fait diverger les résultats sans qu'on
100
+ puisse l'attribuer.
101
  metadata:
102
  Dict libre pour notes utilisateur, etc. Ne doit pas
103
  contenir d'info qui devrait être dans un autre champ.
 
108
  run_id: str = Field(min_length=1, max_length=256)
109
  corpus_name: str = Field(min_length=1, max_length=128)
110
  n_documents: int = Field(ge=0)
111
+ pipeline_specs: tuple[PipelineSpec, ...] = Field(default_factory=tuple)
112
+ adapter_kwargs: dict[str, dict[str, Any]] = Field(default_factory=dict)
113
  view_specs: tuple[EvaluationView, ...] = Field(default_factory=tuple)
114
  code_version: str = Field(min_length=1, max_length=128)
115
  started_at: datetime
 
117
  dependencies_lock: dict[str, str] = Field(default_factory=dict)
118
  metadata: dict[str, str] = Field(default_factory=dict)
119
 
120
+ @computed_field # type: ignore[prop-decorator]
121
+ @property
122
+ def pipeline_names(self) -> tuple[str, ...]:
123
+ """Liste compacte des noms de pipelines (sérialisée dans le
124
+ JSON pour les lecteurs qui ne traitent pas le DAG complet).
125
+
126
+ Dérivée de ``pipeline_specs`` ; la liste authoritative pour
127
+ la reproductibilité est ``pipeline_specs`` qui porte les DAG
128
+ complets avec params et inputs_from.
129
+ """
130
+ return tuple(spec.name for spec in self.pipeline_specs)
131
+
132
+ @model_validator(mode="before")
133
+ @classmethod
134
+ def _accept_legacy_pipeline_names(
135
+ cls,
136
+ data: Any,
137
+ ) -> Any:
138
+ """Accepte ``pipeline_names`` au constructeur comme alias
139
+ déprécié de ``pipeline_specs``.
140
+
141
+ Trois cas :
142
+
143
+ 1. ``pipeline_names`` seul → convertit chaque nom en
144
+ ``PipelineSpec(name=n, steps=())`` + ``DeprecationWarning``.
145
+ 2. ``pipeline_specs`` + ``pipeline_names`` cohérents → cas du
146
+ round-trip JSON (``pipeline_names`` est un computed_field
147
+ sérialisé) : on ignore silencieusement le doublon.
148
+ 3. ``pipeline_specs`` + ``pipeline_names`` incohérents →
149
+ ``ValueError`` (incohérence sémantique).
150
+ """
151
+ if not isinstance(data, dict):
152
+ return data
153
+ if "pipeline_names" not in data:
154
+ return data
155
+ names = data["pipeline_names"]
156
+ if "pipeline_specs" in data:
157
+ specs = data["pipeline_specs"]
158
+ spec_names = tuple(
159
+ s.name if hasattr(s, "name") else s.get("name")
160
+ for s in specs
161
+ )
162
+ if tuple(names) != spec_names:
163
+ raise ValueError(
164
+ "RunManifest : ``pipeline_names`` et "
165
+ "``pipeline_specs`` désignent des pipelines "
166
+ f"distinctes (names={tuple(names)!r}, "
167
+ f"specs={spec_names!r}).",
168
+ )
169
+ # Round-trip JSON : computed_field re-sérialisé puis
170
+ # re-parsé. On ignore le doublon, ``pipeline_specs``
171
+ # est authoritative.
172
+ data = dict(data)
173
+ data.pop("pipeline_names")
174
+ return data
175
+ warnings.warn(
176
+ "RunManifest(pipeline_names=...) is deprecated and will "
177
+ "be removed in 2.0. Use pipeline_specs=tuple(PipelineSpec"
178
+ "(name=n, steps=()) for n in names) instead.",
179
+ DeprecationWarning,
180
+ stacklevel=2,
181
+ )
182
+ data = dict(data)
183
+ data.pop("pipeline_names")
184
+ data["pipeline_specs"] = tuple(
185
+ PipelineSpec(name=n, steps=()) for n in names
186
+ )
187
+ return data
188
+
189
  @property
190
  def duration_seconds(self) -> float:
191
  """Durée wall-clock du run en secondes."""
picarones/interfaces/web/routers/jobs.py CHANGED
@@ -239,6 +239,17 @@ async def submit_job(
239
  detail=f"Échec de soumission du job : {type(exc).__name__}",
240
  ) from exc
241
 
 
 
 
 
 
 
 
 
 
 
 
242
  return JobSubmitResponse(job_id=job_id, status="pending")
243
 
244
 
@@ -287,6 +298,14 @@ async def cancel_job(request: Request, job_id: str) -> JobCancelResponse:
287
 
288
  store.mark_cancelled(job_id)
289
  updated = store.get(job_id)
 
 
 
 
 
 
 
 
290
  return JobCancelResponse(
291
  job_id=updated.job_id, status=updated.status,
292
  )
 
239
  detail=f"Échec de soumission du job : {type(exc).__name__}",
240
  ) from exc
241
 
242
+ # Audit trail — création de job est une action sensible (peut
243
+ # consommer du quota cloud, démarrer un long calcul). Log INFO
244
+ # avec l'IP source pour la traçabilité institutionnelle.
245
+ client = request.client
246
+ client_host = client.host if client is not None else "unknown"
247
+ logger.info(
248
+ "[audit] job_submitted job_id=%s corpus=%s from=%s",
249
+ job_id,
250
+ run_spec.corpus_name or "",
251
+ client_host,
252
+ )
253
  return JobSubmitResponse(job_id=job_id, status="pending")
254
 
255
 
 
298
 
299
  store.mark_cancelled(job_id)
300
  updated = store.get(job_id)
301
+ # Audit trail — annulation peut détruire des résultats partiels
302
+ # et libérer du quota cloud non remboursable.
303
+ client = request.client
304
+ client_host = client.host if client is not None else "unknown"
305
+ logger.info(
306
+ "[audit] job_cancelled job_id=%s from=%s",
307
+ job_id, client_host,
308
+ )
309
  return JobCancelResponse(
310
  job_id=updated.job_id, status=updated.status,
311
  )
picarones/pipeline/executor.py CHANGED
@@ -421,10 +421,19 @@ class PipelineExecutor:
421
  outputs,
422
  )
423
 
424
- # 5. Succès.
425
- # S47 persiste les outputs dans le store si fourni. La
426
- # méthode interne sait gérer le cas content_hash manquant
427
- # (skip silencieux) on lui passe la responsabilité.
 
 
 
 
 
 
 
 
 
428
  if self._artifact_store is not None:
429
  self._persist_to_cache(
430
  step=step, inputs=inputs, context=context, outputs=outputs,
 
421
  outputs,
422
  )
423
 
424
+ # 5. Filtrage sur ``step.output_types``.
425
+ # Un adapter peut produire plus de types que le YAML n'en
426
+ # déclare (ex: Tesseract avec ``expose_confidences=True``
427
+ # mais le step ne déclare que ``[raw_text]``). Le contrat
428
+ # est que seuls les outputs déclarés en sortie de step
429
+ # passent en aval — sinon un DAG branchant pourrait recevoir
430
+ # des artefacts qui ne devaient pas exister à cette jonction.
431
+ declared = set(step.output_types)
432
+ outputs = {t: a for t, a in outputs.items() if t in declared}
433
+
434
+ # 6. Succès — persiste dans le store si fourni. La méthode
435
+ # interne sait gérer le cas content_hash manquant (skip
436
+ # silencieux) — on lui passe la responsabilité.
437
  if self._artifact_store is not None:
438
  self._persist_to_cache(
439
  step=step, inputs=inputs, context=context, outputs=outputs,
picarones/pipeline/spec.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``picarones.pipeline.spec`` — shim de compatibilité descendante (déprécié).
2
+
3
+ Le module canonique est ``picarones.domain.pipeline_spec`` depuis le
4
+ sprint S40. Ce module a été supprimé temporairement au S57 puis
5
+ restauré au S59 avec ``DeprecationWarning`` pour respecter une
6
+ deprecation period propre vis-à-vis des callers externes (espaces
7
+ HuggingFace tiers, scripts archivistiques, notebooks de chercheurs).
8
+
9
+ Suppression effective prévue en version majeure suivante (1.x → 2.0).
10
+
11
+ ::
12
+
13
+ # Migration : remplacer
14
+ from picarones.pipeline.spec import PipelineSpec
15
+ # par
16
+ from picarones.domain import PipelineSpec
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import warnings
22
+
23
+ from picarones.domain.pipeline_spec import (
24
+ INITIAL_STEP_ID,
25
+ PipelineSpec,
26
+ PipelineStep,
27
+ )
28
+
29
+ warnings.warn(
30
+ "picarones.pipeline.spec is deprecated and will be removed in 2.0. "
31
+ "Import from picarones.domain instead "
32
+ "(`from picarones.domain import PipelineSpec, PipelineStep, "
33
+ "INITIAL_STEP_ID`).",
34
+ DeprecationWarning,
35
+ stacklevel=2,
36
+ )
37
+
38
+ __all__ = ["INITIAL_STEP_ID", "PipelineSpec", "PipelineStep"]
tests/api_stability/__init__.py ADDED
File without changes
tests/api_stability/test_deprecated_aliases.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Garde-fou de stabilité d'API : les symboles dépréciés au S57
2
+ restent accessibles avec ``DeprecationWarning`` jusqu'à la 2.0.
3
+
4
+ Pour une release institutionnelle, supprimer un symbole exporté du
5
+ package public exige une deprecation period publique — un caller
6
+ externe (espace HuggingFace tiers, script BnF, notebook de chercheur)
7
+ doit pouvoir mettre à jour son code AVANT la cassure dure.
8
+
9
+ Trois alias couverts :
10
+
11
+ 1. ``picarones.pipeline.spec`` (module entier).
12
+ 2. ``BaseLLMAdapter.DEFAULT_CORRECTION_PROMPT`` (singulier).
13
+ 3. ``BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPT`` (singulier).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib
19
+ import sys
20
+ import warnings
21
+
22
+
23
+ def test_pipeline_spec_module_emits_deprecation_warning() -> None:
24
+ """``from picarones.pipeline.spec import …`` fonctionne avec un
25
+ ``DeprecationWarning`` qui pointe vers le chemin canonique.
26
+ """
27
+ sys.modules.pop("picarones.pipeline.spec", None)
28
+ with warnings.catch_warnings(record=True) as captured:
29
+ warnings.simplefilter("always")
30
+ importlib.import_module("picarones.pipeline.spec")
31
+ deprecations = [
32
+ w for w in captured if issubclass(w.category, DeprecationWarning)
33
+ ]
34
+ assert deprecations, "DeprecationWarning attendu sur l'import legacy."
35
+ assert "picarones.domain" in str(deprecations[0].message), (
36
+ "Le message du warning doit pointer vers la cible canonique."
37
+ )
38
+
39
+
40
+ def test_pipeline_spec_module_still_resolves_classes() -> None:
41
+ """L'alias résout vers les MÊMES objets que ``picarones.domain``."""
42
+ sys.modules.pop("picarones.pipeline.spec", None)
43
+ with warnings.catch_warnings():
44
+ warnings.simplefilter("ignore", DeprecationWarning)
45
+ from picarones.pipeline.spec import (
46
+ INITIAL_STEP_ID as LegacyInit,
47
+ )
48
+ from picarones.pipeline.spec import (
49
+ PipelineSpec as LegacySpec,
50
+ )
51
+ from picarones.pipeline.spec import (
52
+ PipelineStep as LegacyStep,
53
+ )
54
+ from picarones.domain.pipeline_spec import (
55
+ INITIAL_STEP_ID,
56
+ PipelineSpec,
57
+ PipelineStep,
58
+ )
59
+ assert LegacySpec is PipelineSpec
60
+ assert LegacyStep is PipelineStep
61
+ assert LegacyInit == INITIAL_STEP_ID
62
+
63
+
64
+ def test_default_correction_prompt_singular_emits_warning() -> None:
65
+ """``BaseLLMAdapter.DEFAULT_CORRECTION_PROMPT`` (singulier) reste
66
+ lisible mais émet ``DeprecationWarning``.
67
+ """
68
+ from picarones.adapters.llm.base import BaseLLMAdapter
69
+
70
+ with warnings.catch_warnings(record=True) as captured:
71
+ warnings.simplefilter("always")
72
+ value = BaseLLMAdapter.DEFAULT_CORRECTION_PROMPT
73
+ deprecations = [
74
+ w for w in captured if issubclass(w.category, DeprecationWarning)
75
+ ]
76
+ assert deprecations
77
+ assert "DEFAULT_CORRECTION_PROMPTS" in str(deprecations[0].message)
78
+ # La valeur retournée est cohérente : prompt FR.
79
+ assert "Corrige" in value
80
+
81
+
82
+ def test_default_transcription_prompt_singular_emits_warning() -> None:
83
+ """``BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPT`` (singulier)
84
+ reste lisible mais émet ``DeprecationWarning``.
85
+ """
86
+ from picarones.adapters.vlm.base import BaseVLMAdapter
87
+
88
+ with warnings.catch_warnings(record=True) as captured:
89
+ warnings.simplefilter("always")
90
+ value = BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPT
91
+ deprecations = [
92
+ w for w in captured if issubclass(w.category, DeprecationWarning)
93
+ ]
94
+ assert deprecations
95
+ assert "DEFAULT_TRANSCRIPTION_PROMPTS" in str(deprecations[0].message)
96
+ assert "Transcris" in value
tests/app/services/test_sprint_a14_s53_inputs_from_propagation.py CHANGED
@@ -62,7 +62,7 @@ def test_orchestrator_propagates_inputs_from_to_pipeline_step(
62
  "picarones.app.services.run_orchestrator.resolve_adapter_class",
63
  return_value=MagicMock,
64
  ):
65
- pipeline_specs, _resolver = orch._build_pipelines(spec)
66
 
67
  assert len(pipeline_specs) == 1
68
  ps = pipeline_specs[0]
@@ -98,5 +98,5 @@ def test_step_without_inputs_from_yields_empty_dict(tmp_path) -> None:
98
  "picarones.app.services.run_orchestrator.resolve_adapter_class",
99
  return_value=MagicMock,
100
  ):
101
- pipeline_specs, _ = orch._build_pipelines(spec)
102
  assert pipeline_specs[0].steps[0].inputs_from == {}
 
62
  "picarones.app.services.run_orchestrator.resolve_adapter_class",
63
  return_value=MagicMock,
64
  ):
65
+ pipeline_specs, _resolver, _kwargs = orch._build_pipelines(spec)
66
 
67
  assert len(pipeline_specs) == 1
68
  ps = pipeline_specs[0]
 
98
  "picarones.app.services.run_orchestrator.resolve_adapter_class",
99
  return_value=MagicMock,
100
  ):
101
+ pipeline_specs, _, _ = orch._build_pipelines(spec)
102
  assert pipeline_specs[0].steps[0].inputs_from == {}
tests/architecture/test_file_budgets.py CHANGED
@@ -98,7 +98,9 @@ FILE_BUDGETS: dict[str, int] = {
98
  "picarones/app/services/benchmark_service.py": 470, # actuel 400
99
  # Sprint A14-S44 — BaseLLMAdapter implémente le contrat StepExecutor
100
  # (input_types, output_types, execute) en plus de complete().
101
- "picarones/adapters/llm/base.py": 475, # actuel 410
 
 
102
  "picarones/core/corpus.py": 600, # actuel 511
103
  "picarones/fixtures.py": 600, # actuel 510
104
  "picarones/measurements/inter_engine.py": 575, # actuel 484
 
98
  "picarones/app/services/benchmark_service.py": 470, # actuel 400
99
  # Sprint A14-S44 — BaseLLMAdapter implémente le contrat StepExecutor
100
  # (input_types, output_types, execute) en plus de complete().
101
+ # S59 ajout du descripteur ``_DeprecatedAttribute`` + alias rétrocompat
102
+ # ``DEFAULT_CORRECTION_PROMPT`` + warning lang fallback (M6).
103
+ "picarones/adapters/llm/base.py": 560, # actuel 486
104
  "picarones/core/corpus.py": 600, # actuel 511
105
  "picarones/fixtures.py": 600, # actuel 510
106
  "picarones/measurements/inter_engine.py": 575, # actuel 484
tests/architecture/test_manifest_reproducibility.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Garde-fou de reproductibilité du ``RunManifest``.
2
+
3
+ L'audit S58 a relevé que ``RunManifest.dependencies_lock`` n'était
4
+ jamais peuplé et que ``pipeline_specs`` ne contenait que les noms,
5
+ rompant la promesse documentée *« à code_version + corpus + specs +
6
+ dependencies_lock identiques, ré-exécuter doit donner les mêmes
7
+ résultats »*.
8
+
9
+ Ces tests verrouillent le contrat :
10
+
11
+ 1. ``capture_dependencies_lock()`` retourne un dict non vide trié.
12
+ 2. ``RunManifest`` accepte des ``pipeline_specs`` complètes (steps,
13
+ adapter_name, params, inputs_from), pas seulement des noms.
14
+ 3. ``adapter_kwargs`` permet de reconstituer les constructeurs
15
+ d'adapters (model, temperature, etc.).
16
+ 4. La sérialisation est déterministe : deux manifests à entrée
17
+ identique produisent les mêmes octets JSON.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from datetime import datetime, timezone
23
+
24
+ from picarones.app.services.dependencies import capture_dependencies_lock
25
+ from picarones.domain.artifacts import ArtifactType
26
+ from picarones.domain.pipeline_spec import PipelineSpec, PipelineStep
27
+ from picarones.domain.run_manifest import RunManifest
28
+
29
+
30
+ def test_capture_dependencies_lock_non_empty_and_sorted() -> None:
31
+ """``capture_dependencies_lock()`` retourne ≥ 1 paquet (pydantic
32
+ au minimum) et trié alphabétiquement (case-insensitive).
33
+ """
34
+ lock = capture_dependencies_lock()
35
+ assert len(lock) > 0, "lock vide — picarones lui-même doit être listé."
36
+ keys = list(lock.keys())
37
+ assert keys == sorted(keys, key=str.lower), (
38
+ "lock non trié — le manifest ne sera pas bit-for-bit "
39
+ "reproductible cross-environnement."
40
+ )
41
+ # pydantic est une dépendance ferme du projet — sa présence prouve
42
+ # que la capture marche sur l'env réel.
43
+ assert any(k.lower() == "pydantic" for k in lock)
44
+
45
+
46
+ def test_run_manifest_carries_full_pipeline_specs() -> None:
47
+ """Le manifest doit porter les ``PipelineSpec`` complètes, pas
48
+ seulement les noms. Sans ça, un relecteur 5 ans plus tard ne peut
49
+ pas reconstituer le DAG sans accès au YAML d'origine.
50
+ """
51
+ step = PipelineStep(
52
+ id="ocr",
53
+ kind="ocr",
54
+ adapter_name="tesseract",
55
+ input_types=(ArtifactType.IMAGE,),
56
+ output_types=(ArtifactType.RAW_TEXT,),
57
+ params={"lang": "fra"},
58
+ )
59
+ spec = PipelineSpec(name="tess_only", steps=(step,))
60
+
61
+ manifest = RunManifest(
62
+ run_id="r1",
63
+ corpus_name="c1",
64
+ n_documents=1,
65
+ pipeline_specs=(spec,),
66
+ adapter_kwargs={"tesseract": {"lang": "fra", "psm": 6}},
67
+ view_specs=(),
68
+ code_version="1.0.0-test",
69
+ started_at=datetime.now(tz=timezone.utc),
70
+ completed_at=datetime.now(tz=timezone.utc),
71
+ dependencies_lock={"pydantic": "2.5.0"},
72
+ )
73
+
74
+ assert manifest.pipeline_specs == (spec,)
75
+ # Vue rétrocompat dérivée des specs.
76
+ assert manifest.pipeline_names == ("tess_only",)
77
+ # Les kwargs d'instanciation sont tracés.
78
+ assert manifest.adapter_kwargs["tesseract"]["psm"] == 6
79
+ # Le step complet est reconstituable.
80
+ assert manifest.pipeline_specs[0].steps[0].params == {"lang": "fra"}
81
+
82
+
83
+ def test_run_manifest_serialization_is_deterministic() -> None:
84
+ """Deux manifests à entrée identique produisent les mêmes
85
+ octets JSON — pré-requis pour le hash d'intégrité que la BnF
86
+ peut citer dans une publication.
87
+ """
88
+ common = dict(
89
+ run_id="r1",
90
+ corpus_name="c1",
91
+ n_documents=42,
92
+ pipeline_specs=(),
93
+ adapter_kwargs={"a": {"k": 1}, "b": {"k": 2}},
94
+ view_specs=(),
95
+ code_version="1.0.0",
96
+ started_at=datetime(2026, 5, 6, tzinfo=timezone.utc),
97
+ completed_at=datetime(2026, 5, 6, tzinfo=timezone.utc),
98
+ dependencies_lock={"pkg-a": "1.0", "pkg-b": "2.0"},
99
+ metadata={"note": "test"},
100
+ )
101
+ m1 = RunManifest(**common)
102
+ m2 = RunManifest(**common)
103
+ assert m1.model_dump_json() == m2.model_dump_json()
104
+
105
+
106
+ def test_run_manifest_rejects_extra_fields() -> None:
107
+ """``extra="forbid"`` — le contrat du manifest n'évolue pas
108
+ silencieusement. Tout nouveau champ exige un ajout explicite
109
+ au modèle (et donc une revue).
110
+ """
111
+ import pytest
112
+ from pydantic import ValidationError
113
+
114
+ with pytest.raises(ValidationError):
115
+ RunManifest(
116
+ run_id="r1",
117
+ corpus_name="c1",
118
+ n_documents=1,
119
+ code_version="1.0",
120
+ started_at=datetime.now(tz=timezone.utc),
121
+ completed_at=datetime.now(tz=timezone.utc),
122
+ unknown_field="nope", # type: ignore[call-arg]
123
+ )
tests/domain/test_sprint_a14_s40_pipeline_spec_in_domain.py CHANGED
@@ -73,18 +73,21 @@ def test_all_paths_resolve_to_same_classes() -> None:
73
  assert DomainInitial == CanonInitial == PkgInitial
74
 
75
 
76
- def test_legacy_spec_module_removed() -> None:
77
- """``picarones.pipeline.spec`` n'existe plus — chemin canonique
78
- unique via ``picarones.domain.pipeline_spec``.
 
 
 
79
  """
80
  import importlib
 
 
81
 
82
- try:
83
- importlib.import_module("picarones.pipeline.spec")
84
- except ModuleNotFoundError:
85
- pass
86
- else:
87
- raise AssertionError(
88
- "picarones.pipeline.spec ne devrait plus exister — "
89
- "importer depuis picarones.domain.",
90
- )
 
73
  assert DomainInitial == CanonInitial == PkgInitial
74
 
75
 
76
+ def test_legacy_spec_module_is_deprecated_shim() -> None:
77
+ """``picarones.pipeline.spec`` reste exposé avec
78
+ ``DeprecationWarning`` jusqu'à la 2.0 (cf. shim S59).
79
+
80
+ La couverture détaillée du contrat (warning émis, classes
81
+ identiques) vit dans ``tests/api_stability/test_deprecated_aliases``.
82
  """
83
  import importlib
84
+ import sys
85
+ import warnings
86
 
87
+ sys.modules.pop("picarones.pipeline.spec", None)
88
+ with warnings.catch_warnings():
89
+ warnings.simplefilter("ignore", DeprecationWarning)
90
+ mod = importlib.import_module("picarones.pipeline.spec")
91
+ assert hasattr(mod, "PipelineSpec")
92
+ assert hasattr(mod, "PipelineStep")
93
+ assert hasattr(mod, "INITIAL_STEP_ID")
 
 
tests/interfaces/web/test_rate_limit_xff.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Garde-fous sur le parsing X-Forwarded-For du ``RateLimitMiddleware``.
2
+
3
+ L'audit S58 a corrigé une faille IP-spoofing (lecture du PREMIER XFF
4
+ au lieu de la N-ième en partant de la fin). Le commit S58 #4 introduit
5
+ ``trust_proxy_count: int`` qui remplace ``trust_x_forwarded_for: bool``,
6
+ mais aucun test ne vérifiait la nouvelle logique.
7
+
8
+ Ces tests verrouillent le contrat sécuritaire :
9
+
10
+ 1. ``trust_proxy_count=0`` : XFF totalement ignoré (mode safe par défaut).
11
+ 2. ``trust_proxy_count=1`` : un proxy en amont, on lit la dernière IP
12
+ de la chaîne (le proxy direct est trustworthy).
13
+ 3. ``trust_proxy_count=N`` mais chaîne plus courte → fallback gracieux.
14
+ 4. Spoof attempt avec une IP injectée en tête → ignorée si la chaîne
15
+ est plus courte qu'attendu.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from unittest.mock import MagicMock
21
+
22
+ from starlette.requests import Request
23
+
24
+ from picarones.interfaces.web.security import RateLimitMiddleware
25
+
26
+
27
+ def _request(xff: str | None, client_host: str = "10.0.0.1") -> Request:
28
+ """Construit une ``Request`` minimale pour ``_extract_ip``."""
29
+ headers: list[tuple[bytes, bytes]] = []
30
+ if xff is not None:
31
+ headers.append((b"x-forwarded-for", xff.encode("ascii")))
32
+ scope = {
33
+ "type": "http",
34
+ "headers": headers,
35
+ "client": (client_host, 0),
36
+ }
37
+ return Request(scope) # type: ignore[arg-type]
38
+
39
+
40
+ def _middleware(trust_proxy_count: int = 0) -> RateLimitMiddleware:
41
+ """Instance prête à appeler ``_extract_ip`` (l'app sous-jacent
42
+ n'est pas exercé, on teste uniquement le helper de parsing)."""
43
+ return RateLimitMiddleware(
44
+ app=MagicMock(),
45
+ trust_proxy_count=trust_proxy_count,
46
+ )
47
+
48
+
49
+ def test_xff_ignored_when_trust_count_zero() -> None:
50
+ """Mode par défaut : XFF est ignoré, l'IP du socket prime.
51
+ Évite tout spoofing si le serveur est exposé directement.
52
+ """
53
+ mw = _middleware(trust_proxy_count=0)
54
+ req = _request(xff="evil.ip.example, real, proxy", client_host="1.2.3.4")
55
+ assert mw._extract_ip(req) == "1.2.3.4"
56
+
57
+
58
+ def test_xff_one_proxy_reads_last_ip() -> None:
59
+ """Avec ``trust_proxy_count=1`` (nginx local par ex.), on lit la
60
+ dernière IP de la chaîne — c'est l'IP que nginx a vue arriver,
61
+ pas celle que le client a forgée.
62
+ """
63
+ mw = _middleware(trust_proxy_count=1)
64
+ req = _request(xff="evil.ip.example, real-client", client_host="10.0.0.1")
65
+ assert mw._extract_ip(req) == "real-client"
66
+
67
+
68
+ def test_xff_two_proxies_reads_n_minus_2() -> None:
69
+ """Avec ``trust_proxy_count=2`` (load balancer + nginx), on lit
70
+ l'avant-avant-dernière IP.
71
+ """
72
+ mw = _middleware(trust_proxy_count=2)
73
+ req = _request(
74
+ xff="client, attacker-spoof, real-client, edge-proxy",
75
+ client_host="10.0.0.1",
76
+ )
77
+ # parts = [client, attacker-spoof, real-client, edge-proxy]
78
+ # idx = max(0, 4 - 2) = 2 → "real-client"
79
+ assert mw._extract_ip(req) == "real-client"
80
+
81
+
82
+ def test_xff_chain_shorter_than_expected_falls_back_gracefully() -> None:
83
+ """Si la chaîne XFF est plus courte que ``trust_proxy_count``
84
+ (mauvaise config ou client tronquant), on ne crash pas — on lit
85
+ l'IP la plus à gauche disponible.
86
+ """
87
+ mw = _middleware(trust_proxy_count=5)
88
+ req = _request(xff="single-ip", client_host="10.0.0.1")
89
+ # parts = [single-ip], idx = max(0, 1 - 5) = 0 → "single-ip"
90
+ assert mw._extract_ip(req) == "single-ip"
91
+
92
+
93
+ def test_xff_empty_value_ignored() -> None:
94
+ """Une chaîne XFF vide retombe sur ``request.client.host``."""
95
+ mw = _middleware(trust_proxy_count=1)
96
+ req = _request(xff="", client_host="10.0.0.1")
97
+ assert mw._extract_ip(req) == "10.0.0.1"
98
+
99
+
100
+ def test_xff_with_whitespace_normalized() -> None:
101
+ """Les espaces autour des virgules sont strippés."""
102
+ mw = _middleware(trust_proxy_count=1)
103
+ req = _request(xff=" client , real-client ", client_host="10.0.0.1")
104
+ assert mw._extract_ip(req) == "real-client"
105
+
106
+
107
+ def test_no_client_returns_unknown() -> None:
108
+ """Si ``request.client`` est ``None`` (cas exotique ASGI sans
109
+ socket), l'extraction retourne ``"unknown"`` plutôt que crash.
110
+ """
111
+ mw = _middleware(trust_proxy_count=0)
112
+ scope = {"type": "http", "headers": [], "client": None}
113
+ req = Request(scope) # type: ignore[arg-type]
114
+ assert mw._extract_ip(req) == "unknown"