diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml new file mode 100644 index 0000000..0d3dacc --- /dev/null +++ b/.github/workflows/eval.yml @@ -0,0 +1,48 @@ +name: eval + +# Non-régression de la qualité d'extraction contre le vrai modèle. +# +# Les runners GitHub n'ont pas de GPU : ce workflow tourne sur un runner +# auto-hébergé enregistré sur le DGX Spark avec le label `dgx`, et vise le +# backend déployé sur cette machine. Lancement manuel (onglet Actions → +# eval → Run workflow), typiquement sur la branche d'une PR qui touche aux +# prompts, au modèle, au découpage ou aux templates. +# +# La référence (eval/baseline.json) se met à jour par PR, après validation : +# python eval/regression.py --api http://localhost:8080 --api-key $KEY --update-baseline + +on: + workflow_dispatch: + inputs: + api: + description: URL du backend à évaluer + default: http://localhost:8080 + forms: + description: Formulaires (séparés par des espaces, vide = les 21) + default: "" + +jobs: + regression: + runs-on: [self-hosted, dgx] + timeout-minutes: 180 + steps: + - uses: actions/checkout@v4 + # truth.py n'importe que la partie pure de l'orchestrateur (core/fields, core/provenance). + - run: python3 -m pip install --user "httpx>=0.27.0,<1.0" + - name: Dossiers de test contre le backend + env: + API_KEY: ${{ secrets.DOCTORFILL_API_KEY }} + API_URL: ${{ inputs.api }} + FORMS: ${{ inputs.forms }} + run: | + python3 eval/regression.py --api "$API_URL" --api-key "$API_KEY" \ + ${FORMS:+--forms $FORMS} --report report.md --json results.json + - if: always() + run: cat report.md >> "$GITHUB_STEP_SUMMARY" || true + - if: always() + uses: actions/upload-artifact@v4 + with: + name: eval-report + path: | + report.md + results.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3cd12c0 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,73 @@ +name: tests + +# À déclarer comme « required status checks » sur main (Settings → Branches) : +# lint, orchestrator, marker-ocr, frontend. Sans cette règle, un merge reste +# possible malgré une CI rouge. + +on: + push: + branches: [main, dev] + pull_request: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install "ruff==0.16.9" + # Erreurs seulement (noms indéfinis, imports inutiles, syntaxe) : pas de style imposé. + - run: ruff check --select F,E9 services eval + + orchestrator: + runs-on: ubuntu-latest + defaults: + run: + working-directory: services/orchestrator + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + # Celle de l'image nvcr.io/nvidia/pytorch de l'orchestrateur. + python-version: "3.12" + cache: pip + cache-dependency-path: services/orchestrator/requirements*.txt + - run: pip install -r requirements-dev.txt + # Sans GPU ni service : OCR, TEI et vLLM sont simulés. Couvre les 21 + # formulaires du catalogue et les 21 dossiers de eval/dossiers/. + - run: python -m pytest tests -q --cov=. --cov-report=term:skip-covered --cov-fail-under=70 + + marker-ocr: + runs-on: ubuntu-latest + defaults: + run: + working-directory: services/marker_ocr + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + # textlayer.py seul : marker et ses modèles ne sont pas nécessaires. + - run: pip install "pypdfium2>=4.30.0,<6.0" "pikepdf>=9.0.0,<11.0" "pytest>=8.0,<10.0" + - run: python -m pytest tests -q + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm run build diff --git a/.gitignore b/.gitignore index df0c0ef..c85de79 100644 --- a/.gitignore +++ b/.gitignore @@ -27,9 +27,17 @@ services/orchestrator/uploads/ services/orchestrator/results/ # ── eval & logs -eval +# Seuls les sorties et les documents générés sont ignorés : une règle `eval` +# entière rendait inertes les scripts et dossiers de test versionnés, et faisait +# échouer tout `git add` sur eux. +eval/results/ +eval/test_docs/ services/logs/*.md +# ── tests +.pytest_cache +.coverage + # ── Formulaires medForms récupérés par tools/gen_catalog.py ── # Les PDF vierges appartiennent à medForms et pèsent ~2 Mo pièce : on versionne # la liste (tools/catalog_fr.txt) et les templates dérivés, pas les binaires. diff --git a/README.md b/README.md index 02eecaf..6501bc3 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,20 @@ doctorfill/ └── docs/wiki/ # Documentation wiki ``` +## Tests + +```bash +cd services/orchestrator +pip install -r requirements-dev.txt +python -m pytest tests # sans GPU : OCR, TEI et vLLM sont simulés +``` + +Exécutés en CI à chaque PR (`.github/workflows/tests.yml`) : les 21 formulaires +et les 21 dossiers de `eval/dossiers/` de bout en bout. La qualité d'extraction +du vrai modèle se contrôle avec `eval/regression.py` contre une référence. +Détails, et réglage de GitHub pour bloquer un merge rouge : +[docs/wiki/Tests.md](docs/wiki/Tests.md). + ## Évaluation RAG ```bash @@ -116,7 +130,10 @@ python generate_docs.py --count 60 --noise 10 python run_eval.py --api http://localhost:8080 --api-key ``` -Précision actuelle : **84.6%** sur Form_AVS (26 champs) — voir [#21](https://github.com/doctorfill-dev/DoctorFill/issues/21) pour le plan d'amélioration. +Non-régression sur les 21 dossiers (vérité terrain dérivée des scénarios) : +`python eval/regression.py --api http://localhost:8080 --api-key ` — voir [docs/wiki/Tests.md](docs/wiki/Tests.md). + +Dernière mesure connue, avant septembre 2026 : **84.6%** sur Form_AVS (26 champs) — voir [#21](https://github.com/doctorfill-dev/DoctorFill/issues/21) pour le plan d'amélioration. ## Roadmap diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 10caf1d..e1f0109 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -11,6 +11,7 @@ Bienvenue sur le wiki de **DoctorFill**, l'application d'auto-remplissage de for | [Déploiement Frontend (Cloudflare)](Deploiement-Frontend-Cloudflare) | Déploiement du frontend sur Cloudflare Pages + Tunnel | | [Pipeline RAG](Pipeline-RAG) | Fonctionnement du pipeline RAG (OCR → Embedding → Retrieval → LLM) | | [Templates](Templates) | Documentation des templates de formulaires (AVS, Cardio, LAA) | +| [Tests](Tests) | Tests en CI et non-régression de la qualité d'extraction | ## Stack technique diff --git a/docs/wiki/Pipeline-RAG.md b/docs/wiki/Pipeline-RAG.md index 6304f86..e1dd460 100644 --- a/docs/wiki/Pipeline-RAG.md +++ b/docs/wiki/Pipeline-RAG.md @@ -2,96 +2,136 @@ ## Vue d'ensemble -Le pipeline RAG (Retrieval-Augmented Generation) est le coeur de DoctorFill. Il extrait automatiquement les informations des documents médicaux pour remplir les formulaires. +Le pipeline extrait les informations des documents médicaux d'un dossier pour +remplir un formulaire medForms (XFA statique hybride, couche AcroForm comprise). +Il est piloté par l'orchestrateur (`services/orchestrator/app.py`) et partagé +par le traitement initial et le re-run (`_extract_fields`, `_fill_pdf`). -## Étapes du pipeline +``` +PDF ──► OCR (texte natif, sinon marker) ──► découpage + embeddings (ChromaDB) + └► synthèse médicale (LLM) + │ + champs du template ──► questions univoques ──► lots ──► LLM (JSON contraint) + │ + normalisation + provenance ◄────────────┘ + │ + remplissage XFA + AcroForm +``` -### 1. OCR — Extraction de texte +## Étapes -**Service** : Marker OCR (`:8082`) +### 1. OCR -- Chaque PDF est envoyé au service Marker OCR -- Sortie : Markdown structuré (headings, tableaux, listes) -- Parallélisation : `asyncio.Semaphore(5)` pour limiter les appels GPU concurrents -- Timeout : 120s par document +**Service** : `marker_ocr` (`:8082`). La couche texte native est lue d'abord +(`textlayer.py`, quelques ms/page) ; l'OCR marker n'est lancé que pour les pages +scannées. Résultats mis en cache par empreinte SHA-256. -### 2. Chunking — Découpage du texte +Un document illisible **n'interrompt plus le job** : il est écarté et signalé +dans `warnings` (`/status`). Reprises proportionnées à la cause : service +injoignable → 5 tentatives espacées ; délai dépassé ou 5xx → une seule reprise ; +4xx → aucune. Si aucun document n'est lisible, le job échoue avec un message +explicite. -**Stratégie** : Semi-sémantique +### 2. Découpage et embeddings -1. **Découpage par headings Markdown** : chaque section `#`, `##`, `###` crée un chunk -2. **Fallback** : si un chunk dépasse 400 mots, il est découpé avec 50 mots de chevauchement +`extraction.chunk_document` — sections Markdown fusionnées si trop courtes +(< 40 mots), découpées si trop longues (300 mots, chevauchement 50). +**Chaque extrait porte sa source** : `[Source : 03_consultations.pdf — page 2 — Médecin traitant]`. +Embeddings `BAAI/bge-m3` par lots de 64 (TEI `:8081`, reprises sur erreur +transitoire), stockés dans une collection ChromaDB éphémère par job (réutilisée +par le chat et le re-run). -### 3. Embedding — Vectorisation +### 3. Synthèse médicale -**Service** : TEI (`:8081`) — Modèle `BAAI/bge-m3` +`medical_synthesis.py` — JSON structuré (patient, diagnostics, incapacités, +traitements, médecins, dates clés). Directe si le dossier tient dans la fenêtre, +hiérarchique sinon (résumé par document, découpé si besoin, puis fusion). +Replis : directe → hiérarchique ; fusion LLM → fusion déterministe. En cas +d'échec complet, l'extraction continue sur les seuls documents. -- Les chunks sont envoyés par batch de 64 pour éviter les timeouts -- Les vecteurs sont stockés dans une collection ChromaDB éphémère (en mémoire) -- Timeout : 120s par batch +### 4. Préparation des champs -### 4. Retrieval — Recherche sémantique +`core/fields.py` : -Pour chaque champ du formulaire : +- **Champs soumis** : question rédigée, ni `computed` ni `preset` d'éditeur. + Un `preset` qui n'est que l'**état vierge** du champ (case « /Off », question + oui/non sur « non », compteur à 0) reste à remplir. +- **Questions univoques** : les occurrences d'une structure répétée reçoivent + leur contexte — « (Contexte : période d'incapacité de travail n°2 sur 4 — de la + plus ancienne à la plus récente ; vide s'il n'y en a pas de n°2) ». +- **Lots** : par section, jusqu'à 8 champs ; les occurrences d'une même + structure restent dans le même lot (jusqu'à 32) pour être réparties sans doublon. -1. **Embedding de la question** : la question du template est vectorisée -2. **Recherche ChromaDB** : `n_results = min(20, nombre_total_de_chunks)` -3. **Reranking** : les 20 résultats sont re-classés par le modèle `BAAI/bge-reranker-v2-m3` -4. **Sélection** : les 7 meilleurs chunks sont conservés +### 5. Contexte du prompt -### 5. Extraction LLM +Deux modes, choisis par job selon un budget en tokens (`extraction.py`) : -**Service** : vLLM (`:8000`) — Modèle `Qwen/Qwen2.5-14B-Instruct-AWQ` +| Mode | Quand | Contexte | +|------|-------|----------| +| Dossier intégral | dossier ≤ `FULL_CONTEXT_MAX_TOKENS` (16k) et place dans la fenêtre | texte complet de tous les documents, identique pour tous les lots (préfixe mis en cache par vLLM) | +| Extraits | au-delà | recherche vectorielle (30 candidats) → rerank `bge-reranker-v2-m3` (top 8) → extraits **entrelacés par rang** entre les champs du lot, dans `RAG_CONTEXT_TOKENS` (9k) | -- Les 7 chunks sélectionnés sont envoyés comme contexte -- Le LLM extrait la valeur selon des règles strictes (prompt système dédié) -- Température : 0.05 (quasi-déterministe) -- Format de réponse : `{"value": "...", "source_quote": "..."}` +La synthèse est transmise **en entier** à chaque lot, avant les documents. -### 6. Remplissage PDF +### 6. Extraction LLM -- Les valeurs extraites sont injectées dans le XML/XFA du formulaire PDF -- Le PDF rempli est disponible en téléchargement +**Service** : vLLM (`:8000`) — `Qwen/Qwen2.5-14B-Instruct-AWQ`, fenêtre 32k. -## Prompt système +- Réponse contrainte par **schéma JSON** (`response_format: json_schema`) : tous + les IDs sont présents, les listes de choix sont des `enum`. Repli automatique + sur `json_object` si le serveur refuse le schéma. +- Température 0, `max_tokens` 4096, délai 600 s. +- Reprises sur les seules erreurs transitoires (réseau, délai, 5xx, 429). +- Fenêtre dépassée → contexte réduit de moitié (3 fois au plus) ; + réponse tronquée → lot scindé ; IDs absents → redemandés une fois. -``` -Tu es un assistant spécialisé dans l'extraction de données depuis des documents médicaux. -On te fournit des EXTRAITS de rapports et une QUESTION. - -RÈGLES STRICTES : -1. Cherche la réponse dans les extraits fournis. -2. Extrais la valeur EXACTE telle qu'elle apparaît dans le texte. -3. Ne réponds "Inconnu" que si l'information est RÉELLEMENT ABSENTE. -4. Réponds UNIQUEMENT en JSON valide. -5. Pour les dates, conserve le format original du document. -6. Pour les noms/prénoms, conserve la casse originale. -``` +### 7. Normalisation et provenance -## Optimisations réalisées +- Valeurs : « non mentionné », « N/A », « ... », exemples de format → vide ; + dates → `JJ.MM.AAAA` ; oui/non canoniques ; nombres seuls ; sexe `M`/`F`. + La réponse brute reste dans `raw_value`. +- Provenance (`core/provenance.py`) : la valeur est-elle retrouvable dans les + documents, et où ? Verdict `verified` / `attested` / `inferred` / `unverified` + / `not_checkable`, avec document, page et extrait. Pour une réponse choisie + (oui/non, option de liste), seule la citation compte. -| Paramètre | Avant | Après | Impact | -|-----------|-------|-------|--------| -| MAX_FILES | 10 | 100 | Support de gros dossiers | -| n_results | 5 | min(20, total) | Meilleure couverture | -| reranked | top 3 | top 7 | Plus de contexte au LLM | -| Embedding | 1 appel | Batch de 64 | Pas de timeout | -| OCR | Séquentiel | Parallèle (sem=5) | ~2x plus rapide | -| Température | 0.1 | 0.05 | Réponses plus stables | -| Timeout embed/rerank | 60s | 120s | Pas de timeout sur gros corpus | +### 8. Remplissage PDF -## Métriques actuelles +`core/fields.collect_form_values` construit les valeurs XFA et AcroForm : +extraits, champs calculés, puis destinataire dérivé du canton. +Cases à cocher : état déclaré par le formulaire (`On`/`Off` ou `1`/`0`). +Listes : libellé choisi → valeur d'export, et libellé affiché dans l'apparence. -| Métrique | Valeur | -|----------|--------| -| Précision AVS (26 champs) | 84.6% (22/26) | -| Temps de traitement (70 docs) | ~90s | -| Temps de traitement (100 docs) | <5 min (estimé) | +## Réglages + +| Variable | Défaut | Rôle | +|----------|--------|------| +| `LLM_MAX_MODEL_LEN` | 32768 | À aligner sur `--max-model-len` de vLLM | +| `FULL_CONTEXT_MAX_TOKENS` | 16000 | Seuil du mode dossier intégral | +| `RAG_CONTEXT_TOKENS` | 9000 | Budget d'extraits par lot | +| `MAX_TOKENS_EXTRACT` | 4096 | Génération par lot | +| `LLM_TIMEOUT` / `SYNTHESIS_TIMEOUT` | 600 / 900 s | Délais LLM | +| `MAX_BATCH_SIZE` / `MAX_FAMILY_SIZE` | 8 / 32 | Taille des lots | +| `RETRIEVAL_CANDIDATES` / `RETRIEVAL_TOP_K` | 30 / 8 | Retrieval | +| `KEEP_DEBUG_LOGS` | false | Conserver les journaux de debug au-delà du job | -## Problèmes connus +## Tests -1. **Contamination par bruit** : les documents d'autres patients polluent le retrieval -2. **Confusion d'entités** : patient/médecin/spécialiste mal distingués -3. **Retrieval miss** : certains champs ne trouvent pas leur chunk pertinent +```bash +cd services/orchestrator +pip install -r requirements-dev.txt +python -m pytest tests +``` + +Sans GPU ni service : OCR, TEI et vLLM sont simulés. Les 21 formulaires et les +21 dossiers d'évaluation sont vérifiés de bout en bout à chaque PR — voir +[Tests](Tests.md). + +## Métriques + +| Métrique | Valeur | +|----------|--------| +| Précision AVS (26 champs, avant les changements de septembre 2026) | 84.6% (22/26) | -→ Voir issue #21 pour le plan d'amélioration. +À remesurer avec `eval/regression.py` (21 dossiers, 324 champs de vérité +terrain) et `eval/check_grounding.py` — voir [Tests](Tests.md). diff --git a/docs/wiki/Tests.md b/docs/wiki/Tests.md new file mode 100644 index 0000000..f45e9b0 --- /dev/null +++ b/docs/wiki/Tests.md @@ -0,0 +1,80 @@ +# Tests et non-régression + +Deux questions différentes, deux dispositifs : + +| Question | Où | Quand | Bloque le merge | +|---|---|---|---| +| **Le code fait-il toujours ce qu'il doit ?** — chaque champ de chaque formulaire atteint le PDF, les erreurs sont absorbées, les valeurs normalisées | CI GitHub Actions (`.github/workflows/tests.yml`), sans GPU, services simulés | chaque PR et chaque push sur `main`/`dev` | oui, une fois les checks déclarés obligatoires (voir plus bas) | +| **Le modèle trouve-t-il toujours les bonnes valeurs ?** — un changement de prompt, de modèle, de découpage ou de template ne dégrade pas l'extraction | `eval/regression.py` contre le vrai backend (`.github/workflows/eval.yml` sur le DGX) | à la demande, sur les PR qui touchent l'extraction | par la référence `eval/baseline.json` et la revue | + +## 1. CI — à chaque PR + +```bash +cd services/orchestrator && pip install -r requirements-dev.txt && python -m pytest tests +cd services/marker_ocr && python -m pytest tests # pypdfium2 + pikepdf suffisent +``` + +| Fichier | Ce qui est garanti | +|---|---| +| `test_catalogue.py` | Invariants des 21 templates : IDs, `xml_path` et `acroform_name` uniques, questions toutes rédigées, options alignées sur leurs valeurs d'export, table cantonale cohérente, chaque lot tient dans la fenêtre du modèle, **aucun formulaire ne perd de champs extraits** (`catalogue_coverage.json`) | +| `test_catalogue_fill.py` | Pour les 21 formulaires, un PDF hybride XFA + AcroForm de même structure (`form_factory.py`) : **chaque champ extrait arrive dans les deux couches**, cases, boutons radio, listes et destinataire cantonal compris | +| `test_eval_dossiers.py` | Les 21 dossiers de `eval/dossiers/` passent **par l'API HTTP** (upload → OCR → synthèse → extraction → `/fields` → `/download`), modèle simulé répondant la vérité terrain : 100 % dans `/fields` et dans le PDF livré. Le garde-fou qualité lui-même est testé (il note 100 % un modèle parfait, détecte une valeur fausse, sort en erreur sur un job en échec) | +| `test_pipeline.py` | Robustesse : document illisible, panne d'embeddings, de rerank ou de synthèse, re-run, purge des fichiers patients, jeton du chat | +| `test_extraction.py` | Appel LLM : contexte trop long, réponse tronquée, IDs absents, réponse vide, schéma refusé, erreurs 4xx/5xx | +| `test_fields.py` | Préparation des champs et normalisation des valeurs | +| `test_fill.py` | Écriture AcroForm/XFA sur un PDF minimal | +| `marker_ocr/tests/test_textlayer.py` | Aiguillage couche texte native / OCR | + +Chaque garde-fou a été vérifié par mutation : réintroduire l'ancien bug (case +« On » écrite « 0 », destinataire cantonal appliqué par nom, cases vierges +exclues) fait échouer la CI. + +Le job `orchestrator` échoue aussi sous 70 % de couverture de code. + +### Rendre la CI bloquante + +Sans cette règle, GitHub autorise le merge d'une PR rouge. +**Settings → Branches → Add branch protection rule** (ou *Rulesets*) sur `main` : + +- *Require a pull request before merging* ; +- *Require status checks to pass before merging*, avec les checks + `lint`, `orchestrator`, `marker-ocr`, `frontend` ; +- *Require branches to be up to date before merging* : la CI est rejouée sur + le résultat du merge, pas seulement sur la branche. + +## 2. Qualité d'extraction — contre le vrai modèle + +`eval/truth.py` dérive la vérité terrain des scénarios qui ont généré les +dossiers (patient, médecin, incapacités, diagnostics, accident) : **324 champs +sur les 21 formulaires**, chacun vérifié présent dans le texte du dossier. + +```bash +# Sur main, après validation : fixer la référence (à committer) +python eval/regression.py --api http://localhost:8080 --api-key $KEY --update-baseline + +# Sur une branche : comparer à la référence +python eval/regression.py --api http://localhost:8080 --api-key $KEY --report report.md +``` + +Code de sortie : `0` sans régression, `1` en cas de régression, `2` si un +dossier n'a pas pu être traité. Est une régression : un formulaire qui perd +plus d'un champ juste par rapport à la référence (bruit de vLLM toléré), un +champ en erreur de plus, ou une précision globale en baisse de plus de +2 points. `--min-accuracy 0.85` ajoute un plancher absolu. + +Le rapport liste, par formulaire, les champs justes, l'écart à la référence, +et le détail des champs faux (attendu / obtenu). + +### Sur le DGX, depuis GitHub + +`.github/workflows/eval.yml` (onglet *Actions → eval → Run workflow*) lance le +même script sur un runner auto-hébergé portant le label `dgx`, et publie le +rapport dans le résumé du job. Prérequis, une fois : + +1. enregistrer un runner sur le DGX (*Settings → Actions → Runners → New + self-hosted runner*), avec le label `dgx` ; +2. créer le secret `DOCTORFILL_API_KEY` (*Settings → Secrets and variables → + Actions*). + +Le workflow est manuel : un runner auto-hébergé ne doit pas exécuter le code +de n'importe quelle PR automatiquement. diff --git a/eval/dossiers/__pycache__/__init__.cpython-314.pyc b/eval/dossiers/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 81506ac..0000000 Binary files a/eval/dossiers/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/eval/dossiers/__pycache__/praticiens.cpython-314.pyc b/eval/dossiers/__pycache__/praticiens.cpython-314.pyc deleted file mode 100644 index f3bf9f9..0000000 Binary files a/eval/dossiers/__pycache__/praticiens.cpython-314.pyc and /dev/null differ diff --git a/eval/dossiers/__pycache__/render.cpython-314.pyc b/eval/dossiers/__pycache__/render.cpython-314.pyc deleted file mode 100644 index 3212082..0000000 Binary files a/eval/dossiers/__pycache__/render.cpython-314.pyc and /dev/null differ diff --git a/eval/dossiers/__pycache__/scenarios_ai.cpython-314.pyc b/eval/dossiers/__pycache__/scenarios_ai.cpython-314.pyc deleted file mode 100644 index 756fea1..0000000 Binary files a/eval/dossiers/__pycache__/scenarios_ai.cpython-314.pyc and /dev/null differ diff --git a/eval/dossiers/__pycache__/scenarios_autres.cpython-314.pyc b/eval/dossiers/__pycache__/scenarios_autres.cpython-314.pyc deleted file mode 100644 index 4a55c28..0000000 Binary files a/eval/dossiers/__pycache__/scenarios_autres.cpython-314.pyc and /dev/null differ diff --git a/eval/dossiers/__pycache__/scenarios_laa.cpython-314.pyc b/eval/dossiers/__pycache__/scenarios_laa.cpython-314.pyc deleted file mode 100644 index 5f19789..0000000 Binary files a/eval/dossiers/__pycache__/scenarios_laa.cpython-314.pyc and /dev/null differ diff --git a/eval/dossiers/scenarios_autres.py b/eval/dossiers/scenarios_autres.py index cb8b3fa..862798c 100644 --- a/eval/dossiers/scenarios_autres.py +++ b/eval/dossiers/scenarios_autres.py @@ -1,6 +1,6 @@ """Scénarios LAMal, assurance militaire, LCA, adressages et prescriptions.""" -from .praticiens import BRUNNER, FAVRE, MORAND, ROSSIER, SCHMID, VERNET +from .praticiens import BRUNNER, FAVRE, MORAND, ROSSIER, VERNET from .render import Patient, Scenario SCENARIOS = [ diff --git a/eval/dossiers/scenarios_laa.py b/eval/dossiers/scenarios_laa.py index 22fe176..dd2063a 100644 --- a/eval/dossiers/scenarios_laa.py +++ b/eval/dossiers/scenarios_laa.py @@ -1,6 +1,6 @@ """Scénarios des formulaires de l'assurance-accidents (LAA / Suva).""" -from .praticiens import BRUNNER, KELLER, MORAND, VERNET +from .praticiens import BRUNNER, KELLER, MORAND from .render import Patient, Scenario SCENARIOS = [ diff --git a/eval/regression.py b/eval/regression.py new file mode 100644 index 0000000..7e51dba --- /dev/null +++ b/eval/regression.py @@ -0,0 +1,236 @@ +""" +Garde-fou qualité : les dossiers de test contre un backend réel, comparés à une référence. + +Les tests de CI simulent le modèle : ils prouvent que la chaîne de traitement est +intacte, pas que le modèle trouve encore les bonnes valeurs. Une modification de +prompt, de découpage, de modèle ou de template peut dégrader l'extraction sans +casser un seul test. Ce script la mesure : il soumet chaque dossier de +`eval/dossiers/` à l'API, compare le résultat à la vérité terrain dérivée des +scénarios (`truth.py`), puis à la dernière référence validée (`baseline.json`). + + # Fixer la référence (sur main, après validation) : + python eval/regression.py --api http://localhost:8080 --api-key $KEY --update-baseline + + # Vérifier une branche avant merge : + python eval/regression.py --api http://localhost:8080 --api-key $KEY --report report.md + + # Sous-ensemble : + python eval/regression.py ... --forms LCA_IncapaciteTravail AI_ReadaptationRente + +Code de sortie : 0 sans régression, 1 en cas de régression, 2 si un dossier n'a +pas pu être traité (job en échec, délai dépassé, API injoignable). +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +import httpx + +from truth import derive_truth, dossier_pdfs, load_scenarios, score + +BASELINE_PATH = Path(__file__).resolve().parent / "baseline.json" + +# Tolérances par défaut. vLLM n'est pas strictement déterministe (ordre du batch +# continu), même à température 0 : un champ d'écart par formulaire est du bruit. +MAX_FIELD_DROP = 1 +MAX_GLOBAL_DROP = 0.02 + + +def run_form(client: httpx.Client, form_id: str, pdfs: list[Path], poll_interval: float = 5.0, + timeout: float = 1800.0, sleep: Callable[[float], None] = time.sleep) -> dict[str, Any]: + """Soumet un dossier, attend le résultat et le note.""" + started = time.monotonic() + files = [("report_files", (p.name, p.read_bytes(), "application/pdf")) for p in pdfs] + resp = client.post("/process-form", files=files, data={"form_id": form_id}) + if resp.status_code != 200: + return {"form": form_id, "status": "rejected", "message": f"HTTP {resp.status_code}: {resp.text[:200]}"} + job = resp.json() + job_id, token = job["job_id"], job.get("token", "") + + while True: + status = client.get(f"/status/{job_id}").json() + if status["status"] == "completed": + break + if status["status"] == "failed": + return {"form": form_id, "status": "failed", "message": status.get("message")} + if time.monotonic() - started > timeout: + return {"form": form_id, "status": "timeout", "message": f"> {timeout:.0f} s"} + sleep(poll_interval) + + fields = client.get(f"/fields/{job_id}", params={"token": token}).json()["fields"] + result = score(derive_truth(form_id), fields) + result.update({ + "form": form_id, + "status": "completed", + "seconds": round(time.monotonic() - started, 1), + "warnings": status.get("warnings", []), + "job_id": job_id, + }) + return result + + +def summarize(results: list[dict[str, Any]]) -> dict[str, Any]: + done = [r for r in results if r["status"] == "completed"] + truth = sum(r["truth_fields"] for r in done) + correct = sum(r["correct"] for r in done) + return { + "forms": len(results), + "completed": len(done), + "truth_fields": truth, + "correct": correct, + "accuracy": round(correct / truth, 4) if truth else None, + "errors": sum(r["errors"] for r in done), + } + + +def compare(results: list[dict[str, Any]], baseline: dict[str, Any] | None, + max_field_drop: int = MAX_FIELD_DROP, max_global_drop: float = MAX_GLOBAL_DROP, + min_accuracy: float | None = None) -> list[str]: + """Régressions par rapport à la référence, en clair.""" + problems: list[str] = [] + reference = (baseline or {}).get("forms", {}) + for r in results: + form = r["form"] + if r["status"] != "completed": + problems.append(f"{form} : job {r['status']} ({r.get('message')})") + continue + base = reference.get(form) + if base is None: + continue + if r["correct"] < base["correct"] - max_field_drop: + problems.append(f"{form} : {r['correct']}/{r['truth_fields']} champs justes " + f"(référence {base['correct']}/{base['truth_fields']})") + if r["errors"] > base.get("errors", 0): + problems.append(f"{form} : {r['errors']} champ(s) en erreur (référence {base.get('errors', 0)})") + + # Précision globale, sur les seuls formulaires présents des deux côtés. + shared = [r for r in results if r["status"] == "completed" and r["form"] in reference] + if shared: + cur = sum(r["correct"] for r in shared) / max(1, sum(r["truth_fields"] for r in shared)) + ref = (sum(reference[r["form"]]["correct"] for r in shared) + / max(1, sum(reference[r["form"]]["truth_fields"] for r in shared))) + if cur < ref - max_global_drop: + problems.append(f"précision globale {cur:.1%} (référence {ref:.1%})") + if min_accuracy is not None: + overall = summarize(results)["accuracy"] + if overall is not None and overall < min_accuracy: + problems.append(f"précision globale {overall:.1%} sous le plancher {min_accuracy:.0%}") + return problems + + +def baseline_from(results: list[dict[str, Any]], meta: dict[str, Any]) -> dict[str, Any]: + return { + "_meta": {**meta, "updated": datetime.now(timezone.utc).isoformat(timespec="seconds")}, + "global": summarize(results), + "forms": {r["form"]: {k: r[k] for k in ("correct", "truth_fields", "accuracy", "fill_rate", "errors")} + for r in results if r["status"] == "completed"}, + } + + +def report_markdown(results: list[dict[str, Any]], problems: list[str], + baseline: dict[str, Any] | None) -> str: + """Rapport lisible dans le résumé d'un job GitHub Actions ou en commentaire de PR.""" + reference = (baseline or {}).get("forms", {}) + summary = summarize(results) + lines = ["## Non-régression de l'extraction", ""] + if summary["accuracy"] is not None: + lines.append(f"**Précision globale : {summary['accuracy']:.1%}** " + f"({summary['correct']}/{summary['truth_fields']} champs, " + f"{summary['completed']}/{summary['forms']} dossiers traités, " + f"{summary['errors']} champ(s) en erreur)") + lines.append("") + lines.append("✅ Aucune régression." if not problems else "❌ **Régressions :**") + lines += [f"- {p}" for p in problems] + lines += ["", "| Formulaire | Justes | Δ réf. | Remplis | Erreurs | Durée |", + "|---|---|---|---|---|---|"] + for r in results: + if r["status"] != "completed": + lines.append(f"| {r['form']} | — | — | — | {r['status']} | — |") + continue + base = reference.get(r["form"]) + delta = f"{r['correct'] - base['correct']:+d}" if base else "n/a" + lines.append(f"| {r['form']} | {r['correct']}/{r['truth_fields']} | {delta} | " + f"{r['filled']}/{r['fields']} | {r['errors']} | {r.get('seconds', 0):.0f} s |") + wrong = [(r["form"], d) for r in results if r["status"] == "completed" + for d in r["details"] if not d["match"]] + if wrong: + lines += ["", "
Champs faux ou manquants", "", + "| Formulaire | Champ | Attendu | Obtenu |", "|---|---|---|---|"] + for form, d in wrong: + got = d["extracted"].replace("\n", " ").replace("|", "/")[:60] or "∅" + lines.append(f"| {form} | {d['id']} ({d['category']}) | {d['expected']} | {got} |") + lines += ["", "
"] + return "\n".join(lines) + "\n" + + +def main(argv: list[str] | None = None, client: httpx.Client | None = None) -> int: + """Point d'entrée. `client` permet aux tests de viser l'application en mémoire.""" + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--api", default="http://localhost:8080") + parser.add_argument("--api-key", default="") + parser.add_argument("--forms", nargs="*", help="Formulaires à évaluer (défaut : tous)") + parser.add_argument("--baseline", type=Path, default=BASELINE_PATH) + parser.add_argument("--update-baseline", action="store_true", help="Écrire le résultat comme nouvelle référence") + parser.add_argument("--max-field-drop", type=int, default=MAX_FIELD_DROP) + parser.add_argument("--max-global-drop", type=float, default=MAX_GLOBAL_DROP) + parser.add_argument("--min-accuracy", type=float, help="Plancher absolu de précision globale (0-1)") + parser.add_argument("--parallel", type=int, default=2, help="Dossiers soumis de front") + parser.add_argument("--report", type=Path, help="Rapport markdown") + parser.add_argument("--json", type=Path, help="Résultats détaillés") + args = parser.parse_args(argv) + + forms = args.forms or sorted(load_scenarios()) + client = client or httpx.Client(base_url=args.api, headers={"X-API-Key": args.api_key}, timeout=900.0) + try: + health = client.get("/health").json() + except httpx.HTTPError as exc: + print(f"API injoignable ({args.api}) : {exc}", file=sys.stderr) + return 2 + print(f"Backend {health.get('build')} — modèle {health.get('model')} — {len(forms)} dossiers") + + def _one(form: str) -> dict[str, Any]: + result = run_form(client, form, dossier_pdfs(form)) + shown = (f"{result['correct']}/{result['truth_fields']}" if result["status"] == "completed" + else result["status"]) + print(f" {form:40} {shown}") + return result + + with ThreadPoolExecutor(max_workers=max(1, args.parallel)) as pool: + results = list(pool.map(_one, forms)) + + baseline = json.loads(args.baseline.read_text(encoding="utf-8")) if args.baseline.exists() else None + problems = compare(results, baseline, args.max_field_drop, args.max_global_drop, args.min_accuracy) + report = report_markdown(results, problems, baseline) + print("\n" + report) + if args.report: + args.report.write_text(report, encoding="utf-8") + if args.json: + args.json.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8") + + failed = [r for r in results if r["status"] != "completed"] + if args.update_baseline: + if failed: + print("Référence non écrite : des dossiers n'ont pas été traités.", file=sys.stderr) + return 2 + meta = {"build": health.get("build"), "model": health.get("model")} + args.baseline.write_text(json.dumps(baseline_from(results, meta), ensure_ascii=False, indent=2) + "\n", + encoding="utf-8") + print(f"Référence écrite : {args.baseline}") + return 0 + if baseline is None: + print("Aucune référence : lancer une fois avec --update-baseline sur main.", file=sys.stderr) + if failed: + return 2 + return 1 if problems else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/eval/run_eval.py b/eval/run_eval.py index be84151..6342599 100644 --- a/eval/run_eval.py +++ b/eval/run_eval.py @@ -125,7 +125,7 @@ def run_evaluation(api_url: str, api_key: str = "", form_id: str = "AI_Readaptat return print(f"\n{'='*70}") - print(f" ÉVALUATION DOCTORFILL RAG") + print(" ÉVALUATION DOCTORFILL RAG") print(f"{'='*70}") print(f" API : {api_url}") print(f" Formulaire: {form_id}") diff --git a/eval/truth.py b/eval/truth.py new file mode 100644 index 0000000..11de0bc --- /dev/null +++ b/eval/truth.py @@ -0,0 +1,234 @@ +""" +Vérité terrain dérivée des scénarios de test, pour les 21 formulaires. + +`ground_truth.json` ne couvre qu'un formulaire (AI_ReadaptationRente) et a dû +être réaligné à la main à chaque régénération de template. Les dossiers de +`eval/dossiers/` sont, eux, générés à partir de scénarios structurés (patient, +médecin, incapacités, diagnostics) : la valeur attendue d'un champ se déduit +donc de sa structure medForms et de son nom, pour n'importe quel formulaire. + +Seuls les faits effectivement présents dans le texte du dossier sont retenus : +un champ que le dossier ne documente pas ne peut pas être reproché au modèle. + +Utilisé par eval/regression.py (contre le vrai backend) et par les tests de +l'orchestrateur (services simulés) : les deux mesurent avec la même règle. +""" + +from __future__ import annotations + +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +EVAL_DIR = Path(__file__).resolve().parent +REPO_DIR = EVAL_DIR.parent +ORCHESTRATOR_DIR = REPO_DIR / "services" / "orchestrator" +DOSSIERS_DIR = EVAL_DIR / "dossiers" +TEMPLATES_DIR = ORCHESTRATOR_DIR / "template" + +for _path in (EVAL_DIR, ORCHESTRATOR_DIR): + if str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +from core.fields import coding_systems, fold, normalize_date, prepare_fields # noqa: E402 +from core.provenance import SourceIndex # noqa: E402 + +# (structure, champ) → (attribut du patient, tolérance) +_PATIENT = { + "lastName": ("nom", "exact"), "firstName": ("prenom", "exact"), + "birthDate": ("naissance", "date"), "sex": ("sexe", "exact"), "ssn": ("avs", "alnum"), + "street": ("rue", "fuzzy"), "zip": ("npa", "exact"), "city": ("ville", "fuzzy"), + "phone": ("tel", "phone"), "email": ("email", "exact"), +} +# Le fournisseur de prestations est le médecin traitant du scénario. +_PROVIDER = { + "ean": ("gln", "alnum"), "zsr": ("rcc", "alnum"), "phone": ("tel", "phone"), + "email": ("email", "exact"), "street": ("rue", "fuzzy"), "zip": ("npa", "exact"), + "city": ("ville", "fuzzy"), +} + +_INDEX = re.compile(r"\[(\d+)\]$") + + +@dataclass +class Expectation: + expected: str + tolerance: str + category: str + + def as_dict(self) -> dict[str, str]: + return {"expected": self.expected, "tolerance": self.tolerance, "category": self.category} + + +def load_scenarios() -> dict[str, Any]: + """Scénarios par identifiant de formulaire.""" + from dossiers import SCENARIOS + return {s.form: s for s in SCENARIOS} + + +def dossier_documents(form_id: str) -> list[dict[str, str]]: + """Texte des documents d'un dossier, tel que l'OCR le rendrait.""" + folder = DOSSIERS_DIR / form_id + return [{"filename": md.with_suffix(".pdf").name, "markdown": md.read_text(encoding="utf-8")} + for md in sorted(folder.glob("[0-9]*.md"))] + + +def dossier_pdfs(form_id: str) -> list[Path]: + return sorted((DOSSIERS_DIR / form_id).glob("[0-9]*.pdf")) + + +def load_template(form_id: str, templates_dir: Path = TEMPLATES_DIR) -> dict: + return json.loads((templates_dir / f"Form_{form_id}.json").read_text(encoding="utf-8")) + + +def _segments(field: dict) -> list[str]: + return [s for s in str(field.get("xml_path") or "").split("/") if s] + + +def _index(segment: str) -> int: + match = _INDEX.search(segment) + return int(match.group(1)) if match else 0 + + +def _bare(segment: str) -> str: + return _INDEX.sub("", segment) + + +def _candidates(scenario, fields: list[dict], coding: dict[str, str]) -> dict[str, Expectation]: + """Valeur attendue de chaque champ que le scénario renseigne.""" + patient, medecin = scenario.patient, scenario.medecin + truth: dict[str, Expectation] = {} + diagnosis_rank = 0 + for field in fields: + parts = _segments(field) + if not parts: + continue + leaf_segment = parts[-1] + leaf = _bare(leaf_segment) + container = parts[-2] if len(parts) >= 2 else "" + struct = _bare(container) + fid = str(field["id"]) + + if leaf == "treatmentCanton": + truth[fid] = Expectation(patient.canton, "exact", "admin") + elif struct == "patientS1Address" and leaf in _PATIENT and _index(leaf_segment) == 0: + attr, tol = _PATIENT[leaf] + truth[fid] = Expectation(getattr(patient, attr), tol, "patient") + elif struct == "providerS1Address" and leaf in _PROVIDER and _index(leaf_segment) == 0: + attr, tol = _PROVIDER[leaf] + truth[fid] = Expectation(getattr(medecin, attr), tol, "medecin") + elif struct == "unemployabilityS1Struct" and leaf in ("beginDate", "endDate", "percentageNum"): + rank = _index(container) + if rank < len(scenario.incapacites): + begin, end, rate = scenario.incapacites[rank] + value, tol = {"beginDate": (begin, "date"), "endDate": (end, "date"), + "percentageNum": (rate, "percent")}[leaf] + truth[fid] = Expectation(value, tol, "incapacite") + elif struct.endswith("diagnosisS1Struct") and leaf == "code" \ + and coding.get("/".join(parts[:-1])) != "CHOP": + if diagnosis_rank < len(scenario.diagnostics): + truth[fid] = Expectation(scenario.diagnostics[diagnosis_rank][0], "alnum", "diagnostic") + diagnosis_rank += 1 + elif struct == "employerS1Address" and leaf in ("condensedName", "companyName"): + truth[fid] = Expectation(patient.employeur, "fuzzy", "travail") + elif leaf == "profession": + truth[fid] = Expectation(patient.profession, "fuzzy", "travail") + elif struct == "lawS1Struct" and leaf == "caseDate" and scenario.accident: + truth[fid] = Expectation(scenario.accident.get("date", ""), "date", "accident") + return {k: v for k, v in truth.items() if str(v.expected or "").strip()} + + +def derive_truth(form_id: str, templates_dir: Path = TEMPLATES_DIR) -> dict[str, Expectation]: + """ + Vérité terrain d'un formulaire : les champs extraits par le pipeline dont le + scénario fixe la valeur, et que le dossier documente réellement. + """ + scenario = load_scenarios()[form_id] + template = load_template(form_id, templates_dir) + fields = prepare_fields(template) + index = SourceIndex(dossier_documents(form_id)) + truth = {} + for fid, exp in _candidates(scenario, fields, coding_systems(template)).items(): + needle = exp.expected + if exp.tolerance == "percent": + needle = re.sub(r"\D", "", needle) + if index.find(needle, whole_words=True) is not None: + truth[fid] = exp + return truth + + +# --------------------------------------------------------------------------- +# Comparaison +# --------------------------------------------------------------------------- + +def _alnum(text: str) -> str: + return re.sub(r"[^0-9a-z]", "", fold(text)) + + +def _phone(text: str) -> str: + digits = re.sub(r"\D", "", text) + if digits.startswith("0041"): + digits = "0" + digits[4:] + elif digits.startswith("41") and len(digits) == 11: + digits = "0" + digits[2:] + return digits + + +def _tokens(text: str) -> set[str]: + return set(re.findall(r"[0-9a-z]+", fold(text))) + + +def matches(expected: str, extracted: str, tolerance: str) -> bool: + """La valeur extraite équivaut-elle à la valeur attendue ?""" + if not str(extracted or "").strip(): + return False + if tolerance == "date": + return normalize_date(expected) == normalize_date(extracted) + if tolerance == "alnum": + return _alnum(expected) == _alnum(extracted) + if tolerance == "phone": + return _phone(expected) == _phone(extracted) + if tolerance == "percent": + exp, ext = re.search(r"\d+", expected), re.search(r"\d+", extracted) + return bool(exp and ext) and int(exp.group()) == int(ext.group()) + if tolerance == "fuzzy": + a, b = fold(expected), fold(extracted) + if a in b or b in a: + return True + ta, tb = _tokens(a), _tokens(b) + return bool(ta and tb) and len(ta & tb) / len(ta | tb) >= 0.6 + return fold(expected) == fold(extracted) + + +def score(truth: dict[str, Expectation], fields_payload: list[dict]) -> dict[str, Any]: + """ + Compare le résultat de /fields à la vérité terrain. + + Returns: + métriques (précision, taux de remplissage, erreurs) et détail par champ. + """ + by_id = {str(f["id"]): f for f in fields_payload} + details = [] + correct = 0 + for fid, exp in sorted(truth.items(), key=lambda kv: [int(p) for p in kv[0].split(".")]): + got = str((by_id.get(fid) or {}).get("value") or "") + ok = matches(exp.expected, got, exp.tolerance) + correct += ok + details.append({"id": fid, "category": exp.category, "expected": exp.expected, + "extracted": got, "match": ok, + "status": "ok" if ok else ("missing" if not got.strip() else "wrong")}) + filled = sum(1 for f in fields_payload if str(f.get("value") or "").strip()) + errors = sum(1 for f in fields_payload if f.get("error")) + return { + "truth_fields": len(truth), + "correct": correct, + "accuracy": round(correct / len(truth), 4) if truth else None, + "fields": len(fields_payload), + "filled": filled, + "fill_rate": round(filled / len(fields_payload), 4) if fields_payload else None, + "errors": errors, + "details": details, + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 01227aa..177cb0f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -40,6 +40,12 @@ const UI_BUILD = { builtAt: __APP_BUILT_AT__, }; +// Erreur de statut définitive : inutile de réinterroger le serveur. +class PollFatalError extends Error {} + +// Coupures tolérées pendant le suivi d'une tâche avant d'abandonner. +const MAX_POLL_FAILURES = 6; + const apiFetch = (url: string, options: RequestInit = {}) => { const headers = new Headers(options.headers || {}); if (API_KEY) headers.set("X-API-Key", API_KEY); @@ -291,6 +297,7 @@ export default function App() { const [isLoading, setIsLoading] = useState(false); const [pdfUrl, setPdfUrl] = useState(null); const [error, setError] = useState(null); + const [warnings, setWarnings] = useState([]); const [isDragging, setIsDragging] = useState(false); const [statusMessage, setStatusMessage] = useState(""); const [progress, setProgress] = useState(0); @@ -547,6 +554,8 @@ export default function App() { // Sauvegarder d'abord si modifié if (synthesisDirty) await handleSaveSynthesis(); setIsRerunning(true); + setError(null); + setWarnings([]); setPdfUrl(null); setFormFields([]); try { @@ -588,13 +597,15 @@ export default function App() { fetchStats(); }, []); - const pollStatus = async (jid: string, token: string) => { + const pollStatus = async (jid: string, token: string, failures = 0) => { try { const res = await apiFetch(`${BASE_URL}/status/${jid}`); + if (res.status === 404) throw new PollFatalError("Tâche introuvable ou expirée."); if (!res.ok) throw new Error("Impossible de lire le statut de la tâche."); const data = await res.json(); setStatusMessage(data.message); setProgress(data.progress); + setWarnings(data.warnings ?? []); if (data.status === "completed") { const pdfRes = await apiFetch(`${BASE_URL}/download/${jid}?token=${encodeURIComponent(token)}`); if (!pdfRes.ok) throw new Error("Erreur lors de la récupération du PDF."); @@ -614,13 +625,22 @@ export default function App() { } else if (data.status === "failed") { setError(data.message); setIsLoading(false); + setIsRerunning(false); setStartedAt(null); } else { setTimeout(() => pollStatus(jid, token), 2000); } } catch (err: any) { + // Une coupure passagère (tunnel, Wi-Fi, redémarrage du proxy) ne doit pas + // abandonner une tâche qui continue côté serveur : on réessaie en espaçant. + if (!(err instanceof PollFatalError) && failures < MAX_POLL_FAILURES) { + setTimeout(() => pollStatus(jid, token, failures + 1), Math.min(2000 * 2 ** failures, 15000)); + return; + } setError(err.message || "Perte de connexion avec le serveur."); setIsLoading(false); + setIsRerunning(false); + setStartedAt(null); } }; @@ -628,6 +648,7 @@ export default function App() { if (reports.length === 0 || !formId) { setError("Il manque le formulaire ou les rapports."); return; } setIsLoading(true); setError(null); + setWarnings([]); setPdfUrl(null); setProgress(0); setStatusMessage("Initialisation de la connexion..."); @@ -683,6 +704,7 @@ export default function App() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ job_id: jobId, + token: jobToken, message: userMsg, history: chatMessages.map((m) => ({ role: m.role, content: m.content })), }), @@ -855,6 +877,16 @@ export default function App() { )} + {!error && warnings.length > 0 && ( +
+ {warnings.map((w, i) => ( +
+ ⚠️ {w} +
+ ))} +
+ )} +