Picarones / docs /explanation /architecture.md
Claude
docs: reconcilier README et spec.md avec l'IA reelle XerOCR (4 onglets)
8c480bc unverified
# Architecture Picarones — manifeste
> **Audience** : développeurs et mainteneurs. Ce document explique
> *pourquoi* le code est organisé comme il l'est, pas seulement *où
> sont les fichiers*. Pour la liste exhaustive des modules, lire
> directement le code — il est typé et documenté.
## Statut `0.9.0` — une seule arborescence canonique
À la release `0.9.0` (mai 2026, clôture du rewrite architectural),
Picarones a **une seule arborescence**. Tous les paquets
pré-rewrite ainsi que leurs sous-paquets transitoires ont été
supprimés au cours des sprints A-H. Pour le détail historique,
voir le [CHANGELOG section 0.9.0](../../CHANGELOG.md) et
[`docs/archive/2026-migration/`](../archive/2026-migration/).
Toute documentation, tout commentaire qui mentionne « deux
arborescences » ou « legacy en cours de retrait » est obsolète.
La seule cohabitation acceptable depuis `0.9.0` est celle entre
**modules canoniques** : par exemple `evaluation/metric_registry`
(module-level, side-effect d'import) et `evaluation/registry/registry`
(instance-based) — deux patterns volontairement coexistants pour
deux usages distincts (auto-discovery vs DI explicite).
Le test `tests/architecture/test_no_legacy_imports_in_rewrite.py`
verrouille cet invariant via `LEGACY_PACKAGES = ()`.
## Architecture — 8 couches concentriques
```
domain → formats → evaluation → pipeline → adapters → app → reports → interfaces
```
**Règle de dépendance stricte** : les flèches d'import vont uniquement
de l'extérieur vers l'intérieur (couche N peut importer 1..N-1, pas
N+1..8). Vérifié par
`tests/architecture/test_layer_dependencies.py`.
| # | Couche | Rôle |
|---|---|---|
| 1 | `domain/` | Types purs (Pydantic + stdlib) |
| 2 | `formats/` | Parsers ALTO, PAGE XML, normalisation texte |
| 3 | `evaluation/` | Métriques, statistiques, vues d'évaluation |
| 4 | `pipeline/` | DAG d'étapes, cache, runner corpus-wide |
| 5 | `adapters/` | OCR, LLM, VLM, corpus importers, storage |
| 6 | `app/` | Services applicatifs (orchestration) |
| 7 | `reports/` | Rendu HTML / JSON / CSV |
| 8 | `interfaces/` | CLI Click + Web FastAPI |
### `picarones/domain/` — types purs
Couche 1 (la plus interne). Aucune dépendance d'exécution,
aucun I/O, aucun framework. Pydantic et stdlib uniquement.
| Module | Contenu |
|---|---|
| `artifacts.py` | `Artifact`, `ArtifactType` (10 types : IMAGE, RAW_TEXT, ALTO_XML, PAGE_XML, ENTITIES, READING_ORDER, ALIGNMENT, CORRECTED_TEXT, CANONICAL_DOCUMENT, CONFIDENCES) |
| `artifact_key.py` | `ArtifactKey` — clé canonique multi-paramètres pour la reprise par hash |
| `corpus.py` | `CorpusSpec`, métadonnées de corpus |
| `documents.py` | `DocumentRef`, `GroundTruthRef` |
| `evaluation_spec.py` | `MetricSpec`, `EvaluationView`, `EvaluationSpec` |
| `pipeline_spec.py` | `PipelineSpec`, `PipelineStep`, `INITIAL_STEP_ID` |
| `projection_spec.py` | `ProjectionSpec` (transformation candidate avant évaluation) |
| `provenance.py` | `ProvenanceRecord` |
| `run_manifest.py` | `RunManifest` — empreinte immuable d'un run, sérialisée en `run_manifest.json` |
| `errors.py` | Hiérarchie d'exceptions (`PicaronesError`, `AdapterStepError`, `ArtifactValidationError`, …) |
### `picarones/formats/` — parsers et sérialiseurs
Lecture/écriture des formats externes : ALTO XML, PAGE XML, texte
normalisé. Dépend du domain ; aucune logique d'évaluation.
Le parser XML interne (`_xml_utils.safe_parse_xml`) délègue à
`defusedxml` avec `forbid_dtd=True`, bloquant XXE, Billion Laughs
et déclarations `<!DOCTYPE>`. Les défenses sont verrouillées par
`tests/security/test_xxe_attack.py`.
### `picarones/evaluation/` — moteurs d'évaluation
| Sous-package | Rôle |
|---|---|
| `metrics/` | ~37 métriques (CER/WER, philologiques, calibration, NER, layout…). Enregistrées via `@register_metric` au registre typé |
| `projectors/` | Projections inter-types (ALTO → texte, canonical → texte) avec `ProjectionReport` |
| `views/` | Vues d'évaluation : `TextView`, `AltoView`, `SearchView`. L'`EvaluationViewExecutor` aligne candidate + GT, applique normalisation + projection, calcule les métriques |
| `evaluation_engine.py` | Moteur central qui exécute une `EvaluationView` |
| `projection_engine.py` | Moteur de projection |
| `registry/` | `MetricRegistry` — découverte typée par signature `(input_type, output_type)` |
| `statistics/` | Wilcoxon, Friedman/Nemenyi, bootstrap, Pareto, CDD |
| `synthetic.py` | `generate_sample_benchmark` (utilisé par `picarones demo`) |
**Whitelist d'imports externes** : `PIL, annotated_types, jiwer,
numpy, pydantic, rapidfuzz, scipy, spacy, typing_extensions,
yaml`. **Pas** `pytesseract, mistralai, azure, google,
pero_ocr` — ceux-là vivent en couche 5 (`adapters/`).
### `picarones/pipeline/` — DAG d'étapes
Orchestration mono-document d'une pipeline composée :
| Module | Rôle |
|---|---|
| `executor.py` | `PipelineExecutor` — exécute un `PipelineSpec` step par step, capture `StepResult`, filtre outputs sur `step.output_types` |
| `planner.py` | `PipelinePlanner` — résout les `inputs_from`, valide la spec, calcule les métriques aux jonctions |
| `validation.py` | Validation statique d'une `PipelineSpec` (types s'enchaînent, pas de cycle) |
| `runner.py` | `CorpusRunner` — orchestration corpus-wide (ThreadPool unique), backpressure, timeout, cancellation |
| `cache.py`, `cache_helpers.py`, `cache_protocol.py` | Reprise par hash via `ArtifactCachePort` |
| `yaml_io.py` | Sérialisation YAML déterministe d'une `PipelineSpec` |
| `llm_pipeline_builder.py` | `make_ocr_llm_pipeline_spec` (3 modes : text_only, text_and_image, zero_shot) |
| `llm_pipeline_config.py` | `OCRLLMPipelineConfig` (container OCR+LLM) |
### `picarones/adapters/` — implémentations concrètes
C'est ici que vivent les **dépendances externes** (pytesseract,
pero, mistralai, openai, anthropic, google-cloud-vision, …).
| Sous-package | Adapters |
|---|---|
| `ocr/` | TesseractAdapter, PeroOCRAdapter, MistralOCRAdapter, GoogleVisionAdapter, AzureDocIntelAdapter, PrecomputedTextAdapter + factory `ocr_adapter_from_name` |
| `llm/` | AnthropicLLMAdapter, OpenAILLMAdapter, MistralLLMAdapter, OllamaLLMAdapter |
| `vlm/` | AnthropicVLMAdapter, OpenAIVLMAdapter, MistralVLMAdapter, OllamaVLMAdapter (héritage multiple `BaseVLMAdapter + BaseLLMAdapter`, MRO guard) |
| `corpus/` | local folder, IIIF, Gallica, HTR-United, HuggingFace Datasets, eScriptorium |
| `storage/` | `InMemoryArtifactStore`, `FilesystemArtifactStore`, `JobStore` (SQLite avec schema versioning) |
| `output_paths.py` | Helper partagé `resolve_output_path` (workspace-aware, read-only-mount-safe) |
| `_retry.py` | Helper partagé `call_with_retry` (3 retries, backoff 2/4/8s, sur 429+5xx+timeout réseau) |
**Règle** : un adapter peut importer le domain et ses libs externes.
Il ne doit **jamais** importer `app/` ou `interfaces/`. Il n'a aucune
logique d'évaluation (un OCR adapter ne calcule pas le CER — il
produit un artefact texte que `evaluation/` consommera).
**Anti-SSRF** : `corpus/_http.py:validate_http_url` refuse
loopback, lien-local, RFC 1918, métadonnées cloud (AWS
`169.254.169.254`, GCP `metadata.google.internal`). Verrouillé par
`tests/security/test_ssrf_attack.py`.
### `picarones/app/` — services applicatifs
Orchestration entre adapters et evaluation.
| Module | Rôle |
|---|---|
| `services/run_orchestrator.py` | `RunOrchestrator.execute(RunSpec)` — point d'entrée d'un run complet |
| `services/benchmark_service.py` | `BenchmarkService.run` — exécute pipelines × vues × corpus, produit `RunResult` |
| `services/benchmark_runner.py` | Façade `run_benchmark_via_service` consommée par CLI/web |
| `services/job_runner.py` | `JobRunner` — soumission asynchrone (thread daemon) avec persistance `JobStore` |
| `services/corpus_service.py` | Loading + sandboxing + extraction ZIP avec ZIP slip protection |
| `services/dependencies.py` | `capture_dependencies_lock()` via `importlib.metadata` pour le `RunManifest` |
| `services/path_security.py` | `WorkspaceManager` — sandboxe par session |
| `services/registry_service.py` | Découverte des adapters et vues canoniques |
| `services/partial_store.py` | Persistance NDJSON des résultats partiels (reprise sur interruption) |
| `schemas/run_spec.py` | `RunSpec`, `StepSpec` — modèles YAML user-facing |
| `results.py` | `RunResult`, `RunDocumentResult`, `ReportRenderer` (alias type unique) |
**Anti ZIP slip** : `corpus_service._extract_safely` rejette les
chemins absolus, `..`, octets nuls, symlinks ZIP entries
(mode UNIX 0xA000), avec garde-fou final `target.resolve().relative_to(extract_dir)`.
Verrouillé par `tests/security/test_zip_slip_attack.py`.
### `picarones/reports/` — rendu déterministe
| Sous-package | Rôle |
|---|---|
| `csv/render.py` | `CsvReportRenderer` — un CSV plat (`run_id, doc, pipeline, view, metric, value, status`) |
| `json/render.py` | `JsonReportRenderer` — manifest + documents en JSON déterministe |
| `html/render.py` | `HtmlReportRenderer` — rapport autonome (TextView, AltoView, SearchView) — minimaliste |
| `html/generator.py` | `ReportGenerator` — rapport interactif riche (~37 renderers + 4 onglets XerOCR + 5 modules de vues thématiques internes) consommé par CLI/web |
| `narrative/` | Moteur narratif (20 détecteurs) — synthèse factuelle déterministe |
| `glossary/`, `i18n/` | Glossaire + i18n FR/EN |
Le rendu est strict : pas de JS dynamique côté serveur, pas d'I/O
hors écriture finale, déterministe bit-for-bit à entrée constante.
Permet à un relecteur 5 ans plus tard de hasher un rapport et de le
citer.
**Anti-XSS** : `html/generator.py` utilise
`autoescape=select_autoescape(['html', 'j2', 'xml'])` (Jinja2) +
helper `_safe_json_for_script_tag` qui encode `<>&` en
`<>&` pour le JSON injecté dans
`<script type="application/json">`. Verrouillé par
`tests/security/test_xss_in_reports.py`.
### `picarones/interfaces/` — points d'entrée user-facing
| Sous-package | Rôle |
|---|---|
| `cli/` | Click — 16+ commandes : `run`, `diagnose`, `economics`, `edition`, `compare`, `robustness`, `history`, `serve`, `metrics`, `engines`, `info`, `demo`, `report`, `import` (group) |
| `web/` | FastAPI — UI Jinja2 + SSE benchmark + ZIP upload + 11 routers (corpus, benchmark, jobs, reports, history, engines, normalization, importers, synthesis, system, home) |
**Anti-CSRF** : middleware `csrf_middleware` actif si
`PICARONES_CSRF_REQUIRED=1`. Pattern double-submit cookie + HMAC
signature. Verrouillé par `tests/security/test_csrf_required.py`.
## Principes architecturaux
### Pas de shim hors deprecation period
Un module a un seul emplacement canonique. Quand un module migre,
on choisit explicitement entre :
- **Suppression dure** (pour la dette interne, pas de caller externe).
- **Shim avec `DeprecationWarning`** (pour la stabilité d'API publique).
Le shim a une date de retrait inscrite dans le CHANGELOG.
À `0.9.0` il reste **un seul shim** documenté :
`picarones/pipeline/spec.py` (réexporte `picarones.domain.pipeline_spec`),
dont la deprecation period expire à la prochaine minor `0.10.0`.
### Pas d'`except Exception: pass`
Toute fonctionnalité optionnelle qui échoue émet un
`logger.warning("[module] feature dégradée : %s", exc)` avec contexte.
Vérifié par `tests/architecture/test_no_side_effect_imports.py`.
### Tests architecturaux comme garde-fous
Plusieurs tests verrouillent des invariants structurels que la revue
de code humaine raterait :
- `test_layer_dependencies.py` — couches strictement orientées
- `test_no_legacy_imports_in_rewrite.py``LEGACY_PACKAGES = ()`
- `test_file_budgets.py` — pas de god-modules
- `test_doc_paths.py` — chemins cités dans la doc existent
- `test_output_paths_uniformity.py` — tous les adapters passent par `resolve_output_path`
- `test_storage_keys_filesystem_safe.py` — clés du store filesystem-safe (Windows)
- `test_manifest_reproducibility.py``RunManifest` capture tout pour rejouer
- `test_module_coverage.py` — chaque module a un test associé
### Tests de sécurité comme verrous de défense
63 tests d'attaque verrouillent les
défenses revendiquées :
- `tests/security/test_xss_in_reports.py` (5) — autoescape Jinja2 + escape JSON.
- `tests/security/test_xxe_attack.py` (9) — XXE / Billion Laughs / DTD.
- `tests/security/test_zip_slip_attack.py` (9) — ZIP slip + symlinks.
- `tests/security/test_ssrf_attack.py` (26) — loopback, RFC 1918, métadonnées cloud.
- `tests/security/test_csrf_required.py` (14) — double-submit + HMAC.
### Reproductibilité bit-for-bit
Le `RunManifest` capture systématiquement : `code_version`,
`pipeline_specs` complets, `adapter_kwargs`, `dependencies_lock`
(via `importlib.metadata`), `view_specs`, timestamps. La
sérialisation est déterministe (Pydantic ordered fields, JSON
sorted keys). Le hash du manifest peut être cité dans une
publication scientifique.
## Évolution
L'évolution de l'architecture est documentée :
- Backlog vivant : [`docs/roadmap/backlog.md`](../roadmap/backlog.md)
- Plans archivés (migration legacy → rewrite, terminée à `0.9.0`) :
[`docs/archive/2026-migration/`](../archive/2026-migration/)
- Roadmap historique pré-rewrite :
[`docs/archive/2026-roadmap/`](../archive/2026-roadmap/)
- Audits institutionnels : [`docs/archive/2026-audits/`](../archive/2026-audits/)
- Politique d'API publique : [`docs/reference/api-stable.md`](../reference/api-stable.md)