From f1742d7809e43c63fef4d2415efff08ef2e59a79 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:08:43 +0000 Subject: [PATCH 01/13] fix: split XFA option labels from their saved values A choice list declares its displayed labels and its saved values in two groups; the generator concatenated them into options such as ['non', 'oui', '0', '1']. The model could answer '0', and 'oui' was never translated to '1', so the radio stayed empty. Nine catalogue fields are fixed in place and the generator now reads the save="1" group apart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CAQEkcJGzQXzySYYsRCKWW --- .../Form_LAA_PremierDiagnostic_MTBI.json | 4 +- .../Form_LAA_PriseEnChargeHospitaliere.json | 4 +- .../template/Form_LAA_RapportAbrege.json | 4 +- .../template/Form_LAA_RapportInitial.json | 4 +- .../Form_LAM_RapportIntermediaire.json | 12 ++++-- .../template/Form_LAMal_RapportInitial.json | 4 +- .../template/Form_LCA_IncapaciteTravail.json | 4 +- services/orchestrator/tools/gen_template.py | 37 ++++++++++++++----- 8 files changed, 55 insertions(+), 18 deletions(-) diff --git a/services/orchestrator/template/Form_LAA_PremierDiagnostic_MTBI.json b/services/orchestrator/template/Form_LAA_PremierDiagnostic_MTBI.json index 0d9d770..a937f39 100644 --- a/services/orchestrator/template/Form_LAA_PremierDiagnostic_MTBI.json +++ b/services/orchestrator/template/Form_LAA_PremierDiagnostic_MTBI.json @@ -557,7 +557,9 @@ "acroform_name": "topmostSubform[0].page1[0].insuranceSelector[0]", "type": "choice", "options": [ - "Sélectionner l'assureur...", + "Sélectionner l'assureur..." + ], + "option_values": [ "0" ], "preset": "0" diff --git a/services/orchestrator/template/Form_LAA_PriseEnChargeHospitaliere.json b/services/orchestrator/template/Form_LAA_PriseEnChargeHospitaliere.json index 81079a1..5e08c85 100644 --- a/services/orchestrator/template/Form_LAA_PriseEnChargeHospitaliere.json +++ b/services/orchestrator/template/Form_LAA_PriseEnChargeHospitaliere.json @@ -619,7 +619,9 @@ "Unfallversicherung Stadt Zürich", "Vaudoise Générale", "Visana Services AG (UVG)", - "Zürich Versicherungsgesellschaft AG", + "Zürich Versicherungsgesellschaft AG" + ], + "option_values": [ "0", "1", "2", diff --git a/services/orchestrator/template/Form_LAA_RapportAbrege.json b/services/orchestrator/template/Form_LAA_RapportAbrege.json index 92ef326..ff1c037 100644 --- a/services/orchestrator/template/Form_LAA_RapportAbrege.json +++ b/services/orchestrator/template/Form_LAA_RapportAbrege.json @@ -287,7 +287,9 @@ "acroform_name": "topmostSubform[0].page1[0].insuranceSelector[0]", "type": "choice", "options": [ - "Sélectionner l'assureur...", + "Sélectionner l'assureur..." + ], + "option_values": [ "0" ], "preset": "0" diff --git a/services/orchestrator/template/Form_LAA_RapportInitial.json b/services/orchestrator/template/Form_LAA_RapportInitial.json index abc1a3c..d54034d 100644 --- a/services/orchestrator/template/Form_LAA_RapportInitial.json +++ b/services/orchestrator/template/Form_LAA_RapportInitial.json @@ -386,7 +386,9 @@ "acroform_name": "topmostSubform[0].page1[0].insuranceSelector[0]", "type": "choice", "options": [ - "Sélectionner l'assureur...", + "Sélectionner l'assureur..." + ], + "option_values": [ "0" ], "preset": "0" diff --git a/services/orchestrator/template/Form_LAM_RapportIntermediaire.json b/services/orchestrator/template/Form_LAM_RapportIntermediaire.json index 07e1893..8a9d82c 100644 --- a/services/orchestrator/template/Form_LAM_RapportIntermediaire.json +++ b/services/orchestrator/template/Form_LAM_RapportIntermediaire.json @@ -473,7 +473,9 @@ "type": "choice", "options": [ "non", - "oui", + "oui" + ], + "option_values": [ "0", "1" ], @@ -490,7 +492,9 @@ "type": "choice", "options": [ "non", - "oui", + "oui" + ], + "option_values": [ "0", "1" ], @@ -525,7 +529,9 @@ "type": "choice", "options": [ "non", - "oui", + "oui" + ], + "option_values": [ "0", "1" ], diff --git a/services/orchestrator/template/Form_LAMal_RapportInitial.json b/services/orchestrator/template/Form_LAMal_RapportInitial.json index 470c809..506285b 100644 --- a/services/orchestrator/template/Form_LAMal_RapportInitial.json +++ b/services/orchestrator/template/Form_LAMal_RapportInitial.json @@ -432,7 +432,9 @@ "acroform_name": "topmostSubform[0].page1[0].insuranceSelector[0]", "type": "choice", "options": [ - "Sélectionner l'assureur...", + "Sélectionner l'assureur..." + ], + "option_values": [ "0" ], "preset": "0" diff --git a/services/orchestrator/template/Form_LCA_IncapaciteTravail.json b/services/orchestrator/template/Form_LCA_IncapaciteTravail.json index ceaf0ed..879e43e 100644 --- a/services/orchestrator/template/Form_LCA_IncapaciteTravail.json +++ b/services/orchestrator/template/Form_LCA_IncapaciteTravail.json @@ -324,7 +324,9 @@ "acroform_name": "topmostSubform[0].page1[0].insuranceSelector[0]", "type": "choice", "options": [ - "Sélectionner l'assureur...", + "Sélectionner l'assureur..." + ], + "option_values": [ "0" ], "preset": "0" diff --git a/services/orchestrator/tools/gen_template.py b/services/orchestrator/tools/gen_template.py index 522f5da..f146b10 100644 --- a/services/orchestrator/tools/gen_template.py +++ b/services/orchestrator/tools/gen_template.py @@ -523,10 +523,29 @@ def _ui_kind(elem: ET.Element) -> str: return _tag(ui[0][0]) +def _item_groups(elem: ET.Element) -> tuple[list[str], list[str]]: + """ + (libellés, valeurs enregistrées) des d'un champ. + + Une liste XFA peut porter deux groupes : les libellés affichés, et + les valeurs enregistrées (`save="1"`). Les concaténer donnait des options + comme ['non', 'oui', '0', '1'] : le modèle pouvait répondre « 0 », et « oui » + n'était jamais traduit en « 1 », donc jamais coché. + """ + shown: list[str] = [] + saved: list[str] = [] + for group in _children(elem, "items"): + texts = [t.text for t in _children(group, "text") if t.text] + (saved if group.get("save") == "1" else shown).extend(texts) + if not shown: + # Un seul groupe, marqué `save` : il sert aussi de libellé. + return saved, [] + return shown, saved if len(saved) == len(shown) else [] + + def _items(elem: ET.Element) -> list[str]: - """Valeurs proposées par le champ (états d'une case, options d'une liste).""" - return [t.text for group in _children(elem, "items") - for t in _children(group, "text") if t.text] + """Valeurs proposées par le champ (états d'une case, libellés d'une liste).""" + return _item_groups(elem)[0] # Texte indicatif du gabarit Designer, pas une valeur : « ». @@ -613,7 +632,7 @@ def parse_xfa_fields(template_xml: str) -> list[dict[str, Any]]: ui = _ui_kind(elem) segments = [s.split("[")[0] for s in som_path] question, vocab_type = _describe(segments[-1], segments[:-1]) - options, option_values = _items(elem), [] + options, option_values = _item_groups(elem) field_type = UI_TO_TYPE.get(ui) if _tag(elem) == "exclGroup": options, option_values = _exclgroup_options(elem) @@ -833,11 +852,11 @@ def build_template(pdf_path: Path, keep_technical: bool = False, entries: list[dict[str, Any]] = [] reused_keys: set[str | None] = set() - # Les sections d'un template rédigé encodent un découpage métier que - # SECTION_SYNTHESIS_KEYS (prompts.py) exploite pour cibler la synthèse - # injectée dans chaque prompt. Une numérotation dérivée des pages XFA le - # détruirait : on repart donc du numéro de section existant, et les champs - # nouveaux vont dans une section créée après la dernière connue. + # Les sections d'un template rédigé encodent un découpage métier : elles + # déterminent les lots d'extraction (core/fields.batch_fields), et les IDs + # de champs en dérivent. Une numérotation dérivée des pages XFA les + # bouleverserait : on repart donc du numéro de section existant, et les + # champs nouveaux vont dans une section créée après la dernière connue. prior_sections = { int(str(f["id"]).split(".")[0]) for f in prior.values() From 2c2e39bcd303330e24ccf365371f60ba3cba0d75 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:08:43 +0000 Subject: [PATCH 02/13] fix: keep checkbox and choice states consistent across XFA and AcroForm - XFA bool fields collapsed anything but '1'/'true' to '0': 'oui' and 'On' were written unchecked while the AcroForm layer showed them checked. - Checkbox normalisation did not understand French answers. - A /Ch list received the label in /V and drew it as is; it now stores the export value and draws the label, whichever of the two it is given. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CAQEkcJGzQXzySYYsRCKWW --- services/orchestrator/core/acroform.py | 31 ++++++++- services/orchestrator/core/checkbox.py | 5 +- services/orchestrator/core/fill.py | 14 ++++- services/orchestrator/tests/__init__.py | 0 services/orchestrator/tests/conftest.py | 24 +++++++ services/orchestrator/tests/pdf_factory.py | 73 ++++++++++++++++++++++ services/orchestrator/tests/test_fill.py | 56 +++++++++++++++++ 7 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 services/orchestrator/tests/__init__.py create mode 100644 services/orchestrator/tests/conftest.py create mode 100644 services/orchestrator/tests/pdf_factory.py create mode 100644 services/orchestrator/tests/test_fill.py diff --git a/services/orchestrator/core/acroform.py b/services/orchestrator/core/acroform.py index 2a3b3e9..8b2e970 100644 --- a/services/orchestrator/core/acroform.py +++ b/services/orchestrator/core/acroform.py @@ -233,6 +233,31 @@ def _fill_radio(field_obj: pikepdf.Object, value: Any) -> bool: return True +def _choice_option(field_obj: pikepdf.Object, value: Any) -> tuple[str, str]: + """ + (valeur d'export, libellé affiché) d'une liste de choix pour `value`. + + Une liste /Ch peut déclarer ses options en paires [export, libellé] : le + formulaire attend l'export dans /V (« 1 »), le lecteur affiche le libellé + (« oui »). Écrire le libellé dans /V, ou dessiner l'export dans l'apparence, + donnait un champ incohérent ou un « 1 » à l'écran. On accepte l'un comme + l'autre en entrée. + """ + text = "" if value is None else str(value).strip() + options = field_obj.get("/Opt") + if options is None: + return text, text + target = text.casefold() + for option in options: + if isinstance(option, pikepdf.Array) and len(option) >= 2: + export, display = str(option[0]), str(option[1]) + else: + export = display = str(option) + if target in (export.strip().casefold(), display.strip().casefold()): + return export, display + return text, text + + def _fill_fields_recursive( pdf: pikepdf.Pdf, acroform: pikepdf.Object, @@ -272,7 +297,11 @@ def _fill_fields_recursive( if full_name in values_by_name and full_name not in filled: value = values_by_name[full_name] on_state = _checkbox_on_state(obj) if _is_checkbox(obj) else None - obj["/V"] = _normalize_acroform_value(value, on_state) + if str(obj.get("/FT") or "") == "/Ch": + export, value = _choice_option(obj, value) + obj["/V"] = pikepdf.String(export) + else: + obj["/V"] = _normalize_acroform_value(value, on_state) # On construit l'apparence nous-mêmes : PDFium n'honore pas /NeedAppearances. try: build_appearance(pdf, acroform, obj, value, on_state) diff --git a/services/orchestrator/core/checkbox.py b/services/orchestrator/core/checkbox.py index b9a2c91..816c93b 100644 --- a/services/orchestrator/core/checkbox.py +++ b/services/orchestrator/core/checkbox.py @@ -90,8 +90,9 @@ def discover_checkbox_paths(datasets_xml: str | Path) -> List[str]: # Checkbox Normalization # ────────────────────────────────────────────────────────────── -_TRUTHY = {"on", "true", "1", "yes", "y", "x", "checked"} -_FALSY = {"off", "false", "0", "no", "n", ""} +# Le modèle répond en français : « oui » tombait dans le repli « Off ». +_TRUTHY = {"on", "true", "1", "yes", "y", "x", "checked", "oui", "vrai", "coché", "coche"} +_FALSY = {"off", "false", "0", "no", "n", "", "non", "faux"} def _to_on_off(value: Any) -> str: diff --git a/services/orchestrator/core/fill.py b/services/orchestrator/core/fill.py index 60cd086..c1d545f 100644 --- a/services/orchestrator/core/fill.py +++ b/services/orchestrator/core/fill.py @@ -78,7 +78,19 @@ def _normalize_value(value: Any, kind: str) -> str: return "" if kind == "bool": - return "1" if value in {"1", "true", "True", True} else "0" + # La valeur arrive déjà sous la forme d'un état de la case (« 1 »/« 0 », + # « On »/« Off », selon ce que déclare le formulaire) : on la garde. + # L'ancienne règle ramenait à « 0 » tout ce qui n'était pas « 1 » ou + # « true » — « oui », « On » compris : la case restait décochée côté XFA + # quand elle était cochée côté AcroForm. + if isinstance(value, bool): + return "1" if value else "0" + text = str(value).strip() + if text.casefold() in {"true", "yes", "oui", "vrai"}: + return "1" + if text.casefold() in {"false", "no", "non", "faux"}: + return "0" + return text if kind == "int": try: diff --git a/services/orchestrator/tests/__init__.py b/services/orchestrator/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/orchestrator/tests/conftest.py b/services/orchestrator/tests/conftest.py new file mode 100644 index 0000000..dcc2432 --- /dev/null +++ b/services/orchestrator/tests/conftest.py @@ -0,0 +1,24 @@ +""" +Configuration commune des tests de l'orchestrateur. + +Les tests tournent sans GPU ni service : OCR, TEI et vLLM sont simulés par un +transport httpx (voir test_pipeline.py). Seuls pikepdf, pypdf et chromadb — +dépendances de l'orchestrateur — sont requis. +""" + +from __future__ import annotations + +import os +import sys +import tempfile +from pathlib import Path + +ORCHESTRATOR_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ORCHESTRATOR_DIR)) + +# Avant l'import de app : ses répertoires de travail sont créés au chargement. +_scratch = Path(tempfile.mkdtemp(prefix="doctorfill-tests-")) +os.environ.setdefault("DEBUG_LOG_DIR", str(_scratch / "debug")) +os.environ.setdefault("STATS_DIR", str(_scratch / "stats")) +os.environ.setdefault("API_KEY", "") +os.environ.setdefault("ANONYMIZED_TELEMETRY", "False") diff --git a/services/orchestrator/tests/pdf_factory.py b/services/orchestrator/tests/pdf_factory.py new file mode 100644 index 0000000..f912e31 --- /dev/null +++ b/services/orchestrator/tests/pdf_factory.py @@ -0,0 +1,73 @@ +""" +Fabrique de formulaires PDF minimaux pour les tests. + +Les PDF medForms ne sont pas versionnés (voir .gitignore) : on construit un +AcroForm équivalent — champs texte, case à cocher sans /AP comme chez medForms, +liste de choix à options [export, libellé] — avec pikepdf. +""" + +from __future__ import annotations + +from pathlib import Path + +import pikepdf +from pikepdf import Array, Dictionary, Name, String + +_FF_COMBO = 1 << 17 + + +def make_acroform(path: Path, fields: list[tuple[str, str, dict]]) -> Path: + """ + Args: + fields: (nom, type, options) avec type ∈ {"text", "checkbox", "combo"} ; + pour "combo", options["opt"] liste des paires (export, libellé). + """ + pdf = pikepdf.new() + pdf.add_blank_page(page_size=(595, 842)) + page = pdf.pages[0] + helv = pdf.make_indirect(Dictionary(Type=Name.Font, Subtype=Name.Type1, + BaseFont=Name.Helvetica, Encoding=Name.WinAnsiEncoding)) + zadb = pdf.make_indirect(Dictionary(Type=Name.Font, Subtype=Name.Type1, BaseFont=Name.ZapfDingbats)) + + widgets = [] + y = 800 + for name, kind, options in fields: + widget = Dictionary(Type=Name.Annot, Subtype=Name.Widget, T=String(name), + Rect=Array([50, y - 18, 400, y]), P=page.obj, + DA=String("/Helv 0 Tf 0 g")) + if kind == "text": + widget.FT = Name.Tx + elif kind == "checkbox": + widget.FT = Name.Btn + widget.V = Name.Off + widget.AS = Name.Off + elif kind == "combo": + widget.FT = Name.Ch + widget.Ff = _FF_COMBO + widget.Opt = Array([Array([String(e), String(d)]) for e, d in options["opt"]]) + widgets.append(pdf.make_indirect(widget)) + y -= 30 + + page.obj.Annots = Array(widgets) + pdf.Root.AcroForm = Dictionary(Fields=Array(widgets), DR=Dictionary(Font=Dictionary(Helv=helv, ZaDb=zadb)), + DA=String("/Helv 0 Tf 0 g")) + pdf.save(str(path)) + return path + + +def read_fields(path: Path) -> dict[str, dict]: + """Nom → {V, AS, has_ap, ap_text} de chaque champ d'un PDF rempli.""" + out = {} + with pikepdf.open(str(path)) as pdf: + for field in pdf.Root.AcroForm.Fields: + ap = field.get("/AP") + ap_text = "" + if ap is not None and isinstance(ap.get("/N"), pikepdf.Stream): + ap_text = ap.N.read_bytes().decode("latin-1") + out[str(field.T)] = { + "V": str(field.get("/V")) if field.get("/V") is not None else None, + "AS": str(field.get("/AS")) if field.get("/AS") is not None else None, + "has_ap": ap is not None, + "ap_text": ap_text, + } + return out diff --git a/services/orchestrator/tests/test_fill.py b/services/orchestrator/tests/test_fill.py new file mode 100644 index 0000000..7a0aece --- /dev/null +++ b/services/orchestrator/tests/test_fill.py @@ -0,0 +1,56 @@ +"""Écriture des valeurs dans le PDF : AcroForm, datasets XFA, cases à cocher.""" + +from core.acroform import fill_acroform +from core.checkbox import normalize_checkboxes +from core.fill import update_datasets +from tests.pdf_factory import make_acroform, read_fields + + +def test_fill_acroform_text_checkbox_and_choice(tmp_path): + blank = make_acroform(tmp_path / "blank.pdf", [ + ("lastName", "text", {}), + ("urgent", "checkbox", {}), + ("notUrgent", "checkbox", {}), + ("ask4Hospitalization", "combo", {"opt": [("0", "non"), ("1", "oui")]}), + ("reason", "combo", {"opt": [("M", "Maladie"), ("A", "Accident")]}), + ]) + out = tmp_path / "out.pdf" + filled = fill_acroform(blank, { + "lastName": "Dupont", "urgent": "1", "notUrgent": "0", + "ask4Hospitalization": "1", "reason": "Accident", + }, out) + assert filled == {"lastName", "urgent", "notUrgent", "ask4Hospitalization", "reason"} + + fields = read_fields(out) + assert fields["lastName"]["V"] == "Dupont" and "(Dupont) Tj" in fields["lastName"]["ap_text"] + assert fields["urgent"]["V"] == "/On" and fields["urgent"]["AS"] == "/On" + assert fields["notUrgent"]["V"] == "/Off" and fields["notUrgent"]["AS"] == "/Off" + # Export dans /V, libellé à l'écran — dans les deux sens d'appariement. + assert fields["ask4Hospitalization"]["V"] == "1" + assert "(oui) Tj" in fields["ask4Hospitalization"]["ap_text"] + assert fields["reason"]["V"] == "A" + assert "(Accident) Tj" in fields["reason"]["ap_text"] + + +def test_update_datasets_keeps_checkbox_states(tmp_path): + src = tmp_path / "base.xml" + src.write_text( + '' + "
Off
" + "
", encoding="utf-8") + dst = tmp_path / "filled.xml" + fields = [{"xml_path": "form/page1/urgent", "type": "bool"}, + {"xml_path": "form/page1/hosp", "type": "bool"}] + update_datasets(src, {"form/page1/urgent": "On", "form/page1/hosp": "oui", + "form/page1/phone[1]": "079 000 00 00"}, dst, fields) + xml = dst.read_text(encoding="utf-8") + # « On » n'est plus ramené à « 0 », « oui » coche la case. + assert "On" in xml + assert "1" in xml + assert "079 000 00 00" in xml.replace("", "") + + +def test_normalize_checkboxes_understands_french(): + values = {"a": "oui", "b": "non", "c": "Dupont"} + normalize_checkboxes(values, ["a", "b"]) + assert values == {"a": "On", "b": "Off", "c": "Dupont"} From 9752a65886aa6a448f078db5eea0360afe52c783 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:08:43 +0000 Subject: [PATCH 03/13] feat: submit every fillable field with an unambiguous question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A preset that is only the blank state of a field (unchecked box, yes/no question on 'non', counter at 0) no longer excludes it from extraction: 91 catalogue fields were never filled. - Repeated structures get their context and rank ('période d'incapacité de travail n°2 sur 4'): 177 fields shared identical questions. - Occurrences of a repeated structure stay in the same batch. - Values are normalised before display and fill: not-found answers become empty, dates JJ.MM.AAAA, yes/no canonical, option labels to export values. - Chosen answers (yes/no, options) are grounded by their quote only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CAQEkcJGzQXzySYYsRCKWW --- services/orchestrator/core/fields.py | 556 +++++++++++++++++++++ services/orchestrator/core/provenance.py | 14 +- services/orchestrator/tests/test_fields.py | 219 ++++++++ 3 files changed, 788 insertions(+), 1 deletion(-) create mode 100644 services/orchestrator/core/fields.py create mode 100644 services/orchestrator/tests/test_fields.py diff --git a/services/orchestrator/core/fields.py b/services/orchestrator/core/fields.py new file mode 100644 index 0000000..ea2ae77 --- /dev/null +++ b/services/orchestrator/core/fields.py @@ -0,0 +1,556 @@ +""" +core/fields.py — Préparation des champs d'un template, avant et après le modèle. + +Avant l'appel LLM : quels champs soumettre, comment formuler leur question pour +qu'elle soit univoque, et comment les regrouper en lots cohérents. +Après : ramener la réponse du modèle à une valeur que le formulaire accepte. + +Tout est déterministe et sans dépendance réseau : c'est la partie du pipeline +qu'on peut tester entièrement hors ligne. +""" + +from __future__ import annotations + +import logging +import re +import unicodedata +from datetime import date +from typing import Any, Iterable + +logger = logging.getLogger(__name__) + + +def fold(text: str) -> str: + """Forme comparable : minuscules, sans accents, espaces réduits.""" + decomposed = unicodedata.normalize("NFKD", str(text).casefold()) + stripped = "".join(c for c in decomposed if not unicodedata.combining(c)) + return " ".join(stripped.split()) + + +# --------------------------------------------------------------------------- +# Quels champs soumettre au modèle +# --------------------------------------------------------------------------- + +def _is_yes_no(labels: Iterable[str]) -> bool: + folded = [fold(label) for label in labels] + return any(f.startswith("oui") for f in folded) and any(f.startswith("non") for f in folded) + + +def is_blank_default(field: dict) -> bool: + """ + Le `preset` d'un champ n'est-il que l'état vierge du formulaire ? + + `preset` recopie la valeur que le PDF vierge porte déjà. Pour un bloc adresse + ou un régime d'assurance, c'est une donnée de l'éditeur qui fait autorité. + Mais une case décochée (« /Off »), une question oui/non positionnée sur + « non » ou un compteur à 0 ne disent rien : c'est l'état par défaut d'un + formulaire que personne n'a encore rempli. Les tenir pour acquis écartait de + l'extraction 79 cases à cocher et une dizaine de questions fermées — des + champs cliniques (urgence, hospitalisation, rechute…) jamais remplis. + """ + preset = str(field.get("preset") or "").strip() + if not preset: + return False + state = preset.lstrip("/").strip().casefold() + kind = field.get("type") + if kind == "bool": + return state in {"off", "0", "no", "non", "false"} + if kind == "int": + # « /0 » est un état de bouton, pas un nombre : on n'y touche pas. + return preset == "0" + if kind == "choice": + labels = field.get("options") or [] + if not _is_yes_no(labels): + return False + values = field.get("option_values") or [] + for index, label in enumerate(labels): + if fold(label).startswith("non"): + if state == fold(label): + return True + if index < len(values) and state == str(values[index]).casefold(): + return True + return False + + +def extractable_fields(template: dict) -> list[dict]: + """ + Champs à soumettre au modèle. + + Écarte les champs `computed` (déduits du formulaire lui-même) et les champs + `preset` : l'éditeur du formulaire les a déjà renseignés (adresse de l'office + destinataire, code EAN, données de routage) et ils font autorité. Les + extraire produisait des documents incohérents — un formulaire AI adressé à + « Helvetia Santé SA » parce que le dossier mentionnait cette caisse, à + l'adresse de l'Office AI restée en dessous. + + Exception : un `preset` qui n'est que l'état vierge du champ (voir + `is_blank_default`) reste à remplir. + """ + return [f for f in template.get("fields", []) + if "id" in f + and str(f.get("question") or "").strip() + and "computed" not in f + and (not f.get("preset") or is_blank_default(f))] + + +# --------------------------------------------------------------------------- +# Questions univoques +# --------------------------------------------------------------------------- + +# Conteneurs medForms dont les occurrences successives portent des champs de même +# question. Le libellé nomme ce que chaque occurrence représente ; l'ordre dit au +# modèle comment les répartir. +STRUCT_CONTEXT: dict[str, tuple[str, str]] = { + "unemployabilityS1Struct": ("période d'incapacité de travail", + "de la plus ancienne à la plus récente"), + "diagnosisS1Struct": ("diagnostic", "du principal au plus secondaire"), + "treatmentS1Struct": ("traitement", "ordre chronologique"), + "documentS1Struct": ("document annexé", "ordre des documents"), + "therapyStruct": ("thérapie", "ordre chronologique"), + "anamnesisStruct": ("anamnèse", "ordre chronologique"), +} + +_INDEX = re.compile(r"\[\d+\]$") + + +def _segments(xml_path: str | None) -> list[str]: + return [s for s in str(xml_path or "").split("/") if s] + + +def _canonical_struct(segment: str) -> str: + """`bdiagnosisS1Struct[2]` → `diagnosisS1Struct` : même structure, préfixe d'ordre.""" + bare = _INDEX.sub("", segment) + for known in STRUCT_CONTEXT: + if bare.endswith(known): + return known + return bare + + +def _container(field: dict) -> str: + """Chemin du conteneur d'un champ, indices et préfixes d'ordre effacés.""" + parts = _segments(field.get("xml_path"))[:-1] + if not parts: + return "" + return "/".join([_INDEX.sub("", p) for p in parts[:-1]] + [_canonical_struct(parts[-1])]) + + +def _struct_of(field: dict) -> str: + parts = _segments(field.get("xml_path")) + return _canonical_struct(parts[-2]) if len(parts) >= 2 else "" + + +# Consignes de format dans une question : utiles au modèle, parasites pour la +# recherche sémantique (« Répondre UNIQUEMENT par … AG, AI, AR, BE… »). +_BRACKETS = re.compile(r"\[[^\]]*\]") +_ANSWER_HINT = re.compile(r"\b(?:Répondre|Reprendre|Répondez)\b.*$", re.IGNORECASE | re.DOTALL) + + +def retrieval_text(question: str) -> str: + """Question débarrassée de ses consignes de format, pour l'embedding et le rerank.""" + text = _ANSWER_HINT.sub("", _BRACKETS.sub("", question)) + return " ".join(text.split()) or question + + +def contextualize(fields: list[dict]) -> list[dict]: + """ + Rend chaque question univoque au sein du formulaire. + + Le vocabulaire medForms produit la même question pour les occurrences + successives d'une structure : « Quelle est la date de début de la période + concernée ? » quatre fois pour quatre périodes d'incapacité, deux fois + « Quel est le numéro de téléphone du patient ? » pour `phone` et `phone[1]`. + 177 champs du catalogue sont dans ce cas. Le modèle n'avait aucun moyen de + savoir laquelle il remplissait : il répétait la même valeur partout ou + laissait tout vide. + + On ajoute à ces questions le contexte que porte le chemin XFA : la structure + (« période d'incapacité de travail ») et le rang de l'occurrence. + + Returns: + copies des champs, enrichies de `prompt_question` (pour le modèle) et + `retrieval_query` (pour la recherche sémantique). + """ + by_question: dict[str, list[int]] = {} + for index, field in enumerate(fields): + by_question.setdefault(fold(field.get("question", "")), []).append(index) + + prepared = [dict(f) for f in fields] + for indexes in by_question.values(): + base = prepared[indexes[0]] + if len(indexes) == 1: + base["prompt_question"] = base.get("question", "") + base["retrieval_query"] = retrieval_text(base.get("question", "")) + continue + + # Rang calculé par structure : un `beginDate` de traitement ne partage pas + # la numérotation des périodes d'incapacité qui ont la même question. + by_struct: dict[str, list[int]] = {} + for i in indexes: + by_struct.setdefault(_container(prepared[i]), []).append(i) + + for members in by_struct.values(): + for rank, i in enumerate(members, start=1): + field = prepared[i] + question = field.get("question", "") + label, order = STRUCT_CONTEXT.get(_struct_of(field), ("", "")) + total = len(members) + if label and total > 1: + hint = f"{label} n°{rank} sur {total} — {order} ; vide s'il n'y en a pas de n°{rank}" + elif label: + hint = label + elif total > 1: + hint = (f"occurrence n°{rank} sur {total} de cette question — une valeur " + f"différente par occurrence ; vide s'il n'y en a pas de n°{rank}") + else: + hint = "" + field["prompt_question"] = f"{question} (Contexte : {hint}.)" if hint else question + field["retrieval_query"] = retrieval_text(question) + (f" — {label}" if label else "") + return prepared + + +def prepare_fields(template: dict) -> list[dict]: + """Champs à extraire, prêts pour la recherche et le prompt.""" + return contextualize(extractable_fields(template)) + + +# --------------------------------------------------------------------------- +# Lots d'extraction +# --------------------------------------------------------------------------- + +def batch_fields(fields: list[dict], max_batch_size: int = 8, + max_family_size: int = 32) -> list[list[dict]]: + """ + Regroupe les champs en lots pour l'appel LLM. + + Par section d'abord (préfixe de l'ID), puis en gardant ensemble les + occurrences d'une même structure répétée : les quatre périodes d'incapacité + doivent être réparties dans un seul appel, sans quoi deux appels distincts + attribuent la même période au n°1 et au n°2. + """ + question_count: dict[str, int] = {} + for f in fields: + key = fold(f.get("question", "")) + question_count[key] = question_count.get(key, 0) + 1 + + # section → familles, dans l'ordre d'apparition + sections: dict[str, dict[str, list[dict]]] = {} + for f in fields: + section = str(f["id"]).split(".")[0] + repeated = question_count[fold(f.get("question", ""))] > 1 and _container(f) + family = f"family:{_container(f)}" if repeated else f"field:{f['id']}" + sections.setdefault(section, {}).setdefault(family, []).append(f) + + batches: list[list[dict]] = [] + for families in sections.values(): + current: list[dict] = [] + for members in families.values(): + if len(members) > 1: + if current: + batches.append(current) + current = [] + for i in range(0, len(members), max_family_size): + batches.append(members[i:i + max_family_size]) + continue + current.extend(members) + if len(current) >= max_batch_size: + batches.append(current) + current = [] + if current: + batches.append(current) + return batches + + +# --------------------------------------------------------------------------- +# Normalisation des valeurs produites par le modèle +# --------------------------------------------------------------------------- + +# Réponses qui disent « je n'ai pas trouvé » au lieu de laisser la chaîne vide. +# Écrites telles quelles, elles remplissaient le formulaire de « Non mentionné ». +_NULL_LIKE = re.compile( + r"^(?:null|none|nil|nan|n/?a|n\.?c\.?|n\.?d\.?|inconnue?s?|\?+|[-–—_.… ]*" + r"|(?:aucune |pas d')?information(?:s)?(?: non disponibles?| absentes?| manquantes?)?" + r"|non (?:mentionn|precis|renseign|specifi|disponible|communiqu|indiqu|document|trouv" + r"|applicable|connu|fourni|determin|rapport|stipul|detaill)\w*(?:\W.*)?" + r"|(?:pas|rien) (?:de donnee|d'information|mentionne|precise|trouve|indique)\w*(?:\W.*)?" + r"|(?:information|donnee)s? (?:non|pas) (?:disponible|mentionne|precise|trouve|fourni)\w*(?:\W.*)?)$" +) +# Exemples de format recopiés à la place d'une valeur. +_PLACEHOLDER = re.compile(r"^<[^<>]*>$|\b(?:jj|dd)\W?mm\W?(?:aaaa|yyyy|aa|yy)\b|x{3,}|^\.\.\.$") + + +def is_null_like(value: str) -> bool: + folded = fold(value) + return not folded or bool(_NULL_LIKE.match(folded)) or bool(_PLACEHOLDER.search(folded)) + + +def coerce_text(value: Any) -> str: + """Ramène n'importe quelle valeur JSON à une chaîne.""" + if value is None: + return "" + if isinstance(value, bool): + return "oui" if value else "non" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + if isinstance(value, (list, tuple)): + return "\n".join(t for t in (coerce_text(v).strip() for v in value) if t) + if isinstance(value, dict): + for key in ("value", "valeur", "text"): + if key in value: + return coerce_text(value[key]) + return "\n".join(f"{k} : {coerce_text(v)}" for k, v in value.items() if coerce_text(v)) + return str(value) + + +_MONTHS = { + "janvier": 1, "janv": 1, "jan": 1, "fevrier": 2, "fevr": 2, "fev": 2, "mars": 3, + "avril": 4, "avr": 4, "mai": 5, "juin": 6, "juillet": 7, "juil": 7, "aout": 8, + "septembre": 9, "sept": 9, "sep": 9, "octobre": 10, "oct": 10, "novembre": 11, + "nov": 11, "decembre": 12, "dec": 12, +} +_DATE_NUMERIC = re.compile(r"\b(\d{1,2})[./-](\d{1,2})[./-](\d{4}|\d{2})\b") +_DATE_ISO = re.compile(r"\b(\d{4})-(\d{1,2})-(\d{1,2})\b") +_DATE_WORDS = re.compile(r"\b(\d{1,2})(?:er)?\s+([a-z]+)\.?\s+(\d{4})\b") + + +def _format_date(day: int, month: int, year: int) -> str | None: + try: + date(year, month, day) + except ValueError: + return None + return f"{day:02d}.{month:02d}.{year:04d}" + + +def normalize_date(value: str) -> str: + """ + Ramène une date au format JJ.MM.AAAA demandé par les formulaires. + + Accepte « 2025-03-15 », « 15/3/2025 », « 15 mars 2025 », et une date seule + noyée dans du texte (« dès le 15.03.2025 »). Plusieurs dates, ou aucune + date reconnue : la valeur est rendue telle quelle. + """ + folded = fold(value) + found: list[str] = [] + for m in _DATE_ISO.finditer(folded): + found.append(_format_date(int(m.group(3)), int(m.group(2)), int(m.group(1))) or "") + without_iso = _DATE_ISO.sub(" ", folded) + for m in _DATE_NUMERIC.finditer(without_iso): + year = int(m.group(3)) + if len(m.group(3)) == 2: + pivot = date.today().year % 100 + 1 + year += 2000 if year <= pivot else 1900 + found.append(_format_date(int(m.group(1)), int(m.group(2)), year) or "") + for m in _DATE_WORDS.finditer(folded): + month = _MONTHS.get(m.group(2)) + if month: + found.append(_format_date(int(m.group(1)), month, int(m.group(3))) or "") + distinct = {d for d in found if d} + if len(distinct) == 1 and len(found) == 1: + return distinct.pop() + return value + + +_TRUE_WORDS = {"oui", "yes", "true", "vrai", "1", "on", "x", "coche", "checked", "y", "o"} +_FALSE_WORDS = {"non", "no", "false", "faux", "0", "off", "n", "decoche", "unchecked"} + + +def normalize_bool(value: str) -> str: + """« oui », « non », ou "" si la réponse ne tranche pas.""" + folded = fold(value).rstrip(".!") + if folded in _TRUE_WORDS or re.match(r"oui\b", folded): + return "oui" + if folded in _FALSE_WORDS or re.match(r"non\b", folded): + return "non" + return "" + + +def normalize_sex(value: str) -> str: + folded = fold(value).rstrip(".") + if folded in {"f", "feminin", "femme", "female", "madame", "mme", "mlle", "fille"} \ + or folded.startswith(("femin", "femme", "madame")): + return "F" + if folded in {"m", "masculin", "homme", "male", "h", "monsieur", "mr", "garcon"} \ + or folded.startswith(("mascul", "homme", "monsieur")): + return "M" + return value + + +def normalize_value(raw: Any, field: dict | None) -> str: + """ + Valeur prête à être écrite, selon le type déclaré dans le template. + + Une réponse qui équivaut à « rien trouvé » devient "" : le champ reste vide, + ce qui est le seul état honnête. + """ + value = coerce_text(raw).strip() + if not value: + return "" + field = field or {} + kind = field.get("type") + options = field.get("options") or [] + + # Une option peut ressembler à une non-réponse (« Inconnue ») : la vérifier d'abord. + if kind == "choice" and any(fold(o) == fold(value) for o in options): + return next(o for o in options if fold(o) == fold(value)) + if is_null_like(value): + return "" + + if kind == "bool": + return normalize_bool(value) + if kind == "sex": + return normalize_sex(value) + if kind == "date": + return normalize_date(value) + if kind == "percent": + match = re.search(r"\d+(?:[.,]\d+)?", value.replace("%", "")) + return match.group(0).replace(",", ".") if match else value + if kind == "int": + match = re.search(r"\d+", value) + return match.group(0) if match else value + return value + + +def resolve_option(value: str, field: dict) -> str: + """ + Traduit le libellé choisi par le modèle en valeur d'export du formulaire. + + Les groupes de boutons radio medForms exportent un index (« 0 », « 1 », …) + et non le libellé affiché ; `option_values` porte la correspondance. On + accepte aussi la valeur d'export elle-même et un libellé abrégé + (« oui » pour « oui (= Major RF) ») s'il ne désigne qu'une option. + + Un libellé inconnu est laissé tel quel : le remplissage laissera alors le + groupe vide plutôt que de cocher au hasard. + """ + options = field.get("options") or [] + values = field.get("option_values") or [] + if not options: + return value + exported = values if len(values) == len(options) else options + target = fold(value) + + for label, export in zip(options, exported): + if fold(label) == target: + return export + if values: + for export in values: + if fold(export) == target: + return export + prefixed = [export for label, export in zip(options, exported) + if re.match(rf"{re.escape(target)}(?:\W|$)", fold(label))] + if target and len(prefixed) == 1: + return prefixed[0] + return value + + +def bool_item(value: str, field: dict, truthy: bool) -> str: + """ + Valeur XFA d'une case à cocher : l'état déclaré par le formulaire. + + Les cases medForms n'utilisent pas toutes le même couple : « On/Off » pour + certaines, « 1/0 » pour d'autres. `options` porte, dans l'ordre, les états + coché puis décoché lus dans le XFA. + """ + options = field.get("options") or [] + if len(options) >= 2: + return options[0] if truthy else options[1] + return "1" if truthy else "0" + + +# --------------------------------------------------------------------------- +# Du résultat d'extraction aux valeurs du formulaire +# --------------------------------------------------------------------------- + +def computed_values(template: dict) -> list[tuple[dict, str]]: + """ + Champs dont la valeur se déduit du formulaire lui-même. + + Le bloc adresse du destinataire en est le seul cas aujourd'hui : il est la + version visible de champs structurés déjà renseignés par l'éditeur, mais + masqués dans la mise en page. Le demander au modèle produisait des adresses + inventées — un numéro de rue emprunté au patient, un code postal fabriqué. + """ + return [(f, f["computed"]) for f in template.get("fields", []) if f.get("computed")] + + +def canton_recipient(template: dict, results: list[dict]) -> dict[str, str]: + """ + Destinataire correspondant au canton de traitement extrait. + + Un formulaire AI s'adresse à l'office AI *du canton du patient*. Le gabarit + porte cette table et l'applique par script — mais le script ne s'exécute + que dans un lecteur XFA, jamais dans le PDF qu'on livre. Sans ça, un dossier + neuchâtelois partait à l'office de Berne, l'adresse par défaut du vierge. + + Canton absent ou inconnu : on ne renvoie rien et le formulaire garde ses + valeurs d'origine — mieux vaut le défaut du gabarit qu'un office arbitraire. + + Returns: + nom de champ → valeur, à écrire tel quel. + """ + table = template.get("_recipient_by_canton") + if not table: + return {} + canton_ids = {str(f["id"]) for f in template.get("fields", []) + if f.get("name") == "treatmentCanton"} + for res in results: + if str(res.get("id")) not in canton_ids: + continue + valeur = str((res.get("result") or {}).get("value") or "").strip().upper() + if valeur in table: + logger.info("Destinataire dérivé du canton %s", valeur) + return table[valeur] + if valeur: + logger.warning("Canton %r hors de la table du formulaire", valeur) + return {} + + +def collect_form_values(template: dict, results: list[dict]) -> tuple[dict[str, str], dict[str, str]]: + """ + Valeurs à écrire dans le PDF, par chemin XFA et par nom AcroForm. + + Réunit, dans cet ordre de priorité croissante : les valeurs extraites, les + champs calculés, puis le destinataire dérivé du canton. Partagé par le + pipeline et le re-run, qui l'avaient chacun recopié — et le re-run avait + perdu en route les deux dernières étapes. + """ + by_id = {str(f["id"]): f for f in template.get("fields", []) if "id" in f} + xfa_values: dict[str, str] = {} + acro_values: dict[str, str] = {} + + def _put(f_def: dict, xfa_value: str, acro_value: str) -> None: + if f_def.get("xml_path"): + xfa_values[f_def["xml_path"]] = xfa_value + if f_def.get("acroform_name"): + acro_values[f_def["acroform_name"]] = acro_value + + for res in results: + payload = res.get("result") + if not isinstance(payload, dict): + continue + f_def = by_id.get(str(res.get("id"))) + if f_def is None: + continue + value = normalize_value(payload.get("value"), f_def) + if not value: + continue + kind = f_def.get("type") + if kind == "bool": + truthy = value == "oui" + _put(f_def, bool_item(value, f_def, truthy), "1" if truthy else "0") + elif kind == "choice": + exported = resolve_option(value, f_def) + _put(f_def, exported, exported) + else: + _put(f_def, value, value) + + for f_def, value in computed_values(template): + _put(f_def, value, value) + + # Le destinataire dépend du canton : il écrase aussi bien les valeurs par + # défaut du gabarit que ce que le modèle aurait pu produire. + for nom, value in canton_recipient(template, results).items(): + for f_def in template.get("fields", []): + if f_def.get("name") == nom: + _put(f_def, value, value) + + return xfa_values, acro_values diff --git a/services/orchestrator/core/provenance.py b/services/orchestrator/core/provenance.py index d257766..93d08f7 100644 --- a/services/orchestrator/core/provenance.py +++ b/services/orchestrator/core/provenance.py @@ -303,13 +303,20 @@ def _locate_quote(quote: str, index: SourceIndex) -> tuple[dict | None, bool]: return None, False -def ground_value(value: str, quote: str, index: SourceIndex) -> dict[str, Any]: +def ground_value(value: str, quote: str, index: SourceIndex, + literal: bool = True) -> dict[str, Any]: """ Rattache une valeur extraite à son texte source. Trois questions, dans cet ordre : la citation existe-t-elle vraiment ? la valeur y figure-t-elle ? sinon, figure-t-elle ailleurs dans les documents ? + Args: + literal: False pour une réponse choisie plutôt que recopiée — « oui », + « non », une option de liste, un sigle de canton. La retrouver dans + le texte ne prouve rien (« ne » figure dans toute phrase négative) : + seule la citation qui la justifie compte. + Returns: {grounding, quote_verified, source_document, source_page, source_excerpt} """ @@ -331,6 +338,11 @@ def _grade(strong: str, hit: dict) -> str: """Un ancrage dans un texte dérivé ne vaut jamais mieux que « à relire ».""" return INFERRED if hit.get("derived") else strong + if not literal: + result["grounding"] = INFERRED if quote_hit is not None else UNVERIFIED + _locate(quote_hit) + return result + # Une valeur d'un seul caractère se retrouve partout par accident : on ne peut # ni l'attester ni la démentir, et le prétendre serait pire que se taire. if len(normalize(value)) < MIN_VALUE_CHARS: diff --git a/services/orchestrator/tests/test_fields.py b/services/orchestrator/tests/test_fields.py new file mode 100644 index 0000000..2c7f44d --- /dev/null +++ b/services/orchestrator/tests/test_fields.py @@ -0,0 +1,219 @@ +"""Préparation des champs et normalisation des valeurs (core/fields.py).""" + +import json +from pathlib import Path + +import pytest + +from core.fields import ( + batch_fields, + collect_form_values, + contextualize, + extractable_fields, + is_blank_default, + normalize_date, + normalize_value, + prepare_fields, + resolve_option, + retrieval_text, +) + +TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "template" + + +# --- Presets ----------------------------------------------------------------- + +@pytest.mark.parametrize("field, expected", [ + ({"type": "bool", "preset": "/Off"}, True), + ({"type": "bool", "preset": "/On"}, False), + ({"type": "int", "preset": "0"}, True), + ({"type": "int", "preset": "/0"}, False), + ({"type": "choice", "preset": "non", "options": ["non", "oui"]}, True), + ({"type": "choice", "preset": "0", "options": ["non", "oui (= Major RF)"], + "option_values": ["0", "1"]}, True), + ({"type": "choice", "preset": "1", "options": ["non", "oui"], "option_values": ["0", "1"]}, False), + ({"type": "choice", "preset": "LAA", "options": ["LAMal", "LCA", "LAA"]}, False), + ({"preset": "IV-Stelle Kanton Bern"}, False), +]) +def test_blank_default(field, expected): + assert is_blank_default(field) is expected + + +def test_extractable_fields_keeps_blank_checkboxes_and_authoritative_presets_out(): + template = {"fields": [ + {"comments": "Section 1"}, + {"id": "1.1", "question": "Urgent ?", "type": "bool", "preset": "/Off"}, + {"id": "1.2", "question": "Régime ?", "type": "choice", "preset": "LAA", + "options": ["LAA", "LCA"]}, + {"id": "1.3", "question": "Adresse office ?", "preset": "Office AI"}, + {"id": "1.4", "question": "", "computed": "x"}, + {"id": "1.5", "question": "Nom ?"}, + ]} + assert [f["id"] for f in extractable_fields(template)] == ["1.1", "1.5"] + + +def test_catalogue_exposes_blank_checkboxes(): + """Les 79 cases vierges du catalogue sont désormais soumises au modèle.""" + template = json.loads((TEMPLATE_DIR / "Form_Adressage_Angiologie.json").read_text(encoding="utf-8")) + ids = {f["id"] for f in extractable_fields(template)} + urgency = next(f for f in template["fields"] if f.get("name") == "appointmentUrgency") + assert urgency["id"] in ids + + +# --- Questions univoques ------------------------------------------------------- + +def _period_fields(): + base = "topmostSubform/page1/unemployabilityS1Struct" + fields = [] + for i, suffix in enumerate(["", "[1]", "[2]"]): + fields.append({"id": f"1.{10 + 2 * i}", "question": "Quelle est la date de début de la période concernée ?", + "xml_path": f"{base}{suffix}/beginDate", "type": "date"}) + fields.append({"id": f"1.{11 + 2 * i}", "question": "Quel est le pourcentage indiqué ?", + "xml_path": f"{base}{suffix}/percentageNum", "type": "percent"}) + return fields + + +def test_repeated_questions_get_occurrence_context(): + prepared = contextualize(_period_fields() + [ + {"id": "2.1", "question": "Quelle est la date de début de la période concernée ?", + "xml_path": "topmostSubform/page1/treatmentS1Struct/beginDate"}, + {"id": "2.2", "question": "Quel est le nom du patient ?", + "xml_path": "topmostSubform/page1/patientS1Address/lastName"}, + ]) + by_id = {f["id"]: f for f in prepared} + assert "période d'incapacité de travail n°1 sur 3" in by_id["1.10"]["prompt_question"] + assert "période d'incapacité de travail n°3 sur 3" in by_id["1.14"]["prompt_question"] + # Même question, autre structure : pas de numérotation partagée. + assert "Contexte : traitement." in by_id["2.1"]["prompt_question"] + # Question unique : inchangée. + assert by_id["2.2"]["prompt_question"] == "Quel est le nom du patient ?" + assert by_id["1.10"]["retrieval_query"].endswith("période d'incapacité de travail") + + +def test_repeated_siblings_without_known_struct(): + prepared = contextualize([ + {"id": "1.1", "question": "Téléphone du patient ?", "xml_path": "t/p/patientS1Address/phone"}, + {"id": "1.2", "question": "Téléphone du patient ?", "xml_path": "t/p/patientS1Address/phone[1]"}, + ]) + assert "occurrence n°1 sur 2" in prepared[0]["prompt_question"] + assert "occurrence n°2 sur 2" in prepared[1]["prompt_question"] + + +def test_prefixed_diagnosis_structs_share_numbering(): + prepared = contextualize([ + {"id": f"4.{i}", "question": "Quel est le libellé du diagnostic posé ?", + "xml_path": f"t/page1/{p}diagnosisS1Struct/name"} + for i, p in enumerate("abde", start=1) + ]) + assert "diagnostic n°4 sur 4" in prepared[3]["prompt_question"] + + +def test_retrieval_text_drops_format_instructions(): + q = "Quel est le canton ? Répondre UNIQUEMENT par deux lettres (ex. : NE, VD, GE)." + assert retrieval_text(q) == "Quel est le canton ?" + assert retrieval_text("Date ? [format : JJ.MM.AAAA]") == "Date ?" + + +def test_batches_keep_repeated_structures_together(): + fields = contextualize( + [{"id": f"1.{i}", "question": f"Q{i} ?", "xml_path": f"t/p/f{i}"} for i in range(1, 4)] + + _period_fields() + ) + batches = batch_fields(fields, max_batch_size=2) + period_batch = next(b for b in batches if any(f["id"] == "1.10" for f in b)) + assert {f["id"] for f in period_batch} == {f["id"] for f in _period_fields()} + assert all(len(b) <= 2 for b in batches if b is not period_batch) + + +def test_every_catalogue_form_prepares_and_batches(): + for path in sorted(TEMPLATE_DIR.glob("Form_*.json")): + template = json.loads(path.read_text(encoding="utf-8")) + fields = prepare_fields(template) + batches = batch_fields(fields) + assert sorted(f["id"] for b in batches for f in b) == sorted(f["id"] for f in fields), path.name + assert all(len(b) <= 32 for b in batches), path.name + + +# --- Valeurs ------------------------------------------------------------------------ + +@pytest.mark.parametrize("raw", [ + "Non mentionné", "non précisé dans les documents", "N/A", "null", "...", "…", "Inconnu", + "", "JJ.MM.AAAA", "756.XXXX.XXXX.XX", "Information non disponible", "-", +]) +def test_null_like_answers_become_empty(raw): + assert normalize_value(raw, {}) == "" + + +@pytest.mark.parametrize("raw", ["non", "Aucun", "Aucune allergie connue", "x"]) +def test_real_answers_survive(raw): + assert normalize_value(raw, {}) == raw + + +@pytest.mark.parametrize("raw, expected", [ + ("2025-03-15", "15.03.2025"), + ("15/3/2025", "15.03.2025"), + ("15 mars 2025", "15.03.2025"), + ("1er février 2024", "01.02.2024"), + ("dès le 07.04.2026", "07.04.2026"), + ("du 01.01.2025 au 31.01.2025", "du 01.01.2025 au 31.01.2025"), + ("31.02.2025", "31.02.2025"), +]) +def test_normalize_date(raw, expected): + assert normalize_date(raw) == expected + + +@pytest.mark.parametrize("raw, kind, expected", [ + ("Oui", "bool", "oui"), ("On", "bool", "oui"), ("1", "bool", "oui"), + ("Non, pas d'urgence", "bool", "non"), ("peut-être", "bool", ""), + ("Madame", "sex", "F"), ("Monsieur", "sex", "M"), ("masculin", "sex", "M"), + ("50 %", "percent", "50"), ("12 séances", "int", "12"), + (["Lombalgie", "HTA"], None, "Lombalgie\nHTA"), (42.0, None, "42"), (True, None, "oui"), +]) +def test_normalize_value_by_type(raw, kind, expected): + assert normalize_value(raw, {"type": kind}) == expected + + +def test_choice_option_that_looks_null_is_kept(): + assert normalize_value("Inconnue", {"type": "choice", "options": ["Maladie", "Inconnue"]}) == "Inconnue" + + +@pytest.mark.parametrize("value, expected", [ + ("oui", "1"), ("Oui", "1"), ("1", "1"), ("non", "0"), ("peut-être", "peut-être"), +]) +def test_resolve_option(value, expected): + field = {"options": ["non", "oui (= Major RF)"], "option_values": ["0", "1"]} + assert resolve_option(value, field) == expected + + +def test_collect_form_values_routes_types_and_recipient(): + template = { + "_recipient_by_canton": {"NE": {"recipientBlock": "Office AI NE"}}, + "fields": [ + {"id": "1.1", "name": "treatmentCanton", "type": "choice", "options": ["NE", "VD"], + "xml_path": "a/treatmentCanton", "acroform_name": "a.treatmentCanton"}, + {"id": "1.2", "name": "urgent", "type": "bool", "options": ["On", "Off"], + "xml_path": "a/urgent", "acroform_name": "a.urgent"}, + {"id": "1.3", "name": "ask", "type": "choice", "options": ["non", "oui"], + "option_values": ["0", "1"], "xml_path": "a/ask", "acroform_name": "a.ask"}, + {"id": "1.4", "name": "birthDate", "type": "date", "xml_path": "a/birthDate"}, + {"id": "1.5", "name": "recipientBlock", "xml_path": "a/recipientBlock", + "acroform_name": "a.recipientBlock"}, + {"id": "1.6", "name": "footer", "computed": "Pied de page", "acroform_name": "a.footer"}, + {"id": "1.7", "name": "remark", "xml_path": "a/remark"}, + ], + } + results = [ + {"id": "1.1", "result": {"value": "NE"}}, + {"id": "1.2", "result": {"value": "oui"}}, + {"id": "1.3", "result": {"value": "oui"}}, + {"id": "1.4", "result": {"value": "2024-02-01"}}, + {"id": "1.7", "result": {"value": "non mentionné"}}, + {"id": "9.9", "error": "extraction_failed"}, + ] + xfa, acro = collect_form_values(template, results) + assert xfa["a/urgent"] == "On" and acro["a.urgent"] == "1" + assert xfa["a/ask"] == "1" and acro["a.ask"] == "1" + assert xfa["a/birthDate"] == "01.02.2024" + assert xfa["a/recipientBlock"] == "Office AI NE" + assert acro["a.footer"] == "Pied de page" + assert "a/remark" not in xfa From fd2845feebe389f470ad1dab5c9bff2564ed64e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:09:34 +0000 Subject: [PATCH 04/13] feat: budget the extraction context and recover failed batches - Chunks carry their source (document, page, section) and are sized for the reranker; tiny sections are merged. - The prompt context is measured in tokens: a dossier that fits is sent whole, once, as a prefix shared by every batch; otherwise chunks are interleaved by rank so no field of a batch is left without sources. - The prompt puts the full synthesis before the documents, documents prevailing; no more '...' example for the model to copy. - Responses are constrained by a JSON schema (falls back to json_object), parsed leniently, and a failed batch is shrunk, split or re-asked instead of being lost; 4xx errors are no longer retried blindly. app.py switches to this module in a following commit; the former prompt builder stays until then. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CAQEkcJGzQXzySYYsRCKWW --- services/orchestrator/extraction.py | 419 ++++++++++++++++++ services/orchestrator/prompts.py | 128 ++++-- .../orchestrator/tests/test_extraction.py | 226 ++++++++++ 3 files changed, 728 insertions(+), 45 deletions(-) create mode 100644 services/orchestrator/extraction.py create mode 100644 services/orchestrator/tests/test_extraction.py diff --git a/services/orchestrator/extraction.py b/services/orchestrator/extraction.py new file mode 100644 index 0000000..6fe8466 --- /dev/null +++ b/services/orchestrator/extraction.py @@ -0,0 +1,419 @@ +""" +extraction.py — Découpage des documents, contexte du prompt et appel LLM d'extraction. + +Trois causes de champs vides ou en erreur y sont traitées : + +- **Contexte hors budget.** Les extraits étaient comptés, pas mesurés : 24 + extraits de 800 mots dépassent la fenêtre de 32k tokens de vLLM, qui répond + 400 — et les 7 champs du lot échouaient après trois tentatives identiques. + Le contexte est désormais construit contre un budget en tokens. +- **Extraits mal répartis.** Les extraits d'un lot étaient concaténés champ par + champ puis tronqués : les derniers champs du lot n'avaient souvent plus aucun + extrait. Ils sont maintenant entrelacés par rang. +- **Réponses incomplètes.** Un JSON tronqué, un ID oublié ou une valeur rendue + en chaîne plutôt qu'en objet perdaient le champ. La réponse est contrainte + par un schéma JSON, lue avec tolérance, et un lot qui échoue est scindé puis + rejoué plutôt qu'abandonné. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +from typing import Any, Callable, Dict, List + +import httpx + +from core.fields import coerce_text + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Budget de tokens +# --------------------------------------------------------------------------- + +# À garder aligné sur --max-model-len de vLLM (docker-compose). +LLM_MAX_MODEL_LEN = int(os.getenv("LLM_MAX_MODEL_LEN", "32768")) +# Plafond de génération d'un lot. Un lot de 28 champs (quatre périodes +# d'incapacité) produit ~2 000 tokens de JSON ; 2048 tronquait les gros lots. +MAX_TOKENS_EXTRACT = int(os.getenv("MAX_TOKENS_EXTRACT", "4096")) +# Marge pour l'écart entre l'estimation et le tokenizer réel. +PROMPT_SAFETY_TOKENS = 1024 +# Dossier transmis en entier au modèle s'il tient sous ce seuil : plus aucune +# information ne dépend de la qualité du retrieval, et le préfixe commun à tous +# les lots n'est calculé qu'une fois par vLLM. +FULL_CONTEXT_MAX_TOKENS = int(os.getenv("FULL_CONTEXT_MAX_TOKENS", "16000")) +# Au-delà : extraits sélectionnés par le retrieval, dans ce budget par lot. +RAG_CONTEXT_TOKENS = int(os.getenv("RAG_CONTEXT_TOKENS", "9000")) +# Délai d'un appel d'extraction. Sous charge (24 requêtes en vol sur le GB10), +# 180 s ne suffisaient pas : le délai expirait, la requête était rejouée, et la +# charge augmentait d'autant. +LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "600")) +MAX_LLM_RETRIES = int(os.getenv("MAX_LLM_RETRIES", "3")) + + +def estimate_tokens(text: str) -> int: + """ + Estimation prudente du nombre de tokens. + + Le français médical (accents, nombres, codes) tourne autour de 3,5 + caractères par token avec le tokenizer Qwen ; on compte 3 pour ne jamais + sous-estimer — c'est la sous-estimation qui provoque les erreurs 400. + """ + return len(text) // 3 + 1 + + +# --------------------------------------------------------------------------- +# Découpage des documents +# --------------------------------------------------------------------------- + +_HEADING = re.compile(r"^(#{1,3})\s+(.+?)\s*$", re.MULTILINE) +_PAGE_TITLE = re.compile(r"^page\s+(\d+)$", re.IGNORECASE) + +CHUNK_MAX_WORDS = int(os.getenv("CHUNK_MAX_WORDS", "300")) +CHUNK_OVERLAP_WORDS = int(os.getenv("CHUNK_OVERLAP_WORDS", "50")) +CHUNK_MIN_WORDS = int(os.getenv("CHUNK_MIN_WORDS", "40")) + + +def chunk_document(filename: str, markdown: str, max_words: int = CHUNK_MAX_WORDS, + overlap: int = CHUNK_OVERLAP_WORDS, min_words: int = CHUNK_MIN_WORDS) -> List[str]: + """ + Découpe un document en extraits autonomes. + + Chaque extrait porte en tête sa source — document, page, section. Seul le + premier extrait d'un document la portait : les suivants arrivaient au + modèle sans qu'il sache de quel rapport, ni de quelle date, ils venaient. + Un « Téléphone : 021 … » isolé ne dit pas s'il s'agit du patient ou du + médecin ; « [Source : 01_fiche.pdf — Médecin traitant] » le dit. + + Les sections trop courtes sont fusionnées avec la suivante (un titre seul + n'est qu'un embedding parasite), les trop longues découpées avec + chevauchement, à une taille que le reranker évalue sans la tronquer. + """ + sections: List[tuple[int | None, str, str]] = [] # (page, titre, texte) + page: int | None = None + positions = [(m.start(), m.group(2).strip()) for m in _HEADING.finditer(markdown)] + if not positions or positions[0][0] > 0: + positions.insert(0, (0, "")) + for index, (start, title) in enumerate(positions): + end = positions[index + 1][0] if index + 1 < len(positions) else len(markdown) + body = markdown[start:end].strip() + page_match = _PAGE_TITLE.match(title) + if page_match: + # « ## Page 3 » passe dans l'en-tête de l'extrait, pas dans son texte. + page = int(page_match.group(1)) + title = "" + body = body.split("\n", 1)[1].strip() if "\n" in body else "" + if body: + sections.append((page, title, body)) + + # Fusion des sections trop courtes avec la suivante, sur la même page. + merged: List[tuple[int | None, str, str]] = [] + for sec in sections: + if merged: + prev_page, prev_title, prev_body = merged[-1] + if len(prev_body.split()) < min_words and prev_page == sec[0]: + merged[-1] = (prev_page, prev_title or sec[1], prev_body + "\n\n" + sec[2]) + continue + merged.append(sec) + + chunks: List[str] = [] + step = max(1, max_words - overlap) + for sec_page, title, body in merged: + label = filename + if sec_page is not None: + label += f" — page {sec_page}" + if title: + label += f" — {title}" + header = f"[Source : {label}]" + words = body.split() + if len(words) <= max_words: + chunks.append(f"{header}\n{body}") + continue + for i in range(0, len(words), step): + part = words[i:i + max_words] + chunks.append(f"{header}\n{' '.join(part)}") + if i + max_words >= len(words): + break + return chunks + + +def full_documents_text(documents: List[Dict[str, str]]) -> str: + """Texte intégral du dossier, un bloc par document, dans un ordre stable.""" + blocks = [] + for doc in sorted(documents, key=lambda d: d.get("filename", "")): + text = (doc.get("markdown") or "").strip() + if text: + blocks.append(f"=== DOCUMENT : {doc.get('filename', 'document')} ===\n{text}") + return "\n\n".join(blocks) + + +def build_rag_context(fields: List[Dict], field_chunks: Dict[str, List[str]], + budget_tokens: int) -> str: + """ + Extraits d'un lot, entrelacés par rang et bornés par un budget en tokens. + + Le meilleur extrait de chaque champ passe avant le second de quiconque : + aucun champ du lot ne se retrouve sans source parce que ses voisins ont + consommé le budget. + """ + lists = [field_chunks.get(str(f["id"]), []) for f in fields] + seen: set = set() + selected: List[str] = [] + used = 0 + depth = max((len(lst) for lst in lists), default=0) + for rank in range(depth): + for lst in lists: + if rank >= len(lst) or lst[rank] in seen: + continue + cost = estimate_tokens(lst[rank]) + if used + cost > budget_tokens: + continue + seen.add(lst[rank]) + selected.append(lst[rank]) + used += cost + return "\n\n---\n\n".join(selected) + + +# --------------------------------------------------------------------------- +# Appel LLM +# --------------------------------------------------------------------------- + +class ContextOverflow(Exception): + """Le prompt dépasse la fenêtre du modèle : rejouer tel quel est inutile.""" + + +class TruncatedResponse(Exception): + """La génération a atteint max_tokens : le JSON est incomplet.""" + + +class SchemaUnsupported(Exception): + """Le serveur refuse `response_format: json_schema`.""" + + +# Le schéma JSON contraint la génération (vLLM, décodage guidé) : plus d'ID +# oublié ni de clé déformée. Désactivé au premier refus du serveur. +_schema_supported = os.getenv("LLM_JSON_SCHEMA", "true").lower() == "true" + + +def response_schema(fields: List[Dict]) -> Dict[str, Any]: + """Schéma de la réponse attendue : un objet par ID, valeur et citation.""" + properties = {} + for f in fields: + value: Dict[str, Any] = {"type": "string"} + options = f.get("options") + if f.get("type") == "choice" and options: + # "" reste permis : l'absence d'information prime sur l'obligation de choisir. + value = {"type": "string", "enum": list(dict.fromkeys(list(options) + [""]))} + properties[str(f["id"])] = { + "type": "object", + "properties": {"value": value, "source_quote": {"type": "string"}}, + "required": ["value", "source_quote"], + "additionalProperties": False, + } + return { + "type": "object", + "properties": properties, + "required": list(properties), + "additionalProperties": False, + } + + +def _load_json_object(content: str) -> Dict[str, Any]: + try: + data = json.loads(content) + except json.JSONDecodeError: + start, end = content.find("{"), content.rfind("}") + if start == -1 or end <= start: + raise + data = json.loads(content[start:end + 1]) + if not isinstance(data, dict): + raise json.JSONDecodeError("réponse JSON non objet", content, 0) + return data + + +def _clean_key(key: Any) -> str: + return str(key).strip().strip("[]•").strip() + + +def parse_response(content: str, fields: List[Dict]) -> Dict[str, Dict[str, str]]: + """ + Lit la réponse du modèle, avec tolérance. + + Accepte les écarts courants hors décodage guidé : ID entre crochets, valeur + rendue en chaîne ou en liste plutôt qu'en objet, réponse enveloppée dans + une clé unique (« champs », « fields »). + + Returns: + ID → {"value", "source_quote"}, pour les seuls IDs trouvés. + """ + data = _load_json_object(content) + wanted = {str(f["id"]) for f in fields} + entries = {_clean_key(k): v for k, v in data.items()} + if not wanted & set(entries) and len(entries) == 1: + inner = next(iter(entries.values())) + if isinstance(inner, dict): + entries = {_clean_key(k): v for k, v in inner.items()} + + parsed: Dict[str, Dict[str, str]] = {} + for fid in wanted: + if fid not in entries: + continue + raw = entries[fid] + if isinstance(raw, dict): + value = raw.get("value", raw.get("valeur", "")) + quote = raw.get("source_quote", raw.get("citation", "")) + else: + value, quote = raw, "" + parsed[fid] = {"value": coerce_text(value).strip(), "source_quote": coerce_text(quote).strip()} + return parsed + + +async def _post_chat(client: httpx.AsyncClient, url: str, payload: Dict[str, Any], + llm_sem: asyncio.Semaphore) -> str: + """Un appel chat/completions, erreurs classées. Retourne le contenu du message.""" + async with llm_sem: + resp = await client.post(f"{url}/chat/completions", json=payload, + timeout=httpx.Timeout(LLM_TIMEOUT, connect=15.0)) + if resp.status_code == 400: + detail = resp.text.lower() + # Libellés selon les versions de vLLM : « maximum context length is… », + # « …is longer than the maximum model length of… ». + if any(k in detail for k in ("context length", "maximum context", "too long", + "maximum model length", "longer than the maximum")): + raise ContextOverflow(resp.text[:300]) + if "response_format" in payload and payload["response_format"].get("type") == "json_schema" \ + and any(k in detail for k in ("json_schema", "response_format", "guided", "schema")): + raise SchemaUnsupported(resp.text[:300]) + resp.raise_for_status() + choice = resp.json()["choices"][0] + if choice.get("finish_reason") == "length": + raise TruncatedResponse(f"{len(choice['message'].get('content') or '')} caractères générés") + return choice["message"]["content"] or "" + + +_TRANSIENT = (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError) + + +async def call_extraction(client: httpx.AsyncClient, url: str, model: str, + messages: List[Dict], fields: List[Dict], + llm_sem: asyncio.Semaphore) -> Dict[str, Dict[str, str]]: + """ + Appel d'extraction avec reprises sur les seules erreurs transitoires. + + Réseau, délai, 5xx, 429 : on rejoue. Une erreur 4xx, un prompt trop long ou + une réponse tronquée se reproduiraient à l'identique : on les remonte tout + de suite à l'appelant, qui scinde le lot. + """ + global _schema_supported + for attempt in range(1, MAX_LLM_RETRIES + 1): + payload: Dict[str, Any] = { + "model": model, + "messages": messages, + "temperature": 0.0, + "max_tokens": MAX_TOKENS_EXTRACT, + } + if _schema_supported: + payload["response_format"] = { + "type": "json_schema", + "json_schema": {"name": "extraction", "schema": response_schema(fields)}, + } + else: + payload["response_format"] = {"type": "json_object"} + try: + content = await _post_chat(client, url, payload, llm_sem) + return parse_response(content, fields) + except SchemaUnsupported as exc: + logger.warning("json_schema refusé par le serveur, repli sur json_object : %s", exc) + _schema_supported = False + # Rejoué sans consommer de tentative : le refus ne dit rien de la requête. + return await call_extraction(client, url, model, messages, fields, llm_sem) + except (ContextOverflow, TruncatedResponse): + raise + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + if status < 500 and status != 429: + raise + error: Exception = exc + except (json.JSONDecodeError, *_TRANSIENT) as exc: + error = exc + if attempt == MAX_LLM_RETRIES: + raise error + logger.warning("LLM retry %d/%d (%s) : %s: %s", attempt, MAX_LLM_RETRIES, + [f["id"] for f in fields], type(error).__name__, error) + await asyncio.sleep(2 * attempt) + raise RuntimeError("appel d'extraction sans réponse") + + +PromptBuilder = Callable[[List[Dict], float], List[Dict]] + + +async def extract_batch(client: httpx.AsyncClient, url: str, model: str, + fields: List[Dict], build_messages: PromptBuilder, + llm_sem: asyncio.Semaphore, _depth: int = 0, _shrink: int = 0) -> List[Dict]: + """ + Extrait un lot de champs, sans jamais perdre tout le lot sur une erreur. + + - prompt trop long : rejoué avec un contexte réduit de moitié, trois fois au plus ; + - réponse tronquée : le lot est scindé en deux, chaque moitié rejouée ; + - IDs absents de la réponse : redemandés une fois, dans un lot à eux ; + - autre échec : le lot est scindé une fois avant d'abandonner. + + Args: + build_messages: (champs, échelle du contexte) → messages. L'échelle + vaut 1 puis 0.5, 0.25… à chaque dépassement de la fenêtre. + + Returns: + [{"id", "result": {"value", "source_quote"}}] ou [{"id", "error"}]. + """ + ids = [f["id"] for f in fields] + + async def _split(depth: int) -> List[Dict]: + half = len(fields) // 2 + parts = await asyncio.gather( + extract_batch(client, url, model, fields[:half], build_messages, llm_sem, depth, _shrink), + extract_batch(client, url, model, fields[half:], build_messages, llm_sem, depth, _shrink), + ) + return parts[0] + parts[1] + + try: + data = await call_extraction(client, url, model, build_messages(fields, 0.5 ** _shrink), + fields, llm_sem) + except ContextOverflow as exc: + if _shrink >= 3: + logger.error("Extraction abandonnée, contexte irréductible (%s) : %s", ids, exc) + return [{"id": f["id"], "error": "extraction_failed"} for f in fields] + logger.warning("Lot %s : fenêtre dépassée, contexte réduit", ids) + return await extract_batch(client, url, model, fields, build_messages, llm_sem, + _depth, _shrink + 1) + except TruncatedResponse as exc: + if len(fields) == 1 or _depth >= 5: + logger.error("Extraction abandonnée, réponse tronquée (%s) : %s", ids, exc) + return [{"id": f["id"], "error": "extraction_failed"} for f in fields] + logger.warning("Lot %s : réponse tronquée, lot scindé", ids) + return await _split(_depth + 1) + except Exception as exc: + logger.error("Erreur batch vLLM (%s) : %s: %s", ids, type(exc).__name__, exc) + if len(fields) > 1 and _depth == 0: + return await _split(1) + return [{"id": f["id"], "error": "extraction_failed"} for f in fields] + + missing = [f for f in fields if str(f["id"]) not in data] + if missing and len(missing) < len(fields) and _depth == 0: + logger.info("IDs absents de la réponse, redemandés : %s", [f["id"] for f in missing]) + retry = await extract_batch(client, url, model, missing, build_messages, llm_sem, 1, _shrink) + for r in retry: + if "result" in r: + data[str(r["id"])] = r["result"] + + results = [] + for f in fields: + entry = data.get(str(f["id"])) + if entry is None: + results.append({"id": f["id"], "error": "field_missing_in_response"}) + else: + results.append({"id": f["id"], "result": dict(entry)}) + return results diff --git a/services/orchestrator/prompts.py b/services/orchestrator/prompts.py index 9c554a9..b010f6c 100644 --- a/services/orchestrator/prompts.py +++ b/services/orchestrator/prompts.py @@ -190,6 +190,89 @@ def build_merge_summaries_prompt(summaries_json: str) -> str: # 3. EXTRACTION BATCH (remplissage du formulaire — plusieurs champs par appel) # --------------------------------------------------------------------------- +SYSTEM_PROMPT_BATCH_EXTRACT = """\ +Tu es un assistant spécialisé dans l'extraction précise de données depuis des dossiers médicaux suisses, \ +pour remplir un formulaire administratif d'assurance (AI, LAA, LAMal, LAM, LCA). + +On te fournit : +- une SYNTHÈSE MÉDICALE structurée du dossier, produite automatiquement : elle aide à s'orienter \ +mais peut contenir des erreurs ; +- les DOCUMENTS du dossier (texte intégral ou extraits les plus pertinents), chacun précédé de sa source ; +- la liste des CHAMPS à remplir, chacun identifié par son ID entre crochets. + +RÈGLES STRICTES : +1. Réponds pour TOUS les IDs demandés, en reprenant exactement ces IDs comme clés du JSON. +2. Les DOCUMENTS font foi. En cas de désaccord entre la synthèse et les documents, suis les documents. +3. Si l'information est introuvable, "value" vaut "" (chaîne vide). N'écris jamais « non mentionné », \ +« inconnu », « N/A », « ... » ni un exemple de format à la place d'une valeur. +4. NE JAMAIS inventer, déduire ou supposer une valeur absente des sources. +5. Distingue les personnes et les entités : patient, médecin traitant, médecin de famille, employeur, \ +assureur, destinataire. Une coordonnée n'appartient qu'à la personne ou l'entité à laquelle le document \ +l'attribue. Ne complète JAMAIS une adresse ou une coordonnée partielle avec des éléments d'une autre \ +personne ou entité : une adresse de cabinet sans rue reste sans rue. +6. Dates au format JJ.MM.AAAA. Nombres et pourcentages : le nombre seul, sans unité ni symbole. +7. Question fermée (oui/non) : réponds seulement si les sources permettent de trancher, sinon "". +8. Si le champ propose une liste de valeurs, reprends exactement l'une d'elles. +9. Champs répétés (« n°2 sur 4 ») : attribue les éléments dans l'ordre indiqué, un élément différent \ +par occurrence. S'il y a moins d'éléments que d'occurrences, laisse "" les occurrences en surnombre \ +plutôt que de répéter une valeur. +10. Pour les listes (ex : diagnostics), liste TOUTES les entrées séparées par des sauts de ligne. +11. "source_quote" : citation EXACTE et courte (100 caractères au plus) du passage des DOCUMENTS qui \ +justifie la valeur, recopiée mot pour mot. "" si la valeur est vide. +12. Les clés techniques de la synthèse ("document_source", noms de fichiers .pdf) désignent la \ +provenance interne d'une information : ne les utilise JAMAIS comme valeur d'un champ. +13. Réponds UNIQUEMENT par l'objet JSON demandé, sans texte avant ou après. +""" + + +def build_batch_extraction_messages( + fields: List[Dict], + synthesis_json: str | None, + documents: str | None, + full_documents: bool = False, +) -> List[Dict]: + """ + Messages d'extraction pour un lot de champs. + + L'ordre compte pour vLLM (--enable-prefix-caching) : prompt système, synthèse + puis documents forment un préfixe identique pour tous les lots d'un job + lorsque les documents sont transmis en entier — il n'est calculé qu'une fois. + Seule la liste des champs, placée en dernier, varie d'un lot à l'autre. + """ + parts = [] + if synthesis_json: + parts.append("SYNTHÈSE MÉDICALE (aide à l'orientation — les documents font foi) :\n" + + synthesis_json) + if documents: + title = ("DOCUMENTS DU DOSSIER (texte intégral)" if full_documents + else "EXTRAITS DE DOCUMENTS (les plus pertinents pour ces champs)") + parts.append(f"{title} :\n{documents}") + + # Les options d'une liste doivent être reprises telles quelles : le remplissage + # les apparie au libellé exact pour choisir le bouton à cocher. + def _line(f: Dict) -> str: + line = f'• [{f["id"]}] {f.get("prompt_question") or f["question"]}' + options = f.get("options") + if options and f.get("type") == "choice": + line += " (réponds exactement par l'une de ces valeurs : " + " | ".join(options) + ")" + return line + + parts.append("CHAMPS À EXTRAIRE :\n" + "\n".join(_line(f) for f in fields)) + ids = ", ".join(f'"{f["id"]}"' for f in fields) + parts.append( + "RÉPONDS UNIQUEMENT par un objet JSON dont les clés sont exactement : " + ids + ".\n" + 'Chaque clé a pour valeur un objet {"value": , "source_quote": }.' + ) + return [ + {"role": "system", "content": SYSTEM_PROMPT_BATCH_EXTRACT}, + {"role": "user", "content": "\n\n".join(parts)}, + ] + + +# --------------------------------------------------------------------------- +# Ancienne interface, encore importée par app.py jusqu'à sa migration +# --------------------------------------------------------------------------- + # Mapping section → clés de la synthèse pertinentes # La clé est le préfixe de l'ID du champ (avant le premier '.') SECTION_SYNTHESIS_KEYS: Dict[str, List[str]] = { @@ -206,31 +289,6 @@ def build_merge_summaries_prompt(summaries_json: str) -> str: } -SYSTEM_PROMPT_BATCH_EXTRACT = """\ -Tu es un assistant spécialisé dans l'extraction précise de données depuis des dossiers médicaux suisses. -On te fournit une SYNTHÈSE MÉDICALE structurée et/ou des EXTRAITS de documents, \ -ainsi qu'une liste de CHAMPS à remplir pour un formulaire administratif. - -RÈGLES STRICTES : -1. Cherche la réponse de chaque champ EN PRIORITÉ dans la SYNTHÈSE MÉDICALE si elle est fournie. -2. Si la synthèse ne contient pas l'information, cherche dans les EXTRAITS DE DOCUMENTS. -3. Si l'information est introuvable dans toutes les sources, retourne exactement "" (chaîne vide). -4. NE JAMAIS inventer, déduire ou supposer une valeur absente des sources. -5. Réponds UNIQUEMENT en JSON valide, sans texte avant ou après. -6. Réponds à TOUS les IDs demandés, même si la valeur est "". -7. Pour les dates, utilise le format DD.MM.YYYY. -8. Pour les listes (ex: diagnostics), liste TOUTES les entrées séparées par des sauts de ligne. -9. "source_quote" doit être la citation EXACTE du texte source (max 100 caractères). - Si valeur vide, mettre "" pour source_quote aussi. -10. Les clés techniques de la synthèse ("document_source", noms de fichiers .pdf) - désignent la provenance interne d'une information : ne les utilise JAMAIS comme - valeur d'un champ. -11. Ne complète JAMAIS une adresse ou une coordonnée partielle avec des éléments - provenant d'une autre personne ou d'une autre entité. Une adresse de cabinet - sans rue reste sans rue. -""" - - def build_batch_extraction_prompt( fields: List[Dict], synthesis_json: str | None, @@ -272,26 +330,6 @@ def _line(f: Dict) -> str: return "\n\n".join(parts) -# --------------------------------------------------------------------------- -# Compatibilité ascendante (ancienne interface mono-champ) -# --------------------------------------------------------------------------- - -SYSTEM_PROMPT_EXTRACT = SYSTEM_PROMPT_BATCH_EXTRACT - - -def build_field_extraction_prompt( - question: str, - synthesis_json: str | None, - chunks_context: str | None, -) -> str: - """Interface mono-champ conservée pour compatibilité.""" - return build_batch_extraction_prompt( - fields=[{"id": "result", "question": question}], - synthesis_json=synthesis_json, - chunks_context=chunks_context, - ) - - # --------------------------------------------------------------------------- # 4. CHAT MÉDICAL INTERACTIF # --------------------------------------------------------------------------- diff --git a/services/orchestrator/tests/test_extraction.py b/services/orchestrator/tests/test_extraction.py new file mode 100644 index 0000000..a0f58b5 --- /dev/null +++ b/services/orchestrator/tests/test_extraction.py @@ -0,0 +1,226 @@ +"""Découpage, contexte et appel LLM résilient (extraction.py).""" + +import asyncio +import json + +import httpx +import pytest + +import extraction +from extraction import ( + build_rag_context, + chunk_document, + extract_batch, + full_documents_text, + parse_response, + response_schema, +) +from prompts import build_batch_extraction_messages + +URL = "http://vllm.test/v1" + + +# --- Découpage ------------------------------------------------------------------ + +def test_every_chunk_carries_its_source(): + md = ("# Fiche\n\nÉtablie le 09.04.2026.\n\n## Identité\n\n- Nom : DUPONT\n\n" + "## Médecin traitant\n\n" + " ".join(f"mot{i}" for i in range(700))) + chunks = chunk_document("01_fiche.pdf", md, max_words=300, overlap=50, min_words=10) + assert all(c.startswith("[Source : 01_fiche.pdf") for c in chunks) + assert any("Médecin traitant" in c.splitlines()[0] for c in chunks[1:]) + assert max(len(c.split()) for c in chunks) <= 300 + 10 + + +def test_text_layer_pages_are_labelled_and_small_sections_merged(): + md = "## Page 1\n\nCourt.\n\n## Page 2\n\n" + " ".join(["texte"] * 80) + chunks = chunk_document("scan.pdf", md, min_words=40) + assert chunks[0].startswith("[Source : scan.pdf — page 1]") + assert "## Page" not in chunks[0] + assert chunks[-1].startswith("[Source : scan.pdf — page 2]") + + +def test_full_documents_text_is_stable(): + docs = [{"filename": "b.pdf", "markdown": "B"}, {"filename": "a.pdf", "markdown": "A"}, + {"filename": "c.pdf", "markdown": " "}] + assert full_documents_text(docs) == "=== DOCUMENT : a.pdf ===\nA\n\n=== DOCUMENT : b.pdf ===\nB" + + +def test_rag_context_interleaves_and_respects_budget(): + chunks = {"1": ["a1 " * 30, "a2 " * 30, "a3 " * 30], "2": ["b1 " * 30, "b2 " * 30]} + fields = [{"id": "1"}, {"id": "2"}] + context = build_rag_context(fields, chunks, budget_tokens=10_000) + order = [block.split()[0] for block in context.split("\n\n---\n\n")] + assert order == ["a1", "b1", "a2", "b2", "a3"] + small = build_rag_context(fields, chunks, budget_tokens=70) + assert [b.split()[0] for b in small.split("\n\n---\n\n")] == ["a1", "b1"] + + +# --- Lecture de la réponse --------------------------------------------------------- + +FIELDS = [{"id": "1.1", "question": "Nom ?"}, {"id": "1.2", "question": "Prénom ?"}, + {"id": "1.3", "question": "Sexe ?", "type": "choice", "options": ["M", "F"]}] + + +def test_parse_response_is_tolerant(): + content = json.dumps({"[1.1]": {"value": "DUPONT", "source_quote": "Nom : DUPONT"}, + "1.2": "Jean", "1.3": {"valeur": ["F"]}}) + parsed = parse_response("Voici :\n" + content, FIELDS) + assert parsed["1.1"] == {"value": "DUPONT", "source_quote": "Nom : DUPONT"} + assert parsed["1.2"] == {"value": "Jean", "source_quote": ""} + assert parsed["1.3"]["value"] == "F" + + +def test_parse_response_unwraps_single_envelope(): + content = json.dumps({"champs": {"1.1": {"value": "X", "source_quote": ""}}}) + assert parse_response(content, FIELDS)["1.1"]["value"] == "X" + + +def test_schema_constrains_ids_and_choices(): + schema = response_schema(FIELDS) + assert schema["required"] == ["1.1", "1.2", "1.3"] + assert schema["properties"]["1.3"]["properties"]["value"]["enum"] == ["M", "F", ""] + + +def test_prompt_puts_shared_context_first(): + messages = build_batch_extraction_messages(FIELDS[:1], '{"patient": {}}', "DOCS", full_documents=True) + user = messages[1]["content"] + assert user.index("SYNTHÈSE") < user.index("DOCUMENTS DU DOSSIER") < user.index("CHAMPS À EXTRAIRE") + assert '"..."' not in user + + +# --- Appel résilient --------------------------------------------------------------- + +def _completion(payload, finish_reason="stop"): + return httpx.Response(200, json={"choices": [{"message": {"content": json.dumps(payload)}, + "finish_reason": finish_reason}]}) + + +def _ids(request): + body = json.loads(request.content) + fmt = body.get("response_format", {}) + if fmt.get("type") == "json_schema": + return list(fmt["json_schema"]["schema"]["properties"]) + return [line.split("]")[0].split("[")[1] for line in body["messages"][1]["content"].splitlines() + if line.startswith("• [")] + + +def _run(handler, fields, builder=None): + async def go(): + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + return await extract_batch(client, URL, "model", fields, + builder or (lambda b, s: build_batch_extraction_messages(b, None, "ctx")), + asyncio.Semaphore(4)) + return asyncio.run(go()) + + +@pytest.fixture(autouse=True) +def _fast(monkeypatch): + monkeypatch.setattr(extraction, "_schema_supported", True) + monkeypatch.setattr(asyncio, "sleep", _no_sleep) + + +async def _no_sleep(*_args, **_kwargs): + return None + + +def test_truncated_batch_is_split_not_lost(): + calls = [] + + def handler(request): + ids = _ids(request) + calls.append(ids) + if len(ids) > 1: + return _completion({}, finish_reason="length") + return _completion({ids[0]: {"value": f"v{ids[0]}", "source_quote": ""}}) + + results = _run(handler, FIELDS) + assert [r["result"]["value"] for r in results] == ["v1.1", "v1.2", "v1.3"] + assert calls[0] == ["1.1", "1.2", "1.3"] + + +def test_context_overflow_shrinks_context_for_single_field(): + scales = [] + + def builder(batch, scale): + scales.append(scale) + return build_batch_extraction_messages(batch, None, "ctx") + + def handler(request): + if scales[-1] >= 1.0: + return httpx.Response(400, text="This model's maximum context length is 32768 tokens.") + return _completion({"1.1": {"value": "ok", "source_quote": ""}}) + + results = _run(handler, FIELDS[:1], builder) + assert results[0]["result"]["value"] == "ok" + assert scales[0] == 1.0 and scales[-1] < 1.0 + + +def test_missing_ids_are_asked_again(): + seen = [] + + def handler(request): + ids = _ids(request) + seen.append(ids) + return _completion({ids[0]: {"value": "A", "source_quote": ""}}) + + results = _run(handler, FIELDS[:2]) + assert seen == [["1.1", "1.2"], ["1.2"]] + assert all("result" in r for r in results) + + +def test_schema_refusal_falls_back_to_json_object(): + formats = [] + + def handler(request): + fmt = json.loads(request.content)["response_format"]["type"] + formats.append(fmt) + if fmt == "json_schema": + return httpx.Response(400, text="Unsupported response_format json_schema") + return _completion({"1.1": {"value": "B", "source_quote": ""}}) + + results = _run(handler, FIELDS[:1]) + assert formats == ["json_schema", "json_object"] + assert results[0]["result"]["value"] == "B" + + +def test_server_errors_are_retried_then_reported_per_field(): + attempts = [] + + def handler(request): + attempts.append(1) + return httpx.Response(503, text="overloaded") + + results = _run(handler, FIELDS[:2]) + assert all(r.get("error") == "extraction_failed" for r in results) + # 3 essais sur le lot, puis 3 sur chacune des deux moitiés. + assert len(attempts) == 9 + + +def test_client_errors_are_not_retried(): + attempts = [] + + def handler(request): + attempts.append(1) + return httpx.Response(422, text="bad request") + + results = _run(handler, FIELDS[:1]) + assert results[0]["error"] == "extraction_failed" + assert len(attempts) == 1 + + +def test_context_overflow_shrinks_instead_of_splitting(): + seen = [] + + def builder(batch, scale): + seen.append(([f["id"] for f in batch], scale)) + return build_batch_extraction_messages(batch, None, "ctx") + + def handler(request): + if seen[-1][1] > 0.25: + return httpx.Response(400, text="maximum context length exceeded") + return _completion({i: {"value": "v", "source_quote": ""} for i in seen[-1][0]}) + + results = _run(handler, FIELDS[:2], builder) + assert all("result" in r for r in results) + assert [ids for ids, _ in seen] == [["1.1", "1.2"]] * 3 + assert [scale for _, scale in seen] == [1.0, 0.5, 0.25] From cab13229f01dc9b5cf728e933391832718433968 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:09:34 +0000 Subject: [PATCH 05/13] fix: keep the medical synthesis from failing on large dossiers Conservative token estimate, explicit max_tokens, 900 s timeout and one retry; a failed direct synthesis falls back to per-document summaries, oversized documents are split, and an impossible merge is replaced by a deterministic one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CAQEkcJGzQXzySYYsRCKWW --- services/orchestrator/medical_synthesis.py | 229 ++++++++++++++++----- 1 file changed, 173 insertions(+), 56 deletions(-) diff --git a/services/orchestrator/medical_synthesis.py b/services/orchestrator/medical_synthesis.py index a20460c..68718a8 100644 --- a/services/orchestrator/medical_synthesis.py +++ b/services/orchestrator/medical_synthesis.py @@ -6,12 +6,18 @@ - Hiérarchique : trop de texte → résumé par document puis fusion Le résultat est un dict Python (issu du JSON LLM) contenant diagnostics, incapacités, etc. -En cas d'échec, retourne None pour que le pipeline continue avec le RAG pur. +En cas d'échec, retourne None pour que le pipeline continue sur les seuls documents. + +Chaque étape a désormais un repli plutôt qu'un point de rupture : la stratégie +directe qui échoue (fenêtre dépassée, JSON tronqué) passe la main à la +hiérarchique, un document trop long pour un résumé est découpé, et une fusion +impossible est remplacée par une fusion déterministe des résumés. """ +import asyncio import json import logging -import re +import os import time from typing import Any @@ -28,12 +34,29 @@ logger = logging.getLogger(__name__) -# Seuil en tokens estimés au-delà duquel on passe en mode hiérarchique. -# 28k laisse ~4k tokens pour le prompt système et la réponse JSON dans une fenêtre de 32k. -MAX_DIRECT_TOKENS = 28_000 +# À garder aligné sur --max-model-len de vLLM (docker-compose). +LLM_MAX_MODEL_LEN = int(os.getenv("LLM_MAX_MODEL_LEN", "32768")) + +# Génération réservée à la synthèse. Elle n'avait pas de plafond explicite : +# vLLM lui laissait ce que le prompt n'occupait pas, soit parfois moins de 4k +# tokens pour un JSON exhaustif — tronqué, donc illisible, donc perdu. +SYNTHESIS_MAX_TOKENS = int(os.getenv("SYNTHESIS_MAX_TOKENS", "6144")) +SUMMARY_MAX_TOKENS = int(os.getenv("SUMMARY_MAX_TOKENS", "3072")) + +# Seuil en tokens estimés au-delà duquel on passe en mode hiérarchique : +# fenêtre − génération − consignes et squelette JSON du prompt. +MAX_DIRECT_TOKENS = LLM_MAX_MODEL_LEN - SYNTHESIS_MAX_TOKENS - 2500 +# Taille maximale d'un morceau de document soumis à un résumé. +MAX_DOC_TOKENS = LLM_MAX_MODEL_LEN - SUMMARY_MAX_TOKENS - 2000 + +# Timeout LLM pour la synthèse. À ~20 tokens/s sur le GB10, 6k tokens de JSON +# prennent 5 minutes : les 180 s d'origine faisaient échouer les gros dossiers. +SYNTHESIS_TIMEOUT = float(os.getenv("SYNTHESIS_TIMEOUT", "900")) +MAX_SYNTHESIS_RETRIES = 2 -# Timeout LLM pour la synthèse (plus long qu'une extraction de champ classique) -SYNTHESIS_TIMEOUT = 180 # secondes + +class _Unrecoverable(Exception): + """Erreur qu'un nouvel essai à l'identique reproduirait (fenêtre, troncature).""" # --------------------------------------------------------------------------- @@ -42,8 +65,14 @@ def estimate_tokens(text: str) -> int: - """Estimation rapide : ~4 caractères = 1 token (heuristique valable pour FR/EN).""" - return len(text) // 4 + """ + Estimation prudente : ~3 caractères par token. + + L'ancienne heuristique (4 caractères) sous-estimait le français médical — + accents, dates, codes — et laissait passer en mode direct des dossiers + qui dépassaient la fenêtre du modèle. + """ + return len(text) // 3 + 1 def _extract_json_from_response(text: str) -> dict[str, Any]: @@ -57,7 +86,10 @@ def _extract_json_from_response(text: str) -> dict[str, Any]: if start == -1 or end == -1 or end <= start: raise ValueError(f"Aucun JSON trouvé dans la réponse : {text[:200]}") json_str = text[start : end + 1] - return json.loads(json_str) + data = json.loads(json_str) + if not isinstance(data, dict): + raise ValueError("La synthèse n'est pas un objet JSON") + return data async def _call_llm( @@ -65,9 +97,16 @@ async def _call_llm( user_prompt: str, vllm_url: str, model_name: str, - timeout: int = SYNTHESIS_TIMEOUT, + max_tokens: int = SYNTHESIS_MAX_TOKENS, + timeout: float = SYNTHESIS_TIMEOUT, ) -> str: - """Appel vLLM et retourne le contenu texte de la réponse.""" + """ + Appel vLLM et retourne le contenu texte de la réponse. + + Rejoue une fois les erreurs transitoires. Une fenêtre dépassée ou une + réponse tronquée sont remontées sans nouvel essai : l'appelant change de + stratégie. + """ payload = { "model": model_name, "messages": [ @@ -75,13 +114,28 @@ async def _call_llm( {"role": "user", "content": user_prompt}, ], "temperature": 0.05, + "max_tokens": max_tokens, "response_format": {"type": "json_object"}, } - async with httpx.AsyncClient(timeout=timeout) as client: - resp = await client.post(f"{vllm_url}/chat/completions", json=payload) - resp.raise_for_status() - data = resp.json() - return data["choices"][0]["message"]["content"] + for attempt in range(1, MAX_SYNTHESIS_RETRIES + 1): + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=15.0)) as client: + resp = await client.post(f"{vllm_url}/chat/completions", json=payload) + if resp.status_code == 400: + raise _Unrecoverable(f"requête refusée : {resp.text[:200]}") + resp.raise_for_status() + choice = resp.json()["choices"][0] + if choice.get("finish_reason") == "length": + raise _Unrecoverable("réponse tronquée (max_tokens atteint)") + return choice["message"]["content"] or "" + except _Unrecoverable: + raise + except (httpx.HTTPError, KeyError, ValueError) as exc: + if attempt == MAX_SYNTHESIS_RETRIES: + raise + logger.warning("Synthèse : nouvel essai après %s: %s", type(exc).__name__, exc) + await asyncio.sleep(3) + raise RuntimeError("synthèse sans réponse") # --------------------------------------------------------------------------- @@ -96,12 +150,7 @@ async def _synthesize_direct( ) -> dict[str, Any]: """Synthèse en un seul appel LLM. Utilisée quand le texte tient dans la fenêtre.""" user_prompt = build_synthesis_prompt(all_docs_text) - raw = await _call_llm( - SYSTEM_PROMPT_SYNTHESIS, - user_prompt, - vllm_url, - model_name, - ) + raw = await _call_llm(SYSTEM_PROMPT_SYNTHESIS, user_prompt, vllm_url, model_name) return _extract_json_from_response(raw) @@ -110,6 +159,33 @@ async def _synthesize_direct( # --------------------------------------------------------------------------- +def _split_document(name: str, text: str, max_tokens: int = MAX_DOC_TOKENS) -> list[tuple[str, str]]: + """ + Découpe un document trop long pour un seul résumé. + + Coupe sur les sauts de paragraphe les plus proches de la limite, pour ne + pas scinder une ligne de diagnostic en deux. + """ + if estimate_tokens(text) <= max_tokens: + return [(name, text)] + max_chars = max_tokens * 3 + parts: list[str] = [] + rest = text + while rest: + if len(rest) <= max_chars: + parts.append(rest) + break + cut = rest.rfind("\n\n", 0, max_chars) + if cut < max_chars // 2: + cut = rest.rfind("\n", 0, max_chars) + if cut < max_chars // 2: + cut = max_chars + parts.append(rest[:cut]) + rest = rest[cut:].lstrip() + total = len(parts) + return [(f"{name} (partie {i}/{total})", part) for i, part in enumerate(parts, start=1)] + + async def _summarize_single_doc( doc_text: str, doc_name: str, @@ -118,13 +194,8 @@ async def _summarize_single_doc( ) -> dict[str, Any]: """Résumé d'un seul document médical.""" user_prompt = build_per_doc_summary_prompt(doc_text, doc_name) - raw = await _call_llm( - SYSTEM_PROMPT_PER_DOC_SUMMARY, - user_prompt, - vllm_url, - model_name, - timeout=120, - ) + raw = await _call_llm(SYSTEM_PROMPT_PER_DOC_SUMMARY, user_prompt, vllm_url, model_name, + max_tokens=SUMMARY_MAX_TOKENS) return _extract_json_from_response(raw) @@ -135,41 +206,76 @@ async def _merge_summaries( ) -> dict[str, Any]: """Fusionne les résumés de chaque document en un dossier unifié.""" summaries_json = json.dumps(summaries, ensure_ascii=False, indent=2) + if estimate_tokens(summaries_json) > MAX_DIRECT_TOKENS: + raise _Unrecoverable("résumés trop volumineux pour une fusion par le modèle") user_prompt = build_merge_summaries_prompt(summaries_json) - raw = await _call_llm( - SYSTEM_PROMPT_MERGE_SUMMARIES, - user_prompt, - vllm_url, - model_name, - timeout=SYNTHESIS_TIMEOUT, - ) + raw = await _call_llm(SYSTEM_PROMPT_MERGE_SUMMARIES, user_prompt, vllm_url, model_name) return _extract_json_from_response(raw) +def _combine_summaries(summaries: list[dict[str, Any]]) -> dict[str, Any]: + """ + Fusion déterministe des résumés par document, sans appel LLM. + + Repli de `_merge_summaries` : moins synthétique (les doublons entre + documents subsistent), mais aucune information n'est perdue — ce qui + compte davantage pour remplir un formulaire que l'élégance du résumé. + """ + combined: dict[str, Any] = { + "patient": {}, "diagnostics": [], "incapacites_travail": [], "traitements": [], + "medecins": [], "dates_cles": {}, "pronostic": None, "canton_traitement": None, + } + pronostics: list[str] = [] + for summary in summaries: + source = summary.get("document") + for key, value in (summary.get("patient") or {}).items(): + if value and not combined["patient"].get(key): + combined["patient"][key] = value + for key in ("diagnostics", "incapacites_travail"): + for entry in summary.get(key) or []: + if isinstance(entry, dict): + entry = {**entry, "document_source": entry.get("document_source") or source} + combined[key].append(entry) + for key in ("traitements", "medecins"): + combined[key].extend(summary.get(key) or []) + dates = summary.get("dates_cles") or {} + if isinstance(dates, dict): + for key, value in dates.items(): + if value and not combined["dates_cles"].get(key): + combined["dates_cles"][key] = value + if summary.get("dates_importantes"): + combined.setdefault("dates_importantes", []).extend(summary["dates_importantes"]) + if summary.get("pronostic"): + pronostics.append(str(summary["pronostic"])) + if summary.get("canton_traitement") and not combined["canton_traitement"]: + combined["canton_traitement"] = summary["canton_traitement"] + combined["pronostic"] = "\n".join(pronostics) or None + return combined + + async def _synthesize_hierarchical( docs: list[tuple[str, str]], # [(doc_name, doc_text), ...] vllm_url: str, model_name: str, ) -> dict[str, Any]: """ - Synthèse hiérarchique : résumé par document en séquentiel (pour ne pas saturer le GPU), - puis fusion en un seul appel. + Synthèse hiérarchique : résumé par document (3 en parallèle pour ne pas + saturer le GPU), puis fusion en un seul appel. """ - import asyncio - - summaries = [] + parts = [part for name, text in docs for part in _split_document(name, text)] sem = asyncio.Semaphore(3) # 3 docs en parallèle max async def _summarize_with_sem(name: str, text: str) -> dict[str, Any] | None: async with sem: try: - return await _summarize_single_doc(text, name, vllm_url, model_name) + summary = await _summarize_single_doc(text, name, vllm_url, model_name) + summary.setdefault("document", name) + return summary except Exception as exc: logger.warning("Échec résumé doc '%s' : %s", name, exc) return None - tasks = [_summarize_with_sem(name, text) for name, text in docs] - results = await asyncio.gather(*tasks) + results = await asyncio.gather(*[_summarize_with_sem(name, text) for name, text in parts]) summaries = [r for r in results if r is not None] if not summaries: @@ -178,7 +284,11 @@ async def _summarize_with_sem(name: str, text: str) -> dict[str, Any] | None: if len(summaries) == 1: return summaries[0] - return await _merge_summaries(summaries, vllm_url, model_name) + try: + return await _merge_summaries(summaries, vllm_url, model_name) + except Exception as exc: + logger.warning("Fusion des résumés par le modèle impossible (%s) — fusion déterministe", exc) + return _combine_summaries(summaries) # --------------------------------------------------------------------------- @@ -201,7 +311,7 @@ async def run_medical_synthesis( Returns: dict Python représentant le dossier médical synthétisé, - ou None si la synthèse a échoué (le pipeline continue avec RAG pur). + ou None si la synthèse a échoué (le pipeline continue sur les seuls documents). """ if not ocr_results: logger.warning("run_medical_synthesis : aucun document OCR fourni") @@ -210,9 +320,9 @@ async def run_medical_synthesis( t0 = time.time() try: - # Construire le texte complet de tous les documents + # Ordre stable : même dossier, même prompt — et même préfixe pour vLLM. docs: list[tuple[str, str]] = [] - for r in ocr_results: + for r in sorted(ocr_results, key=lambda d: d.get("filename", "")): name = r.get("filename", "document_inconnu") text = r.get("markdown", "") if text.strip(): @@ -235,16 +345,23 @@ async def run_medical_synthesis( total_tokens, ) + result = None + strategy = "hiérarchique" if total_tokens <= MAX_DIRECT_TOKENS: - result = await _synthesize_direct(all_docs_text, vllm_url, model_name) - strategy = "directe" - else: + try: + result = await _synthesize_direct(all_docs_text, vllm_url, model_name) + strategy = "directe" + except Exception as exc: + # Un seul document mal formé ou un JSON tronqué ne justifie pas + # de renoncer : les résumés par document tiennent plus sûrement. + logger.warning("Synthèse directe impossible (%s: %s) — passage en hiérarchique", + type(exc).__name__, exc) + if result is None: result = await _synthesize_hierarchical(docs, vllm_url, model_name) - strategy = "hiérarchique" elapsed = time.time() - t0 - nb_diagnostics = len(result.get("diagnostics", [])) - nb_incapacites = len(result.get("incapacites_travail", [])) + nb_diagnostics = len(result.get("diagnostics") or []) + nb_incapacites = len(result.get("incapacites_travail") or []) logger.info( "Synthèse médicale terminée (%s) en %.1fs — %d diagnostics, %d périodes d'incapacité", @@ -260,7 +377,7 @@ async def run_medical_synthesis( elapsed = time.time() - t0 logger.error( "Échec de la synthèse médicale après %.1fs : %s — " - "le pipeline continuera avec le RAG pur", + "le pipeline continuera sur les seuls documents", elapsed, exc, ) From f3c1815420133243ba9566cf9787fa19bffb568a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:09:34 +0000 Subject: [PATCH 06/13] fix: share one pipeline path between run and rerun, degrade instead of failing - Run and rerun share _extract_fields and _fill_pdf: the rerun copy lost the computed fields and the canton recipient, so a re-run AI form went back to the template's default office. Rerun now also waits for a pipeline slot. - The synthesis is passed whole to every batch: the per-section filter (SECTION_SYNTHESIS_KEYS, removed) only matched the former AVS layout, and hid incapacity, diagnosis and physician data from most other forms. - One unreadable document is skipped with a warning instead of failing the job (and leaving the embed consumer looping); OCR retries match the cause. - Embedding and rerank outages degrade retrieval instead of failing the job. - Actionable failures ('no readable text') reach the user; /status exposes warnings. - /chat requires the job token like /fields and /download. - Debug logs holding full patient text are purged with the job (KEEP_DEBUG_LOGS to keep them); the purge loop no longer dies on error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CAQEkcJGzQXzySYYsRCKWW --- services/orchestrator/app.py | 1146 ++++++++---------- services/orchestrator/prompts.py | 61 - services/orchestrator/tests/test_pipeline.py | 323 +++++ 3 files changed, 849 insertions(+), 681 deletions(-) create mode 100644 services/orchestrator/tests/test_pipeline.py diff --git a/services/orchestrator/app.py b/services/orchestrator/app.py index a12f01b..12804d9 100644 --- a/services/orchestrator/app.py +++ b/services/orchestrator/app.py @@ -28,11 +28,13 @@ from core.inject import inject_datasets from core.checkbox import discover_checkbox_paths, normalize_checkboxes from core.acroform import detect_form_type, fill_acroform +from core.fields import batch_fields, collect_form_values, normalize_value, prepare_fields from core.provenance import SourceIndex, ground_value +import extraction import stats from medical_synthesis import run_medical_synthesis -from prompts import (SYSTEM_PROMPT_BATCH_EXTRACT, build_batch_extraction_prompt, - SECTION_SYNTHESIS_KEYS, build_chat_messages, build_synthesis_refine_messages) +from prompts import (SYSTEM_PROMPT_BATCH_EXTRACT, build_batch_extraction_messages, + build_chat_messages, build_synthesis_refine_messages) logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -88,6 +90,10 @@ async def verify_api_key(request: Request, call_next): # --- Dossier de logs de debug (markdown OCR, chunks, résultats LLM) DEBUG_LOG_DIR = Path(os.getenv("DEBUG_LOG_DIR", "/tmp/doctorfill_debug")) DEBUG_LOG_DIR.mkdir(parents=True, exist_ok=True) +# Ces journaux contiennent le texte intégral des dossiers patients. Ils étaient +# conservés sans limite ; ils suivent désormais la rétention des jobs, sauf +# demande explicite pour une session de mise au point. +KEEP_DEBUG_LOGS = os.getenv("KEEP_DEBUG_LOGS", "false").lower() == "true" JOBS: Dict[str, Dict[str, Any]] = {} JOB_RETENTION_SECONDS = int(os.getenv("JOB_RETENTION_SECONDS", "3600")) @@ -157,11 +163,16 @@ def _build_id() -> str: # retrouvable dans le texte des documents, et où ? Le seuil de rerank qui tenait # ce rôle notait la source secondaire du prompt et flaggait 94 champs sur 112, # dont 76 correctement remplis — voir l'en-tête de core/provenance.py. +# +# Budget de tokens, délais et reprises de l'appel d'extraction : voir extraction.py. -# Plafond de génération pour un batch d'extraction (~7 champs × valeur + citation). -MAX_TOKENS_EXTRACT = int(os.getenv("MAX_TOKENS_EXTRACT", "2048")) -# Retries sur un appel LLM d'extraction (l'OCR en a 5, le rerank 3, celui-ci n'en avait aucun). -MAX_LLM_RETRIES = 3 +# Extraits retenus par champ après rerank, parmi les candidats de la recherche vectorielle. +RETRIEVAL_CANDIDATES = int(os.getenv("RETRIEVAL_CANDIDATES", "30")) +RETRIEVAL_TOP_K = int(os.getenv("RETRIEVAL_TOP_K", "8")) +# Taille d'un lot d'extraction ; les occurrences d'une structure répétée restent +# groupées jusqu'à MAX_FAMILY_SIZE (voir core/fields.batch_fields). +MAX_BATCH_SIZE = int(os.getenv("MAX_BATCH_SIZE", "8")) +MAX_FAMILY_SIZE = int(os.getenv("MAX_FAMILY_SIZE", "32")) # Requêtes LLM en vol. À garder aligné sur --max-num-seqs de vLLM (docker-compose) : # en dessous, le batch continu de vLLM tourne à vide ; au-dessus, les requêtes @@ -241,89 +252,50 @@ async def _cleanup_expired_jobs(): """Tâche de fond qui purge les jobs terminés et leurs fichiers temporaires.""" while True: await asyncio.sleep(300) - now = time.time() - expired = [ - jid for jid, data in JOBS.items() - if data.get("status") in ("completed", "failed") - and now - data.get("completed_at", now) > JOB_RETENTION_SECONDS - ] - for jid in expired: - tmp_dir = Path(f"/tmp/{jid}") - if tmp_dir.exists(): - shutil.rmtree(tmp_dir, ignore_errors=True) - logger.info(f"Cleanup: fichiers temporaires supprimés pour job {jid}") - try: - chroma_client.delete_collection(name=f"col_{jid}") - except Exception: - pass - JOBS.pop(jid, None) - if expired: - logger.info(f"Cleanup: {len(expired)} job(s) expiré(s) purgé(s)") + try: + _purge_expired_jobs() + except Exception as exc: + # Une exception ici arrêtait la tâche pour de bon : plus aucun job, + # ni aucun dossier patient sur disque, n'était purgé ensuite. + logger.error(f"Cleanup: échec de la purge ({type(exc).__name__}: {exc})") + + +def _purge_expired_jobs() -> None: + """Purge les jobs terminés depuis plus de JOB_RETENTION_SECONDS et leurs fichiers.""" + now = time.time() + expired = [ + jid for jid, data in JOBS.items() + if data.get("status") in ("completed", "failed") + and now - data.get("completed_at", now) > JOB_RETENTION_SECONDS + ] + for jid in expired: + tmp_dir = Path(f"/tmp/{jid}") + if tmp_dir.exists(): + shutil.rmtree(tmp_dir, ignore_errors=True) + logger.info(f"Cleanup: fichiers temporaires supprimés pour job {jid}") + debug_dir = JOBS[jid].get("_debug_dir") + if debug_dir and not KEEP_DEBUG_LOGS: + shutil.rmtree(debug_dir, ignore_errors=True) + try: + chroma_client.delete_collection(name=f"col_{jid}") + except Exception: + pass + JOBS.pop(jid, None) + if expired: + logger.info(f"Cleanup: {len(expired)} job(s) expiré(s) purgé(s)") # --------------------------------------------------- # --- UTILS --- -def markdown_semantic_chunking(md_text: str, max_words: int = 800) -> List[str]: - raw_chunks = re.split(r'(?=\n#{1,3} )', md_text) - final_chunks = [] - for chunk in raw_chunks: - chunk = chunk.strip() - if not chunk: - continue - words = chunk.split() - if len(words) > max_words: - for i in range(0, len(words), max_words - 100): - final_chunks.append(" ".join(words[i:i + max_words])) - else: - final_chunks.append(chunk) - return final_chunks - - -def _normalize_field_value(value: str, field_type: str | None) -> str: - """Normalise la valeur extraite selon le type du champ déclaré dans le template.""" - if not value or not field_type: - return value - if field_type == "sex": - # Normalise vers M ou F - v = value.strip().upper() - if v in ("M", "MASCULIN", "HOMME", "MALE", "H"): - return "M" - if v in ("F", "FÉMININ", "FEMININ", "FEMME", "FEMALE"): - return "F" - # Cas où le LLM a renvoyé "M (masculin)" ou "F (féminin)" - if v.startswith("M"): - return "M" - if v.startswith("F"): - return "F" - return value # Valeur non reconnue : on laisse passer - if field_type == "percent": - # Garde uniquement les chiffres (et éventuellement une virgule/point décimale) - match = re.search(r'\d+(?:[.,]\d+)?', value.replace("%", "")) - if match: - return match.group(0).replace(",", ".") - return value - return value - - -def _resolve_option_value(value: str, f_def: Dict) -> str: +class UserFacingError(Exception): """ - Traduit le libellé choisi par le LLM en valeur d'export du formulaire. + Échec dont la cause peut être dite à l'utilisateur sans rien exposer. - Les groupes de boutons radio medForms exportent un index (« 0 », « 1 », …) - et non le libellé affiché ; `option_values` porte la correspondance, dérivée - du XFA par tools/gen_template. Un libellé inconnu est laissé tel quel : le - remplissage laissera alors le groupe vide plutôt que de cocher au hasard. + [SEC-10] garde les erreurs internes derrière un message générique ; celles-ci + décrivent le dossier soumis (« aucun document lisible ») et lui permettent + d'agir plutôt que de « réessayer » en vain. """ - options = f_def.get("options") - option_values = f_def.get("option_values") - if not options or not option_values or len(options) != len(option_values): - return value - target = value.strip().casefold() - for label, exported in zip(options, option_values): - if label.strip().casefold() == target: - return exported - return value def _sanitize_filename(filename: str, index: int) -> str: @@ -343,10 +315,37 @@ def _sanitize_filename(filename: str, index: int) -> str: return safe +_TEI_TRANSIENT = (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError) + + +async def _tei_post(client: httpx.AsyncClient, route: str, payload: Dict, timeout: float) -> Dict: + """ + Appel TEI avec reprises sur erreur transitoire. + + L'embedding n'en avait aucune : un seul hoquet du service pendant l'OCR + faisait échouer tout le job, après plusieurs minutes de traitement. + """ + max_retries = 3 + for attempt in range(1, max_retries + 1): + try: + resp = await client.post(f"{TEI_URL}{route}", json=payload, timeout=timeout) + resp.raise_for_status() + return resp.json() + except httpx.HTTPStatusError as e: + if e.response.status_code < 500 or attempt == max_retries: + raise + err: Exception = e + except _TEI_TRANSIENT as e: + if attempt == max_retries: + raise + err = e + logger.warning(f"TEI {route} retry {attempt}/{max_retries}: {type(err).__name__}: {err}") + await asyncio.sleep(2 * attempt) + raise RuntimeError(f"TEI {route} sans réponse") + + async def fetch_embeddings(client: httpx.AsyncClient, texts: List[str]): - resp = await client.post(f"{TEI_URL}/embed", json={"texts": texts}, timeout=120.0) - resp.raise_for_status() - return resp.json()["embeddings"] + return (await _tei_post(client, "/embed", {"texts": texts}, timeout=120.0))["embeddings"] async def fetch_embeddings_batched(client: httpx.AsyncClient, texts: List[str], batch_size: int = 64) -> List: @@ -379,107 +378,22 @@ async def fetch_question_embeddings(client: httpx.AsyncClient, form_id: str, async def fetch_rerank(client: httpx.AsyncClient, query: str, docs: List[str], rerank_sem: asyncio.Semaphore = None): - """Rerank avec semaphore + retry sur PoolTimeout/ReadError/ConnectError.""" - MAX_RETRIES = 3 - - async def _call(): - resp = await client.post(f"{TEI_URL}/rerank", json={"query": query, "documents": docs}, timeout=300.0) - resp.raise_for_status() - return resp.json()["results"] - - async def _call_with_retry(): - for attempt in range(1, MAX_RETRIES + 1): - try: - return await _call() - except (httpx.PoolTimeout, httpx.ReadError, httpx.ConnectError) as e: - if attempt == MAX_RETRIES: - raise - logger.warning(f"Rerank retry {attempt}/{MAX_RETRIES}: {type(e).__name__}: {e}") - await asyncio.sleep(2 * attempt) - + """Rerank avec semaphore + retry sur erreur transitoire.""" if rerank_sem: async with rerank_sem: - return await _call_with_retry() + data = await _tei_post(client, "/rerank", {"query": query, "documents": docs}, timeout=300.0) else: - return await _call_with_retry() - - -def _filter_synthesis_for_section(synthesis: Dict | None, section_id: str) -> str | None: - """Retourne uniquement les clés de synthèse pertinentes pour la section donnée.""" - if not synthesis: - return None - keys = SECTION_SYNTHESIS_KEYS.get(section_id, list(synthesis.keys())) - filtered = {k: synthesis[k] for k in keys if k in synthesis and synthesis[k]} - return json.dumps(filtered, ensure_ascii=False) if filtered else None - - -def _extractable_fields(template: Dict) -> List[Dict]: - """ - Champs à soumettre au modèle. - - Écarte les champs `preset` : l'éditeur du formulaire les a déjà renseignés - (adresse de l'office destinataire, code EAN, données de routage) et ils font - autorité. Les extraire produisait des documents incohérents — un formulaire - AI adressé à « Helvetia Santé SA » parce que le dossier mentionnait cette - caisse, à l'adresse de l'Office AI restée en dessous. Ça allège aussi le - budget LLM de 112 questions sur l'ensemble du catalogue. - """ - return [f for f in template["fields"] - if f.get("question", "").strip() - and not f.get("preset") and not f.get("computed")] - - -def _computed_values(template: Dict) -> List[tuple[Dict, str]]: - """ - Champs dont la valeur se déduit du formulaire lui-même. - - Le bloc adresse du destinataire en est le seul cas aujourd'hui : il est la - version visible de champs structurés déjà renseignés par l'éditeur, mais - masqués dans la mise en page. Le demander au modèle produisait des adresses - inventées — un numéro de rue emprunté au patient, un code postal fabriqué. - """ - return [(f, f["computed"]) for f in template["fields"] if f.get("computed")] - - -def _canton_recipient(template: Dict, results: List[Dict]) -> Dict[str, str]: - """ - Destinataire correspondant au canton de traitement extrait. - - Un formulaire AI s'adresse à l'office AI *du canton du patient*. Le gabarit - porte cette table et la applique par script — mais le script ne s'exécute - que dans un lecteur XFA, jamais dans le PDF qu'on livre. Sans ça, un dossier - neuchâtelois partait à l'office de Berne, l'adresse par défaut du vierge. - - Canton absent ou inconnu : on ne renvoie rien et le formulaire garde ses - valeurs d'origine — mieux vaut le défaut du gabarit qu'un office arbitraire. - - Returns: - nom de champ → valeur, à écrire tel quel. - """ - table = template.get("_recipient_by_canton") - if not table: - return {} - canton_ids = {str(f["id"]) for f in template["fields"] - if f.get("name") == "treatmentCanton"} - for res in results: - if str(res.get("id")) not in canton_ids: - continue - valeur = str((res.get("result") or {}).get("value") or "").strip().upper() - if valeur in table: - logger.info("Destinataire dérivé du canton %s", valeur) - return table[valeur] - if valeur: - logger.warning("Canton %r hors de la table du formulaire", valeur) - return {} + data = await _tei_post(client, "/rerank", {"query": query, "documents": docs}, timeout=300.0) + return data["results"] def _synthesis_as_source(synthesis: Dict | None) -> List[Dict]: """ Expose la synthèse médicale comme source indexable, marquée comme dérivée. - C'est la source *principale* du prompt d'extraction : sans elle dans l'index, - toute valeur citée depuis la synthèse plutôt que depuis un document brut - était déclarée non tracée. Le drapeau `derived` évite l'excès inverse — la + C'est une source du prompt d'extraction : sans elle dans l'index, toute + valeur citée depuis la synthèse plutôt que depuis un document brut était + déclarée non tracée. Le drapeau `derived` évite l'excès inverse — la synthèse est une production du modèle, s'y adosser ne vaut pas preuve. """ if not synthesis: @@ -491,96 +405,287 @@ def _synthesis_as_source(synthesis: Dict | None) -> List[Dict]: }] +# Réponses choisies plutôt que recopiées : les retrouver dans le texte ne prouve rien. +_NON_LITERAL_TYPES = {"bool", "choice"} + + def _annotate_confidence(results: List[Dict], source_index: SourceIndex, - field_score_map: Dict[str, float]) -> None: + field_score_map: Dict[str, float], fields_by_id: Dict[str, Dict]) -> None: """ - Rattache chaque valeur extraite au texte dont elle provient. + Normalise chaque valeur extraite et la rattache au texte dont elle provient. + + La normalisation (date au format JJ.MM.AAAA, « non mentionné » → vide, oui/non + canonique…) a lieu ici plutôt qu'au remplissage : le clinicien voit dans + l'application exactement ce qui sera écrit dans le formulaire. La valeur + brute du modèle reste disponible dans `raw_value` quand elle diffère. Aucun appel LLM supplémentaire, aucun seuil : la valeur se retrouve dans les documents ou elle ne s'y retrouve pas. Le résultat porte le verdict (`grounding`), et l'ancrage quand il existe — document, page, extrait — de quoi vérifier d'un coup d'œil plutôt que d'avoir à croire le modèle. - `rerank_score` reste renseigné à titre de diagnostic, mais ne déclenche plus - rien : voir l'en-tête de core/provenance.py. - - On ne supprime jamais une valeur — c'est au clinicien de trancher. + On ne supprime jamais une valeur réelle — c'est au clinicien de trancher. """ for res in results: payload = res.get("result") if not isinstance(payload, dict): continue - payload["rerank_score"] = round(field_score_map.get(str(res["id"]), 0.0), 4) - - value = str(payload.get("value") or "").strip() + fid = str(res["id"]) + f_def = fields_by_id.get(fid, {}) + score = field_score_map.get(fid) + payload["rerank_score"] = round(score, 4) if score is not None else None + + raw = str(payload.get("value") or "").strip() + value = normalize_value(raw, f_def) + if value != raw: + payload["raw_value"] = raw + payload["value"] = value if not value: + payload["source_quote"] = "" continue - payload.update(ground_value(value, str(payload.get("source_quote") or ""), source_index)) - - -def _group_fields_into_batches(fields: List[Dict], max_batch_size: int = 7) -> List[List[Dict]]: - """Groupe les champs par section (préfixe de l'ID), max max_batch_size par batch.""" - from collections import defaultdict - by_section: Dict[str, List[Dict]] = defaultdict(list) - for f in fields: - section = str(f["id"]).split(".")[0] - by_section[section].append(f) - batches = [] - for section_fields in by_section.values(): - for i in range(0, len(section_fields), max_batch_size): - batches.append(section_fields[i:i + max_batch_size]) - return batches - - -async def extract_fields_batch_vllm( - client: httpx.AsyncClient, - fields: List[Dict], - chunks_context: str, - synthesis_json: str | None, - llm_sem: asyncio.Semaphore, -) -> List[Dict]: - """Extrait plusieurs champs en un seul appel LLM (mode batch).""" - prompt = build_batch_extraction_prompt(fields, synthesis_json, chunks_context) - payload = { - "model": VLLM_MODEL, - "messages": [ - {"role": "system", "content": SYSTEM_PROMPT_BATCH_EXTRACT}, - {"role": "user", "content": prompt}, - ], - "temperature": 0.05, - "response_format": {"type": "json_object"}, - "max_tokens": MAX_TOKENS_EXTRACT, - } + payload.update(ground_value(value, str(payload.get("source_quote") or ""), source_index, + literal=f_def.get("type") not in _NON_LITERAL_TYPES)) - async def _call_with_retry() -> Dict: - """Un JSON tronqué ou un pic de charge ne doit pas condamner les 7 champs du batch.""" - for attempt in range(1, MAX_LLM_RETRIES + 1): - try: - async with llm_sem: - resp = await client.post(f"{VLLM_URL}/chat/completions", json=payload, timeout=180.0) - resp.raise_for_status() - return json.loads(resp.json()["choices"][0]["message"]["content"]) - except (httpx.PoolTimeout, httpx.ReadError, httpx.ConnectError, - httpx.ReadTimeout, httpx.HTTPStatusError, json.JSONDecodeError) as e: - if attempt == MAX_LLM_RETRIES: - raise - logger.warning(f"LLM retry {attempt}/{MAX_LLM_RETRIES} " - f"({[f['id'] for f in fields]}): {type(e).__name__}: {e}") - await asyncio.sleep(2 * attempt) - try: - data = await _call_with_retry() - results = [] - for field in fields: - fid = str(field["id"]) - if fid in data and isinstance(data[fid], dict): - results.append({"id": field["id"], "result": data[fid]}) +async def _retrieve(client: httpx.AsyncClient, col, n_chunks: int, form_id: str, + fields: List[Dict]) -> tuple[Dict[str, List[str]], Dict[str, float]]: + """ + Extraits pertinents de chaque champ : recherche vectorielle puis rerank. + + La requête est la question débarrassée de ses consignes de format, enrichie + du contexte de structure (voir core/fields.contextualize). Un rerank en + échec ne fait plus échouer le job : le champ garde l'ordre de la recherche + vectorielle, moins fin mais exploitable. + """ + if n_chunks == 0 or not fields: + return {}, {} + queries = [f.get("retrieval_query") or f["question"] for f in fields] + q_embs = await fetch_question_embeddings(client, form_id, queries) + + async def _one(query: str, q_emb) -> tuple[List[str], float | None]: + hits = col.query(query_embeddings=[q_emb], + n_results=min(RETRIEVAL_CANDIDATES, n_chunks))["documents"][0] + try: + reranked = await fetch_rerank(client, query, hits, rerank_sem=_RERANK_SEM) + except Exception as exc: + logger.warning(f"Rerank indisponible, ordre vectoriel conservé : {type(exc).__name__}: {exc}") + return hits[:RETRIEVAL_TOP_K], None + top = reranked[:RETRIEVAL_TOP_K] + # Le meilleur score sert d'indice de pertinence du retrieval pour ce champ. + best = float(top[0].get("score", 0.0)) if top else 0.0 + return [r["document"] for r in top], best + + retrieved = await asyncio.gather(*[_one(q, e) for q, e in zip(queries, q_embs)]) + chunk_map = {str(f["id"]): cks for f, (cks, _) in zip(fields, retrieved)} + score_map = {str(f["id"]): s for f, (_, s) in zip(fields, retrieved) if s is not None} + return chunk_map, score_map + + +async def _extract_fields(client: httpx.AsyncClient, job_id: str, form_id: str, template: Dict, + col, n_chunks: int, synthesis: Dict | None, documents: List[Dict], + on_progress) -> List[Dict]: + """ + Extraction de tous les champs d'un formulaire. Partagée par le pipeline et le re-run. + + Deux modes de contexte : + - dossier intégral, quand il tient dans la fenêtre du modèle — le cas des + dossiers usuels. Aucune information ne dépend alors de la qualité du + retrieval, et vLLM ne calcule qu'une fois le préfixe commun à tous les lots ; + - extraits choisis par retrieval + rerank, au-delà. + + La synthèse médicale est transmise en entier à chaque lot. Elle était filtrée + par numéro de section selon une table écrite pour la mise en page du seul + formulaire AVS : sur les autres formulaires, les champs d'incapacité de + travail, de diagnostic ou de médecin ne recevaient pas la partie de la + synthèse qui les concernait. + """ + fields = prepare_fields(template) + fields_by_id = {str(f["id"]): f for f in fields} + batches = batch_fields(fields, max_batch_size=MAX_BATCH_SIZE, max_family_size=MAX_FAMILY_SIZE) + if not batches: + return [] + + source_index = SourceIndex(documents, _synthesis_as_source(synthesis)) + docs_text = extraction.full_documents_text(documents) + synthesis_json = json.dumps(synthesis, ensure_ascii=False) if synthesis else None + + # Budget : fenêtre du modèle − génération − marge − parties fixes du prompt. + fields_tokens = max( + extraction.estimate_tokens("\n".join( + (f.get("prompt_question") or f["question"]) + " ".join(f.get("options") or []) + " " * 40 + for f in batch)) + for batch in batches) + prompt_budget = (extraction.LLM_MAX_MODEL_LEN - extraction.MAX_TOKENS_EXTRACT + - extraction.PROMPT_SAFETY_TOKENS + - extraction.estimate_tokens(SYSTEM_PROMPT_BATCH_EXTRACT) - fields_tokens) + docs_budget = prompt_budget - (extraction.estimate_tokens(synthesis_json) if synthesis_json else 0) + if synthesis_json and docs_budget < 3000: + logger.warning(f"[{job_id[:8]}] Synthèse trop volumineuse pour le prompt d'extraction — omise") + synthesis_json, docs_budget = None, prompt_budget + + full_mode = extraction.estimate_tokens(docs_text) <= min(extraction.FULL_CONTEXT_MAX_TOKENS, docs_budget) + rag_budget = max(1000, min(extraction.RAG_CONTEXT_TOKENS, docs_budget)) + + if full_mode: + field_chunk_map, field_score_map = {}, {} + logger.info(f"[{job_id[:8]}] {len(fields)} champs, {len(batches)} lots — dossier intégral " + f"(~{extraction.estimate_tokens(docs_text)} tokens)") + else: + field_chunk_map, field_score_map = await _retrieve(client, col, n_chunks, form_id, fields) + logger.info(f"[{job_id[:8]}] {len(fields)} champs, {len(batches)} lots — extraits " + f"(budget {rag_budget} tokens/lot)") + + def build_messages(batch: List[Dict], scale: float) -> List[Dict]: + if full_mode and scale >= 1.0: + return build_batch_extraction_messages(batch, synthesis_json, docs_text, full_documents=True) + if full_mode: + # Repli rare : l'estimation a sous-évalué le dossier. On tronque plutôt + # que d'abandonner le champ. + limit = int(docs_budget * scale) * 3 + return build_batch_extraction_messages(batch, synthesis_json, docs_text[:limit]) + if not field_chunk_map: + # Index vide (embeddings indisponibles) : le début du dossier plutôt + # que rien. + return build_batch_extraction_messages(batch, synthesis_json, + docs_text[:int(rag_budget * scale) * 3]) + context = extraction.build_rag_context(batch, field_chunk_map, int(rag_budget * scale)) + return build_batch_extraction_messages(batch, synthesis_json, context) + + done = 0 + + async def _run_batch(batch: List[Dict]) -> List[Dict]: + nonlocal done + result = await extraction.extract_batch(client, VLLM_URL, VLLM_MODEL, batch, + build_messages, _LLM_SEM) + _annotate_confidence(result, source_index, field_score_map, fields_by_id) + done += 1 + on_progress(done, len(batches)) + return result + + batch_results = await asyncio.gather(*[_run_batch(b) for b in batches]) + results = [item for batch in batch_results for item in batch] + failed = sum(1 for r in results if "error" in r) + if failed: + logger.warning(f"[{job_id[:8]}] {failed}/{len(results)} champs en erreur d'extraction") + return results + + +_FIELD_ERRORS_WARNING = "champ(s) n'ont pas pu être extraits — à compléter à la main." + + +def _record_field_errors(job: Dict, results: List[Dict]) -> None: + """Avertissement sur les champs en erreur, recalculé à chaque passage (re-run compris).""" + warnings = [w for w in job.setdefault("warnings", []) if not w.endswith(_FIELD_ERRORS_WARNING)] + failed = sum(1 for r in results if "error" in r) + if failed: + warnings.append(f"{failed} {_FIELD_ERRORS_WARNING}") + job["warnings"] = warnings + + +def _template_fields_view(template: Dict) -> List[Dict]: + """Description des champs extraits, pour /fields et le chat.""" + return [ + {"id": f["id"], "label": f.get("label", str(f["id"])), + "question": f.get("question", ""), "section": str(f["id"]).split(".")[0]} + for f in prepare_fields(template) + ] + + +def _fill_pdf(job_id: str, form_id: str, template: Dict, results: List[Dict], + tmp_dir: Path, suffix: str = "") -> tuple[Path, str]: + """ + Écrit les valeurs dans le formulaire PDF. Partagé par le pipeline et le re-run. + + Supporte XFA, AcroForm pur et formulaires hybrides. Le re-run en avait une + copie qui ignorait les champs calculés et le destinataire cantonal : après + un re-run, un formulaire AI repartait à l'office AI par défaut du gabarit. + + Returns: + (chemin du PDF rempli, type de formulaire) + """ + source_template_path = Path(f"forms/Form_{form_id}.pdf") + if not source_template_path.exists(): + raise FileNotFoundError(f"Template introuvable pour form_id={form_id}") + empty_form_path = tmp_dir / f"empty{suffix}.pdf" + shutil.copy(source_template_path, empty_form_path) + + form_type = detect_form_type(empty_form_path) + logger.info(f"[{job_id[:8]}] Type de formulaire détecté : {form_type}") + + xfa_values, acro_values = collect_form_values(template, results) + output_pdf = tmp_dir / f"output{suffix}.pdf" + if output_pdf.exists(): + output_pdf.unlink() + + if form_type in ("xfa", "hybrid"): + base_xml = tmp_dir / f"base{suffix}.xml" + try: + extract_xfa_datasets(empty_form_path, base_xml) + checkbox_paths = discover_checkbox_paths(base_xml) + normalize_checkboxes(xfa_values, checkbox_paths) + filled_xml = tmp_dir / f"filled{suffix}.xml" + update_datasets(base_xml, xfa_values, filled_xml, template["fields"]) + inject_datasets(empty_form_path, filled_xml, output_pdf) + except PDFNoXFAError: + logger.warning(f"[{job_id[:8]}] XFA introuvable malgré détection hybrid — fallback AcroForm") + form_type = "acroform" + + if form_type == "acroform" or (form_type == "hybrid" and acro_values): + # Pour un hybride : repartir du PDF XFA déjà rempli si disponible, sinon template + acro_source = output_pdf if (form_type == "hybrid" and output_pdf.exists()) else empty_form_path + fill_acroform(acro_source, acro_values, output_pdf) + + if form_type == "none" or not output_pdf.exists(): + raise ValueError(f"Impossible de remplir le formulaire (type={form_type})") + return output_pdf, form_type + + +def _load_template(form_id: str) -> Dict: + with open(f"template/Form_{form_id}.json", "r", encoding="utf-8") as f: + return json.load(f) + + +MAX_OCR_RETRIES = 5 + + +async def _ocr_document(client: httpx.AsyncClient, path: Path) -> str: + """ + Texte d'un PDF via le service OCR, avec reprises proportionnées à la cause. + + Service injoignable (redémarrage) : jusqu'à 5 tentatives espacées. Délai + dépassé ou erreur 5xx : une seule reprise — un OCR de dix minutes rejoué + cinq fois, c'est une heure perdue. Erreur 4xx : le PDF est refusé, inutile + d'insister. + """ + content = path.read_bytes() + last_err: Exception | None = None + for attempt in range(1, MAX_OCR_RETRIES + 1): + try: + resp = await client.post( + f"{MARKER_URL}/extract", + files={'file': (path.name, content, 'application/pdf')}, + timeout=httpx.Timeout(600.0, connect=15.0)) + resp.raise_for_status() + return resp.json().get("markdown", "") or "" + except httpx.HTTPStatusError as e: + if e.response.status_code < 500 or attempt >= 2: + raise + last_err, delay = e, 5 + except (httpx.TimeoutException, httpx.RemoteProtocolError) as e: + if isinstance(e, httpx.ConnectTimeout): + last_err, delay = e, 10 * attempt + elif attempt >= 2: + raise else: - results.append({"id": field["id"], "error": "field_missing_in_response"}) - return results - except Exception as e: - logger.error(f"Erreur batch vLLM ({[f['id'] for f in fields]}): {e}") - return [{"id": f["id"], "error": "extraction_failed"} for f in fields] + last_err, delay = e, 5 + except httpx.NetworkError as e: + last_err = e + delay = 10 * attempt if isinstance(e, httpx.ConnectError) else 2 * attempt + logger.warning(f"OCR retry {attempt}/{MAX_OCR_RETRIES} pour {path.name}: " + f"{type(last_err).__name__} (retry in {delay}s)") + if attempt < MAX_OCR_RETRIES: + await asyncio.sleep(delay) + raise last_err or RuntimeError("OCR sans réponse") async def run_pipeline_task(job_id: str, form_id: str, tmp_dir: Path, report_paths: List[Path]): @@ -593,31 +698,39 @@ async def run_pipeline_task(job_id: str, form_id: str, tmp_dir: Path, report_pat produire de plus vite. Le chronomètre court pendant l'attente — c'est bien le temps que l'utilisateur subit. """ - if _PIPELINE_SEM.locked(): - attente = stats.summary().get("recent_average_seconds") - repere = f" — environ {attente / 60:.0f} min" if attente else "" - logger.info(f"[{job_id[:8]}] En file d'attente ({MAX_PARALLEL_JOBS} exécutions en cours)") - JOBS[job_id].update({ - "status": "processing", - "message": f"⏳ En file d'attente{repere}…", - "progress": 2, - }) + _announce_queue(job_id) async with _PIPELINE_SEM: await _run_pipeline(job_id, form_id, tmp_dir, report_paths) +def _announce_queue(job_id: str) -> None: + if not _PIPELINE_SEM.locked(): + return + attente = stats.summary().get("recent_average_seconds") + repere = f" — environ {attente / 60:.0f} min" if attente else "" + logger.info(f"[{job_id[:8]}] En file d'attente ({MAX_PARALLEL_JOBS} exécutions en cours)") + JOBS[job_id].update({ + "status": "processing", + "message": f"⏳ En file d'attente{repere}…", + "progress": 2, + }) + + async def _run_pipeline(job_id: str, form_id: str, tmp_dir: Path, report_paths: List[Path]): """ Pipeline avec streaming OCR→embed, synthèse médicale globale et semaphores rerank/LLM. - STEP 1 : OCR et embedding en parallèle via asyncio.Queue - STEP 2 : Synthèse médicale globale (LLM lit tous les documents, produit un JSON structuré) - - STEP 3 : RAG hybride (synthèse + chunks reranked) pour chaque champ - - STEP 4 : Injection XFA + - STEP 3 : Extraction des champs (dossier intégral ou RAG, selon sa taille) + - STEP 4 : Injection XFA / AcroForm """ collection_name = f"col_{job_id}" debug_dir = DEBUG_LOG_DIR / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{job_id[:8]}" debug_dir.mkdir(parents=True, exist_ok=True) (debug_dir / "markdown").mkdir(exist_ok=True) + # Connu dès le départ : un job en échec doit lui aussi voir ses journaux purgés. + JOBS[job_id]["_debug_dir"] = str(debug_dir) + warnings: List[str] = JOBS[job_id].setdefault("warnings", []) timings: Dict[str, float] = {} # Résultats OCR bruts (nécessaires pour la synthèse) @@ -638,100 +751,101 @@ async def _run_pipeline(job_id: str, form_id: str, tmp_dir: Path, report_paths: t_ocr_start = time.perf_counter() ocr_done = 0 - ocr_sem = _OCR_SEM - MAX_OCR_RETRIES = 5 col = chroma_client.get_or_create_collection(name=collection_name) chunk_index = 0 - all_chunks: List[str] = [] - # Queue pour le pipeline OCR → embed + # Queue pour le pipeline OCR → embed ; None marque la fin du flux. ocr_queue: asyncio.Queue = asyncio.Queue() - embed_done = asyncio.Event() - async def _ocr_one(path: Path) -> tuple: + async def _ocr_one(path: Path) -> None: """OCR un PDF et envoie le résultat dans la queue pour embedding.""" nonlocal ocr_done - async with ocr_sem: - with open(path, "rb") as f: - content = f.read() - last_err = None - for attempt in range(1, MAX_OCR_RETRIES + 1): - try: - resp = await client.post( - f"{MARKER_URL}/extract", - files={'file': (path.name, content, 'application/pdf')}, - timeout=600.0) - resp.raise_for_status() - md_text = resp.json().get("markdown", "") - ocr_done += 1 - JOBS[job_id].update({ - "message": f"📄 OCR {ocr_done}/{total_files} documents...", - "progress": 5 + int(35 * ocr_done / total_files) - }) - # Debug : sauvegarder le markdown OCR - md_filename = path.stem + ".md" - (debug_dir / "markdown" / md_filename).write_text(md_text, encoding="utf-8") - # Accumuler pour la synthèse médicale - ocr_raw_results.append({"filename": path.name, "markdown": md_text}) - # Envoyer dans la queue pour embedding immédiat - await ocr_queue.put((path.name, md_text)) - return path.name, md_text - except (httpx.ReadError, httpx.ConnectError, httpx.RemoteProtocolError) as e: - last_err = e - delay = 10 * attempt if isinstance(e, httpx.ConnectError) else 2 * attempt - logger.warning(f"[{job_id[:8]}] OCR retry {attempt}/{MAX_OCR_RETRIES} pour {path.name}: {type(e).__name__} (retry in {delay}s)") - if attempt < MAX_OCR_RETRIES: - await asyncio.sleep(delay) - raise last_err + async with _OCR_SEM: + try: + md_text = await _ocr_document(client, path) + except Exception as e: + # Un document illisible ne condamne plus tout le dossier : il est + # écarté, et l'utilisateur en est averti. + logger.error(f"[{job_id[:8]}] OCR abandonné pour {path.name}: " + f"{type(e).__name__}: {e}") + warnings.append(f"Document illisible, ignoré : {path.name}") + md_text = None + ocr_done += 1 + JOBS[job_id].update({ + "message": f"📄 OCR {ocr_done}/{total_files} documents...", + "progress": 5 + int(35 * ocr_done / total_files) + }) + if md_text is None: + return + if not md_text.strip(): + warnings.append(f"Aucun texte trouvé dans : {path.name}") + return + # Debug : sauvegarder le markdown OCR + (debug_dir / "markdown" / (path.stem + ".md")).write_text(md_text, encoding="utf-8") + ocr_raw_results.append({"filename": path.name, "markdown": md_text}) + await ocr_queue.put((path.name, md_text)) async def _embed_consumer(): """Consomme la queue OCR, chunk et embed au fil de l'eau.""" nonlocal chunk_index pending_chunks: List[str] = [] + embed_failed = False - while True: + async def _flush(): + nonlocal chunk_index, pending_chunks, embed_failed + if not pending_chunks or embed_failed: + pending_chunks = [] + return try: - name, md_text = await asyncio.wait_for(ocr_queue.get(), timeout=2.0) - except asyncio.TimeoutError: - if ocr_queue.empty() and embed_done.is_set(): - break - continue - - doc_md = f"\n\n--- SOURCE: {name} ---\n\n" + md_text - doc_chunks = markdown_semantic_chunking(doc_md) - pending_chunks.extend(doc_chunks) - all_chunks.extend(doc_chunks) - - # Embed quand on a accumulé assez de chunks - if len(pending_chunks) >= 64 or (embed_done.is_set() and ocr_queue.empty()): - if pending_chunks: - embeds = await fetch_embeddings_batched(client, pending_chunks) - ids = [f"{job_id}_{chunk_index + i}" for i in range(len(pending_chunks))] - col.add(documents=pending_chunks, embeddings=embeds, ids=ids) - chunk_index += len(pending_chunks) - logger.info(f"[{job_id[:8]}] Embedded {chunk_index} chunks so far...") - pending_chunks = [] - - # Flush les chunks restants - if pending_chunks: - embeds = await fetch_embeddings_batched(client, pending_chunks) + embeds = await fetch_embeddings_batched(client, pending_chunks) + except Exception as e: + # L'index ne sert qu'au retrieval des gros dossiers, au chat et au + # re-run : son absence dégrade, elle ne justifie pas d'échouer. + logger.error(f"[{job_id[:8]}] Embeddings indisponibles : {type(e).__name__}: {e}") + warnings.append("Recherche sémantique indisponible : le chat sur le dossier " + "sera limité.") + embed_failed, pending_chunks = True, [] + return ids = [f"{job_id}_{chunk_index + i}" for i in range(len(pending_chunks))] col.add(documents=pending_chunks, embeddings=embeds, ids=ids) chunk_index += len(pending_chunks) + logger.info(f"[{job_id[:8]}] Embedded {chunk_index} chunks so far...") + pending_chunks = [] + + while True: + item = await ocr_queue.get() + if item is None: + break + name, md_text = item + pending_chunks.extend(extraction.chunk_document(name, md_text)) + if len(pending_chunks) >= 64: + await _flush() + await _flush() # Lancer OCR et embedding en parallèle (pipeline) embed_task = asyncio.create_task(_embed_consumer()) - await asyncio.gather(*[_ocr_one(p) for p in report_paths]) - embed_done.set() + try: + await asyncio.gather(*[_ocr_one(p) for p in report_paths]) + finally: + # Toujours clore le flux : sans ce marqueur, le consommateur + # attendait indéfiniment après une erreur d'OCR. + await ocr_queue.put(None) await embed_task + # Ordre stable : l'OCR rend les documents dans l'ordre où il les finit. + ocr_raw_results.sort(key=lambda d: d["filename"]) + if not ocr_raw_results: + raise UserFacingError( + "Aucun texte n'a pu être extrait des documents fournis. " + "Vérifiez qu'il s'agit bien de rapports lisibles (PDF non vides, non protégés).") + t_ocr_end = time.perf_counter() timings["ocr_embed_pipeline"] = t_ocr_end - t_ocr_start - chunks = all_chunks - logger.info(f"[{job_id[:8]}] Pipeline OCR+embed terminé: {len(chunks)} chunks en {timings['ocr_embed_pipeline']:.1f}s") + logger.info(f"[{job_id[:8]}] Pipeline OCR+embed terminé: {chunk_index} chunks " + f"en {timings['ocr_embed_pipeline']:.1f}s") # ============================================================ - # STEP 2 : Synthèse médicale globale (nouveau) + # STEP 2 : Synthèse médicale globale # ============================================================ JOBS[job_id].update({ "status": "processing", @@ -741,19 +855,11 @@ async def _embed_consumer(): t_synthesis_start = time.perf_counter() synthesis = await run_medical_synthesis(ocr_raw_results, VLLM_URL, VLLM_MODEL) timings["medical_synthesis"] = time.perf_counter() - t_synthesis_start - - # Index de provenance : construit sur le texte OCR complet, pas sur - # les chunks retenus. Une citation exacte tirée d'un document classé - # hors du top-24 passait sinon pour inventée. - derived_sources = _synthesis_as_source(synthesis) - source_index = SourceIndex(ocr_raw_results, derived_sources) JOBS[job_id]["_source_documents"] = ocr_raw_results - synthesis_json: str | None = None if synthesis: - synthesis_json = json.dumps(synthesis, ensure_ascii=False) - nb_dx = len(synthesis.get("diagnostics", [])) - nb_it = len(synthesis.get("incapacites_travail", [])) + nb_dx = len(synthesis.get("diagnostics") or []) + nb_it = len(synthesis.get("incapacites_travail") or []) logger.info( f"[{job_id[:8]}] Synthèse OK en {timings['medical_synthesis']:.1f}s " f"— {nb_dx} diagnostics, {nb_it} périodes d'incapacité" @@ -763,96 +869,33 @@ async def _embed_consumer(): json.dumps(synthesis, ensure_ascii=False, indent=2), encoding="utf-8" ) else: - logger.warning(f"[{job_id[:8]}] Synthèse échouée — mode RAG pur activé") + logger.warning(f"[{job_id[:8]}] Synthèse échouée — extraction sur les seuls documents") + warnings.append("Synthèse médicale indisponible : extraction faite sur les seuls documents.") # ============================================================ - # STEP 3 : RAG hybride — rerank par champ + batch LLM par section - # Phase A : retrieval + rerank en parallèle (sem=5), top-8 chunks/champ - # Phase B : batch LLM par section (sem=10), ~7 champs par appel + # STEP 3 : Extraction des champs # ============================================================ t_rag_start = time.perf_counter() JOBS[job_id].update({"status": "processing", "message": "🤖 Analyse LLM et extraction des entités...", "progress": 50}) - with open(f"template/Form_{form_id}.json", "r") as f: - template = json.load(f) - - # Une question vide ne produit qu'un embedding parasite : les templates - # générés par tools/gen_template.py la laissent à "" tant qu'elle n'est - # pas rédigée. - fields_with_q = _extractable_fields(template) - n_fields = len(fields_with_q) - logger.info(f"[{job_id[:8]}] Step 3: {n_fields} champs — phase A rerank, phase B batch-LLM...") - - all_q_embs = await fetch_question_embeddings( - client, form_id, [f["question"] for f in fields_with_q]) - - rerank_sem, llm_sem = _RERANK_SEM, _LLM_SEM - - # --- Phase A : retrieval + rerank par champ (top-12 chunks) --- - async def _retrieve_for_field(field: Dict, q_emb) -> tuple[List[str], float]: - hits = col.query(query_embeddings=[q_emb], n_results=min(30, len(chunks)))["documents"][0] - reranked = await fetch_rerank(client, field["question"], hits, rerank_sem=rerank_sem) - top = reranked[:12] - # Le meilleur score sert d'indice de pertinence du retrieval pour ce champ. - best_score = float(top[0].get("score", 0.0)) if top else 0.0 - return [r["document"] for r in top], best_score - - retrieved: List[tuple[List[str], float]] = await asyncio.gather(*[ - _retrieve_for_field(f, emb) for f, emb in zip(fields_with_q, all_q_embs) - ]) - field_chunk_map: Dict[str, List[str]] = { - str(f["id"]): cks for f, (cks, _) in zip(fields_with_q, retrieved) - } - field_score_map: Dict[str, float] = { - str(f["id"]): score for f, (_, score) in zip(fields_with_q, retrieved) - } + template = _load_template(form_id) - # --- Phase B : batch LLM par section --- - batches = _group_fields_into_batches(fields_with_q, max_batch_size=7) - batches_done = 0 - - async def _extract_batch(batch_fields: List[Dict]) -> List[Dict]: - nonlocal batches_done - # Fusionner les chunks uniques du batch (max 16 chunks fusionnés) - seen: set = set() - merged_chunks: List[str] = [] - for bf in batch_fields: - for c in field_chunk_map.get(str(bf["id"]), []): - if c not in seen: - seen.add(c) - merged_chunks.append(c) - chunks_ctx = "\n---\n".join(merged_chunks[:24]) - - # Filtrer la synthèse sur la section de ce batch - section = str(batch_fields[0]["id"]).split(".")[0] - filtered_synthesis = _filter_synthesis_for_section(synthesis, section) - - result = await extract_fields_batch_vllm( - client, batch_fields, chunks_ctx, filtered_synthesis, llm_sem - ) - _annotate_confidence(result, source_index, field_score_map) - batches_done += 1 + def _progress(done: int, total: int) -> None: JOBS[job_id].update({ - "message": f"🤖 Extraction {batches_done}/{len(batches)} sections...", - "progress": 50 + int(40 * batches_done / len(batches)) + "message": f"🤖 Extraction {done}/{total} sections...", + "progress": 50 + int(40 * done / total), }) - return result - batch_results = await asyncio.gather(*[_extract_batch(b) for b in batches]) - results = [item for batch in batch_results for item in batch] - t_rag_end = time.perf_counter() - timings["rag_extraction"] = t_rag_end - t_rag_start + results = await _extract_fields(client, job_id, form_id, template, col, chunk_index, + synthesis, ocr_raw_results, _progress) + timings["rag_extraction"] = time.perf_counter() - t_rag_start # Stocker les résultats bruts pour le debug/eval + chat JOBS[job_id]["_debug_results"] = results - JOBS[job_id]["_debug_chunks_count"] = len(chunks) + JOBS[job_id]["_debug_chunks_count"] = chunk_index JOBS[job_id]["_debug_synthesis"] = synthesis # Stocker les définitions de champs pour l'endpoint /fields - JOBS[job_id]["_template_fields"] = [ - {"id": f["id"], "label": f.get("label", str(f["id"])), - "question": f.get("question", ""), "section": str(f["id"]).split(".")[0]} - for f in fields_with_q - ] + JOBS[job_id]["_template_fields"] = _template_fields_view(template) # Debug : sauvegarder les résultats LLM results_debug = [] @@ -860,7 +903,9 @@ async def _extract_batch(batch_fields: List[Dict]) -> List[Dict]: entry = {"field_id": r.get("id")} if "result" in r: entry["value"] = r["result"].get("value") + entry["raw_value"] = r["result"].get("raw_value") entry["source_quote"] = r["result"].get("source_quote") + entry["grounding"] = r["result"].get("grounding") if "error" in r: entry["error"] = r["error"] results_debug.append(entry) @@ -869,85 +914,11 @@ async def _extract_batch(batch_fields: List[Dict]) -> List[Dict]: # ============================================================ # STEP 4 : Injection des valeurs dans le formulaire PDF - # Supporte XFA, AcroForm pur et formulaires hybrides # ============================================================ t_xfa_start = time.perf_counter() JOBS[job_id].update({"status": "processing", "message": "✍️ Injection des données dans le formulaire PDF...", "progress": 90}) - empty_form_path = tmp_dir / "empty.pdf" - - source_template_path = Path(f"forms/Form_{form_id}.pdf") - if not source_template_path.exists(): - raise FileNotFoundError(f"Template introuvable pour form_id={form_id}") - shutil.copy(source_template_path, empty_form_path) - - # Détecter le type de formulaire - form_type = detect_form_type(empty_form_path) - logger.info(f"[{job_id[:8]}] Type de formulaire détecté : {form_type}") - - # Construire les valeurs extraites par champ - xfa_values: Dict[str, str] = {} # xml_path → valeur - acro_values: Dict[str, str] = {} # acroform_name → valeur - - for res in results: - if "result" not in res or not res["result"].get("value"): - continue - value = str(res["result"]["value"]) - f_def = next((f for f in template["fields"] if f.get("id") == res["id"]), None) - if f_def is None: - continue - # Normalisation selon le type déclaré dans le template - value = _normalize_field_value(value, f_def.get("type")) - value = _resolve_option_value(value, f_def) - # XFA path - if f_def.get("xml_path"): - xfa_values[f_def["xml_path"]] = value - # AcroForm name (champ optionnel dans le template JSON) - if f_def.get("acroform_name"): - acro_values[f_def["acroform_name"]] = value - - for f_def, value in _computed_values(template): - if f_def.get("xml_path"): - xfa_values[f_def["xml_path"]] = value - if f_def.get("acroform_name"): - acro_values[f_def["acroform_name"]] = value - - # Le destinataire dépend du canton : il écrase aussi bien les valeurs - # par défaut du gabarit que ce que le modèle aurait pu produire. - for nom, value in _canton_recipient(template, results).items(): - for f_def in template["fields"]: - if f_def.get("name") != nom: - continue - if f_def.get("xml_path"): - xfa_values[f_def["xml_path"]] = value - if f_def.get("acroform_name"): - acro_values[f_def["acroform_name"]] = value - - output_pdf = tmp_dir / "output.pdf" - - if form_type in ("xfa", "hybrid"): - # --- Injection XFA --- - base_xml = tmp_dir / "base.xml" - try: - extract_xfa_datasets(empty_form_path, base_xml) - checkbox_paths = discover_checkbox_paths(base_xml) - normalize_checkboxes(xfa_values, checkbox_paths) - filled_xml = tmp_dir / "filled.xml" - update_datasets(base_xml, xfa_values, filled_xml, template["fields"]) - # Pour un hybride, on part du template pour la base XFA - inject_datasets(empty_form_path, filled_xml, output_pdf) - except PDFNoXFAError: - logger.warning(f"[{job_id[:8]}] XFA introuvable malgré détection hybrid — fallback AcroForm") - form_type = "acroform" - - if form_type == "acroform" or (form_type == "hybrid" and acro_values): - # --- Injection AcroForm --- - # Pour un hybride : repartir du PDF XFA déjà rempli si disponible, sinon template - acro_source = output_pdf if (form_type == "hybrid" and output_pdf.exists()) else empty_form_path - fill_acroform(acro_source, acro_values, output_pdf) - - if form_type == "none" or not output_pdf.exists(): - raise ValueError(f"Impossible de remplir le formulaire (type={form_type})") + output_pdf, form_type = _fill_pdf(job_id, form_id, template, results, tmp_dir) t_xfa_end = time.perf_counter() timings["xfa_injection"] = t_xfa_end - t_xfa_start @@ -957,24 +928,26 @@ async def _extract_batch(batch_fields: List[Dict]) -> List[Dict]: ok_count = sum(1 for r in results if "result" in r and r["result"].get("value")) err_count = sum(1 for r in results if "error" in r) empty_count = sum(1 for r in results if "result" in r and not r["result"].get("value")) - synthesis_info = "Non disponible (fallback RAG pur)" + _record_field_errors(JOBS[job_id], results) + synthesis_info = "Non disponible (extraction sur les seuls documents)" if synthesis: - nb_dx = len(synthesis.get("diagnostics", [])) - nb_it = len(synthesis.get("incapacites_travail", [])) + nb_dx = len(synthesis.get("diagnostics") or []) + nb_it = len(synthesis.get("incapacites_travail") or []) synthesis_info = f"{nb_dx} diagnostics, {nb_it} périodes d'incapacité" summary = ( f"Job: {job_id}\nForm: {form_id}\nDate: {datetime.now().isoformat()}\n" - f"Fichiers: {total_files}\nChunks: {len(chunks)}\n" + f"Fichiers: {total_files} (lus: {len(ocr_raw_results)})\nChunks: {chunk_index}\n" f"Type formulaire: {form_type}\n" f"Synthèse médicale: {synthesis_info}\n" - f"Champs: {n_fields} (OK: {ok_count}, Erreurs: {err_count}, Vides: {empty_count})\n" + f"Champs: {len(results)} (OK: {ok_count}, Erreurs: {err_count}, Vides: {empty_count})\n" f"Timings: OCR+embed={timings['ocr_embed_pipeline']:.1f}s, " f"Synthèse={timings.get('medical_synthesis', 0):.1f}s, " - f"RAG={timings['rag_extraction']:.1f}s, Injection={timings['xfa_injection']:.1f}s, " + f"Extraction={timings['rag_extraction']:.1f}s, Injection={timings['xfa_injection']:.1f}s, " f"Total={timings['total']:.1f}s\n" ) (debug_dir / "summary.txt").write_text(summary, encoding="utf-8") - logger.info(f"[{job_id[:8]}] Pipeline terminé en {timings['total']:.1f}s — Debug: {debug_dir}") + logger.info(f"[{job_id[:8]}] Pipeline terminé en {timings['total']:.1f}s — " + f"{ok_count}/{len(results)} champs remplis — Debug: {debug_dir}") # Durée vécue par l'utilisateur : de la soumission au formulaire prêt, # transfert et attente comprises. `timings["total"]` ne couvre que le @@ -993,9 +966,16 @@ async def _extract_batch(batch_fields: List[Dict]) -> List[Dict]: "duration_s": round(elapsed, 1), "_form_id": form_id, "_tmp_dir": str(tmp_dir), - "_debug_dir": str(debug_dir), }) + except UserFacingError as e: + logger.warning(f"Job {job_id} refusé : {e}") + JOBS[job_id].update({ + "status": "failed", + "message": str(e), + "progress": 0, + "completed_at": time.time() + }) except Exception as e: # [SEC-10] Log complet côté serveur, message générique côté client logger.error(f"Erreur Job {job_id}: {e}", exc_info=True) @@ -1132,6 +1112,9 @@ async def get_status(job_id: str): "progress": job.get("progress"), "elapsed_s": round(time.time() - started, 1) if started else None, "duration_s": job.get("duration_s"), + # Ce que l'utilisateur doit savoir sans que le job échoue : document + # illisible écarté, champs à compléter à la main. + "warnings": job.get("warnings", []), } @@ -1144,7 +1127,7 @@ async def get_fields(job_id: str, token: str = ""): if job_id not in JOBS or JOBS[job_id].get("status") != "completed": raise HTTPException(status_code=404, detail="Session introuvable ou traitement non terminé.") expected_token = JOBS[job_id].get("token", "") - if expected_token and token != expected_token: + if expected_token and not secrets.compare_digest(token, expected_token): raise HTTPException(status_code=403, detail="Token invalide.") template_fields = JOBS[job_id].get("_template_fields", []) @@ -1168,6 +1151,9 @@ async def get_fields(job_id: str, token: str = ""): "source_page": payload.get("source_page"), "source_excerpt": payload.get("source_excerpt"), "source_match": payload.get("source_match"), + # Réponse du modèle avant normalisation, quand elle diffère. + "raw_value": payload.get("raw_value"), + "error": r.get("error"), }) return {"fields": fields_out} @@ -1178,6 +1164,7 @@ async def get_fields(job_id: str, token: str = ""): class ChatRequest(BaseModel): job_id: str message: str + token: str = "" history: List[Dict[str, Any]] = [] @@ -1193,6 +1180,11 @@ async def chat_endpoint(request: ChatRequest): job = JOBS.get(request.job_id) if not job or job.get("status") != "completed": raise HTTPException(status_code=404, detail="Session introuvable ou traitement non terminé.") + # Le chat lit le dossier patient : même contrôle que /fields et /download, + # qui l'exigeaient déjà alors que celui-ci s'en passait. + expected_token = job.get("token", "") + if expected_token and not secrets.compare_digest(request.token, expected_token): + raise HTTPException(status_code=403, detail="Token invalide.") synthesis = job.get("_debug_synthesis") synthesis_json = json.dumps(synthesis, ensure_ascii=False) if synthesis else None @@ -1309,24 +1301,23 @@ async def get_synthesis(job_id: str, token: str = ""): @app.post("/synthesis/{job_id}/generate") async def generate_synthesis(job_id: str, token: str = ""): - """(Re)génère la synthèse médicale depuis les fichiers OCR sauvegardés sur disque.""" + """(Re)génère la synthèse médicale depuis le texte OCR conservé avec le job.""" job = _validate_job_token(job_id, token) - debug_dir = Path(job.get("_debug_dir", "")) - markdown_dir = debug_dir / "markdown" - if not markdown_dir.exists(): - raise HTTPException(status_code=404, detail="Fichiers OCR introuvables (session expirée ou trop ancienne).") - - ocr_results = [] - for md_file in sorted(markdown_dir.glob("*.md")): - try: - ocr_results.append({"filename": md_file.name, "markdown": md_file.read_text(encoding="utf-8")}) - except Exception: - pass + # Le texte OCR vit avec le job ; les journaux de debug, purgés avec lui, ne + # servent plus que de secours pour un job antérieur à ce changement. + ocr_results = list(job.get("_source_documents") or []) + if not ocr_results: + markdown_dir = Path(job.get("_debug_dir", "")) / "markdown" + for md_file in sorted(markdown_dir.glob("*.md")) if markdown_dir.exists() else []: + try: + ocr_results.append({"filename": md_file.name, "markdown": md_file.read_text(encoding="utf-8")}) + except OSError: + pass if not ocr_results: - raise HTTPException(status_code=404, detail="Aucun fichier OCR trouvé pour régénérer la synthèse.") + raise HTTPException(status_code=404, detail="Texte OCR introuvable (session expirée ou trop ancienne).") - logger.info(f"[{job_id[:8]}] Régénération synthèse depuis {len(ocr_results)} fichiers OCR...") + logger.info(f"[{job_id[:8]}] Régénération synthèse depuis {len(ocr_results)} documents...") synthesis = await run_medical_synthesis(ocr_results, VLLM_URL, VLLM_MODEL) if synthesis is None: raise HTTPException(status_code=502, detail="Le LLM n'a pas pu générer la synthèse. Vérifiez les logs.") @@ -1381,6 +1372,13 @@ async def refine_synthesis(job_id: str, request: SynthesisRefineRequest): async def rerun_pipeline_task(job_id: str): + """Re-run sous le même portier que le pipeline : il sollicite autant le GPU.""" + _announce_queue(job_id) + async with _PIPELINE_SEM: + await _rerun_pipeline(job_id) + + +async def _rerun_pipeline(job_id: str): """Re-run rapide : saute l'OCR/embedding, réutilise le ChromaDB existant avec la synthèse mise à jour.""" form_id = JOBS[job_id].get("_form_id") synthesis = JOBS[job_id].get("_debug_synthesis") @@ -1393,127 +1391,31 @@ async def rerun_pipeline_task(job_id: str): col = chroma_client.get_collection(name=collection_name) col_count = col.count() - if col_count == 0: - raise ValueError("ChromaDB vide — session expirée, impossible de relancer sans re-OCR.") - - with open(f"template/Form_{form_id}.json", "r") as f: - template = json.load(f) - - fields_with_q = _extractable_fields(template) - n_fields = len(fields_with_q) - logger.info(f"[{job_id[:8]}] Re-run: {n_fields} champs, {col_count} chunks en ChromaDB.") + documents = JOBS[job_id].get("_source_documents") or [] + if not documents: + raise ValueError("Contexte du job vide — session expirée, impossible de relancer sans re-OCR.") - # Le re-run ne repasse pas par l'OCR : il réutilise le texte conservé - # avec le job. Sans lui, aucune valeur ne pourrait être rattachée. - source_index = SourceIndex(JOBS[job_id].get("_source_documents", []), - _synthesis_as_source(synthesis)) + template = _load_template(form_id) + logger.info(f"[{job_id[:8]}] Re-run: {col_count} chunks en ChromaDB.") limits = httpx.Limits(max_connections=50, max_keepalive_connections=10, keepalive_expiry=30) async with httpx.AsyncClient(limits=limits) as client: - JOBS[job_id].update({"message": "🔄 Re-run : re-encodage des questions...", "progress": 15}) - all_q_embs = await fetch_question_embeddings( - client, form_id, [f["question"] for f in fields_with_q]) - - rerank_sem, llm_sem = _RERANK_SEM, _LLM_SEM - - async def _retrieve_field(field: Dict, q_emb) -> tuple[List[str], float]: - hits = col.query(query_embeddings=[q_emb], n_results=min(30, col_count))["documents"][0] - reranked = await fetch_rerank(client, field["question"], hits, rerank_sem=rerank_sem) - top = reranked[:12] - best_score = float(top[0].get("score", 0.0)) if top else 0.0 - return [r["document"] for r in top], best_score - - JOBS[job_id].update({"message": "🔄 Re-run : retrieval + reranking...", "progress": 30}) - retrieved: List[tuple[List[str], float]] = await asyncio.gather(*[ - _retrieve_field(f, emb) for f, emb in zip(fields_with_q, all_q_embs) - ]) - field_chunk_map: Dict[str, List[str]] = { - str(f["id"]): cks for f, (cks, _) in zip(fields_with_q, retrieved) - } - field_score_map: Dict[str, float] = { - str(f["id"]): score for f, (_, score) in zip(fields_with_q, retrieved) - } + JOBS[job_id].update({"message": "🔄 Re-run : extraction...", "progress": 30}) - batches = _group_fields_into_batches(fields_with_q, max_batch_size=7) - batches_done = 0 - - async def _extract_batch_rerun(batch_fields: List[Dict]) -> List[Dict]: - nonlocal batches_done - seen: set = set() - merged: List[str] = [] - for bf in batch_fields: - for c in field_chunk_map.get(str(bf["id"]), []): - if c not in seen: - seen.add(c) - merged.append(c) - chunks_ctx = "\n---\n".join(merged[:24]) - section = str(batch_fields[0]["id"]).split(".")[0] - filtered_synthesis = _filter_synthesis_for_section(synthesis, section) - result = await extract_fields_batch_vllm(client, batch_fields, chunks_ctx, filtered_synthesis, llm_sem) - _annotate_confidence(result, source_index, field_score_map) - batches_done += 1 + def _progress(done: int, total: int) -> None: JOBS[job_id].update({ - "message": f"🔄 Re-run : extraction {batches_done}/{len(batches)}...", - "progress": 40 + int(45 * batches_done / len(batches)) + "message": f"🔄 Re-run : extraction {done}/{total}...", + "progress": 30 + int(55 * done / total), }) - return result - - batch_results = await asyncio.gather(*[_extract_batch_rerun(b) for b in batches]) - results = [item for batch in batch_results for item in batch] + results = await _extract_fields(client, job_id, form_id, template, col, col_count, + synthesis, documents, _progress) JOBS[job_id]["_debug_results"] = results - JOBS[job_id]["_template_fields"] = [ - {"id": f["id"], "label": f.get("label", str(f["id"])), - "question": f.get("question", ""), "section": str(f["id"]).split(".")[0]} - for f in fields_with_q - ] + JOBS[job_id]["_template_fields"] = _template_fields_view(template) + _record_field_errors(JOBS[job_id], results) - # Step 4: PDF fill JOBS[job_id].update({"message": "✍️ Re-run : injection dans le formulaire...", "progress": 88}) - empty_form_path = tmp_dir / "empty_rerun.pdf" - source_template_path = Path(f"forms/Form_{form_id}.pdf") - if not source_template_path.exists(): - raise FileNotFoundError(f"Template PDF introuvable pour form_id={form_id}") - shutil.copy(source_template_path, empty_form_path) - - form_type = detect_form_type(empty_form_path) - xfa_values: Dict[str, str] = {} - acro_values: Dict[str, str] = {} - - for res in results: - if "result" not in res or not res["result"].get("value"): - continue - value = str(res["result"]["value"]) - f_def = next((f for f in template["fields"] if f.get("id") == res["id"]), None) - if f_def is None: - continue - value = _normalize_field_value(value, f_def.get("type")) - value = _resolve_option_value(value, f_def) - if f_def.get("xml_path"): - xfa_values[f_def["xml_path"]] = value - if f_def.get("acroform_name"): - acro_values[f_def["acroform_name"]] = value - - output_pdf = tmp_dir / "output_rerun.pdf" - - if form_type in ("xfa", "hybrid"): - base_xml = tmp_dir / "base_rerun.xml" - try: - extract_xfa_datasets(empty_form_path, base_xml) - checkbox_paths = discover_checkbox_paths(base_xml) - normalize_checkboxes(xfa_values, checkbox_paths) - filled_xml = tmp_dir / "filled_rerun.xml" - update_datasets(base_xml, xfa_values, filled_xml, template["fields"]) - inject_datasets(empty_form_path, filled_xml, output_pdf) - except PDFNoXFAError: - form_type = "acroform" - - if form_type == "acroform" or (form_type == "hybrid" and acro_values): - acro_source = output_pdf if (form_type == "hybrid" and output_pdf.exists()) else empty_form_path - fill_acroform(acro_source, acro_values, output_pdf) - - if not output_pdf.exists(): - raise ValueError(f"Impossible de générer le formulaire re-run (type={form_type})") + output_pdf, _ = _fill_pdf(job_id, form_id, template, results, tmp_dir, suffix="_rerun") JOBS[job_id].update({ "status": "completed", @@ -1542,6 +1444,9 @@ async def trigger_rerun(job_id: str, background_tasks: BackgroundTasks, token: s raise HTTPException(status_code=409, detail="Un traitement est déjà en cours.") if not job.get("_form_id"): raise HTTPException(status_code=400, detail="Métadonnées de re-run manquantes (job trop ancien ?).") + # Marqué tout de suite : sinon un second clic, avant le démarrage de la tâche + # de fond, lançait un second re-run concurrent sur le même job. + job.update({"status": "processing", "message": "🔄 Re-run en préparation...", "progress": 2}) background_tasks.add_task(rerun_pipeline_task, job_id) return {"ok": True, "job_id": job_id} @@ -1587,6 +1492,7 @@ async def debug_results(job_id: str, token: str = ""): "value": r.get("result", {}).get("value") if "result" in r else None, "source_quote": r.get("result", {}).get("source_quote") if "result" in r else None, "quote_verified": r.get("result", {}).get("quote_verified") if "result" in r else None, + "raw_value": r.get("result", {}).get("raw_value") if "result" in r else None, "rerank_score": r.get("result", {}).get("rerank_score") if "result" in r else None, "grounding": r.get("result", {}).get("grounding") if "result" in r else None, "source_document": r.get("result", {}).get("source_document") if "result" in r else None, diff --git a/services/orchestrator/prompts.py b/services/orchestrator/prompts.py index b010f6c..1bdc661 100644 --- a/services/orchestrator/prompts.py +++ b/services/orchestrator/prompts.py @@ -269,67 +269,6 @@ def _line(f: Dict) -> str: ] -# --------------------------------------------------------------------------- -# Ancienne interface, encore importée par app.py jusqu'à sa migration -# --------------------------------------------------------------------------- - -# Mapping section → clés de la synthèse pertinentes -# La clé est le préfixe de l'ID du champ (avant le premier '.') -SECTION_SYNTHESIS_KEYS: Dict[str, List[str]] = { - "1": ["canton_traitement", "patient"], - "2": ["patient", "medecins"], - "3": ["incapacites_travail", "dates_cles"], - "4": ["diagnostics", "traitements"], - "5": ["incapacites_travail", "pronostic"], - "6": ["incapacites_travail", "traitements"], - "7": ["medecins"], - "8": ["diagnostics", "traitements"], - "9": ["medecins"], - "10": ["pronostic"], -} - - -def build_batch_extraction_prompt( - fields: List[Dict], - synthesis_json: str | None, - chunks_context: str | None, -) -> str: - """ - Construit le prompt pour extraire plusieurs champs en un seul appel LLM. - La synthèse est déjà pré-filtrée sur la section pertinente. - """ - import json as _json - - parts = [] - - if synthesis_json: - parts.append(f"SYNTHÈSE MÉDICALE (source principale) :\n{synthesis_json}") - - if chunks_context: - parts.append(f"EXTRAITS DE DOCUMENTS (source secondaire) :\n{chunks_context}") - - # Les options d'un groupe de boutons radio doivent être reprises telles quelles : - # le remplissage les apparie au libellé exact pour choisir le bouton à cocher. - def _line(f: Dict) -> str: - line = f'• [{f["id"]}] {f["question"]}' - options = f.get("options") - if options and f.get("type") == "choice": - line += " (réponds exactement par l'une de ces valeurs : " + " | ".join(options) + ")" - return line - - field_lines = "\n".join(_line(f) for f in fields) - parts.append(f"CHAMPS À EXTRAIRE :\n{field_lines}") - - # Fournir un exemple JSON avec tous les IDs pour guider le modèle - example = {str(f["id"]): {"value": "...", "source_quote": "..."} for f in fields} - parts.append( - f"RÉPONDS UNIQUEMENT avec ce JSON (tous les IDs sont obligatoires) :\n" - + _json.dumps(example, ensure_ascii=False, indent=2) - ) - - return "\n\n".join(parts) - - # --------------------------------------------------------------------------- # 4. CHAT MÉDICAL INTERACTIF # --------------------------------------------------------------------------- diff --git a/services/orchestrator/tests/test_pipeline.py b/services/orchestrator/tests/test_pipeline.py new file mode 100644 index 0000000..5621534 --- /dev/null +++ b/services/orchestrator/tests/test_pipeline.py @@ -0,0 +1,323 @@ +""" +Pipeline complet, services simulés : OCR → embeddings → synthèse → extraction → +remplissage, puis re-run et chat. + +Le transport httpx remplace marker, TEI et vLLM ; le formulaire est un vrai PDF +AcroForm (tests/pdf_factory.py) et le remplissage passe par le code de +production. Ce qui est vérifié, c'est l'enchaînement : qu'un document illisible +n'emporte pas le job, que le re-run écrive les mêmes champs dérivés que le +premier passage, que le dossier soit transmis en entier quand il tient. +""" + +import asyncio +import json +import re +import time +from pathlib import Path + +import httpx +import pytest +from fastapi.testclient import TestClient + +import app as orchestrator +import medical_synthesis +from tests.pdf_factory import make_acroform, read_fields + +DOCS = { + "01_fiche.pdf": ( + "# Fiche administrative\n\n## Identité\n\n- Nom : DUPONT\n- Prénom : Jeanne\n" + "- Date de naissance : 02.02.1985\n- Canton : Neuchâtel (NE)\n\n" + "## Contexte\n\nConsultation en urgence pour lombalgie aiguë, patiente hospitalisée." + ), + "02_consultation.pdf": ( + "# Consultation du 09.04.2026\n\nLombalgie aiguë (M54.5). Incapacité de travail à 100 % " + "du 09.04.2026 au 30.04.2026." + ), + "03_scan_illisible.pdf": None, # le service OCR échoue sur celui-ci +} + +# Réponses du « modèle » : ce qu'un LLM rendrait, écarts de format compris. +LLM_ANSWERS = { + "1.1": {"value": "NE", "source_quote": "Canton : Neuchâtel (NE)"}, + "1.2": {"value": "DUPONT", "source_quote": "Nom : DUPONT"}, + "1.3": {"value": "1985-02-02", "source_quote": "Date de naissance : 02.02.1985"}, + "1.4": {"value": "Oui", "source_quote": "Consultation en urgence"}, + "1.5": {"value": "oui", "source_quote": "patiente hospitalisée"}, + "1.6": {"value": "Non mentionné", "source_quote": ""}, +} + +SYNTHESIS = {"patient": {"nom": "DUPONT", "prenom": "Jeanne"}, "diagnostics": [], + "incapacites_travail": [], "canton_traitement": "NE"} + + +def _template(): + return { + "_reviewed": True, + "_recipient_by_canton": {"NE": {"recipientBlock": "Office AI du canton de Neuchâtel"}, + "VD": {"recipientBlock": "Office AI du canton de Vaud"}}, + "fields": [ + {"comments": "Section 1"}, + {"id": "1.1", "name": "treatmentCanton", "question": "Canton de traitement ?", + "type": "choice", "options": ["NE", "VD"], "acroform_name": "treatmentCanton"}, + {"id": "1.2", "name": "lastName", "question": "Nom du patient ?", "acroform_name": "lastName"}, + {"id": "1.3", "name": "birthDate", "question": "Date de naissance ? [format : JJ.MM.AAAA]", + "type": "date", "acroform_name": "birthDate"}, + {"id": "1.4", "name": "urgent", "question": "Consultation urgente ? Répondre par oui ou non.", + "type": "bool", "options": ["On", "Off"], "preset": "/Off", "acroform_name": "urgent"}, + {"id": "1.5", "name": "ask4Hospitalization", "question": "Hospitalisé ? Répondre par oui ou non.", + "type": "choice", "options": ["non", "oui"], "option_values": ["0", "1"], "preset": "0", + "acroform_name": "ask4Hospitalization"}, + {"id": "1.6", "name": "email", "question": "E-mail du patient ?", "acroform_name": "email"}, + {"id": "1.7", "name": "insurance", "question": "Régime ?", "type": "choice", + "options": ["LAA", "LCA"], "preset": "LAA", "acroform_name": "insurance"}, + {"id": "2.1", "name": "recipientBlock", "acroform_name": "recipientBlock", "computed": ""}, + {"id": "2.2", "name": "footer", "acroform_name": "footer", "computed": "DoctorFill — test"}, + ], + } + + +class FakeServices: + """Marker, TEI et vLLM derrière un seul transport httpx.""" + + def __init__(self): + self.extraction_prompts: list[str] = [] + self.ocr_calls: dict[str, int] = {} + + @staticmethod + def _vector(text: str) -> list[float]: + vec = [0.0] * 32 + for word in re.findall(r"\w+", text.lower()): + vec[hash(word) % 32] += 1.0 + norm = sum(v * v for v in vec) ** 0.5 or 1.0 + return [v / norm for v in vec] + + def handler(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/extract"): + body = request.content.decode("latin-1") + name = re.search(r'filename="([^"]+)"', body).group(1) + self.ocr_calls[name] = self.ocr_calls.get(name, 0) + 1 + if DOCS[name] is None: + return httpx.Response(500, json={"detail": "Erreur lors de l'extraction du PDF."}) + return httpx.Response(200, json={"markdown": DOCS[name], "status": "success"}) + if path.endswith("/embed"): + texts = json.loads(request.content)["texts"] + return httpx.Response(200, json={"embeddings": [self._vector(t) for t in texts]}) + if path.endswith("/rerank"): + payload = json.loads(request.content) + q = set(re.findall(r"\w+", payload["query"].lower())) + scored = [{"document": d, "score": len(q & set(re.findall(r"\w+", d.lower()))) / 10} + for d in payload["documents"]] + return httpx.Response(200, json={"results": sorted(scored, key=lambda r: -r["score"])}) + if path.endswith("/chat/completions"): + body = json.loads(request.content) + system = body["messages"][0]["content"] + if "médecin expert" in system: + content = json.dumps(SYNTHESIS) + else: + self.extraction_prompts.append(body["messages"][1]["content"]) + ids = list(body["response_format"]["json_schema"]["schema"]["properties"]) + content = json.dumps({i: LLM_ANSWERS[i] for i in ids}) + return httpx.Response(200, json={"choices": [{"message": {"content": content}, + "finish_reason": "stop"}]}) + return httpx.Response(404) + + +@pytest.fixture +def workspace(tmp_path, monkeypatch): + """Répertoire de travail de l'orchestrateur : template/, forms/, dossiers uploadés.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "template").mkdir() + (tmp_path / "forms").mkdir() + (tmp_path / "template" / "Form_Test.json").write_text(json.dumps(_template()), encoding="utf-8") + make_acroform(tmp_path / "forms" / "Form_Test.pdf", [ + ("treatmentCanton", "combo", {"opt": [("NE", "NE"), ("VD", "VD")]}), + ("lastName", "text", {}), ("birthDate", "text", {}), ("urgent", "checkbox", {}), + ("ask4Hospitalization", "combo", {"opt": [("0", "non"), ("1", "oui")]}), + ("email", "text", {}), ("insurance", "text", {}), ("recipientBlock", "text", {}), + ("footer", "text", {}), + ]) + uploads = tmp_path / "uploads" + uploads.mkdir() + for name in DOCS: + (uploads / name).write_bytes(b"%PDF-1.4 " + name.encode()) + + services = FakeServices() + real_client = httpx.AsyncClient + + class MockedClient(real_client): + def __init__(self, *args, **kwargs): + kwargs.pop("limits", None) + kwargs["transport"] = httpx.MockTransport(services.handler) + super().__init__(*args, **kwargs) + + monkeypatch.setattr(httpx, "AsyncClient", MockedClient) + + async def _no_sleep(*_a, **_k): + return None + monkeypatch.setattr(asyncio, "sleep", _no_sleep) + monkeypatch.setattr(orchestrator, "VALID_FORM_IDS", {"Test"}) + return tmp_path, uploads, services + + +def _start_job(tmp_path: Path, uploads: Path) -> str: + job_id = f"test{time.time_ns():x}" + orchestrator.JOBS[job_id] = {"status": "pending", "progress": 0, "token": "secret", + "started_at": time.time()} + tmp_dir = tmp_path / "job" + tmp_dir.mkdir(exist_ok=True) + asyncio.run(orchestrator.run_pipeline_task(job_id, "Test", tmp_dir, sorted(uploads.glob("*.pdf")))) + return job_id + + +def test_pipeline_fills_form_and_survives_unreadable_document(workspace): + tmp_path, uploads, services = workspace + job_id = _start_job(tmp_path, uploads) + job = orchestrator.JOBS[job_id] + + assert job["status"] == "completed", job.get("message") + assert any("03_scan_illisible.pdf" in w for w in job["warnings"]) + # Une erreur 5xx de l'OCR est rejouée une fois, pas cinq. + assert services.ocr_calls["03_scan_illisible.pdf"] == 2 + + # Dossier court : transmis en entier, identique pour tous les lots. + assert services.extraction_prompts + assert all("DOCUMENTS DU DOSSIER (texte intégral)" in p for p in services.extraction_prompts) + assert all("=== DOCUMENT : 01_fiche.pdf ===" in p for p in services.extraction_prompts) + # Le preset d'éditeur n'est pas soumis ; la case vierge et la question oui/non le sont. + asked = "\n".join(services.extraction_prompts) + assert "[1.7]" not in asked and "[1.4]" in asked and "[1.5]" in asked + + fields = read_fields(Path(job["file_path"])) + assert fields["lastName"]["V"] == "DUPONT" + assert fields["birthDate"]["V"] == "02.02.1985" # date ISO normalisée + assert fields["urgent"]["V"] == "/On" # « Oui » coche la case + assert fields["ask4Hospitalization"]["V"] == "1" # libellé → valeur d'export + assert fields["email"]["V"] is None # « Non mentionné » n'est pas écrit + assert fields["recipientBlock"]["V"] == "Office AI du canton de Neuchâtel" + assert fields["footer"]["V"] == "DoctorFill — test" + + results = {r["id"]: r["result"] for r in job["_debug_results"]} + assert results["1.3"]["value"] == "02.02.1985" and results["1.3"]["raw_value"] == "1985-02-02" + assert results["1.6"]["value"] == "" + assert results["1.2"]["grounding"] == "verified" + assert results["1.4"]["grounding"] == "inferred" # « oui » : justifié par la citation seule + + +def test_rerun_writes_the_same_derived_fields(workspace): + tmp_path, uploads, services = workspace + job_id = _start_job(tmp_path, uploads) + services.extraction_prompts.clear() + + asyncio.run(orchestrator.rerun_pipeline_task(job_id)) + job = orchestrator.JOBS[job_id] + assert job["status"] == "completed", job.get("message") + assert job["file_path"].endswith("output_rerun.pdf") + fields = read_fields(Path(job["file_path"])) + # Le re-run perdait le destinataire cantonal et les champs calculés. + assert fields["recipientBlock"]["V"] == "Office AI du canton de Neuchâtel" + assert fields["footer"]["V"] == "DoctorFill — test" + assert fields["urgent"]["V"] == "/On" + assert services.extraction_prompts + + +def test_job_fails_with_actionable_message_when_nothing_is_readable(workspace, monkeypatch): + tmp_path, uploads, _ = workspace + for name in list(DOCS): + monkeypatch.setitem(DOCS, name, None) + job_id = _start_job(tmp_path, uploads) + job = orchestrator.JOBS[job_id] + assert job["status"] == "failed" + assert "Aucun texte" in job["message"] + + +def test_synthesis_failure_does_not_block_extraction(workspace, monkeypatch): + tmp_path, uploads, _ = workspace + + async def _broken(*_a, **_k): + return None + monkeypatch.setattr(orchestrator, "run_medical_synthesis", _broken) + job_id = _start_job(tmp_path, uploads) + job = orchestrator.JOBS[job_id] + assert job["status"] == "completed" + assert any("Synthèse" in w for w in job["warnings"]) + + +def test_chat_requires_the_job_token(workspace): + tmp_path, uploads, _ = workspace + job_id = _start_job(tmp_path, uploads) + client = TestClient(orchestrator.app) + resp = client.post("/chat", json={"job_id": job_id, "message": "Diagnostic ?"}) + assert resp.status_code == 403 + status = client.get(f"/status/{job_id}").json() + assert status["status"] == "completed" and status["warnings"] + fields = client.get(f"/fields/{job_id}", params={"token": "secret"}).json()["fields"] + assert {f["id"] for f in fields} >= {"1.4", "1.5"} + assert client.get(f"/fields/{job_id}", params={"token": "nope"}).status_code == 403 + + +def test_synthesis_falls_back_to_hierarchical(monkeypatch): + calls = [] + + async def fake_call(system, user, url, model, **kwargs): + calls.append(system) + if system == medical_synthesis.SYSTEM_PROMPT_SYNTHESIS: + raise medical_synthesis._Unrecoverable("réponse tronquée") + if system == medical_synthesis.SYSTEM_PROMPT_PER_DOC_SUMMARY: + name = re.search(r"DOCUMENT : (.+)", user).group(1) + return json.dumps({"document": name, "patient": {"nom": "DUPONT"}, + "diagnostics": [{"description": f"dx {name}"}]}) + raise medical_synthesis._Unrecoverable("fusion impossible") + + monkeypatch.setattr(medical_synthesis, "_call_llm", fake_call) + result = asyncio.run(medical_synthesis.run_medical_synthesis( + [{"filename": "b.pdf", "markdown": "texte b"}, {"filename": "a.pdf", "markdown": "texte a"}], + "http://vllm", "model")) + # Directe → hiérarchique → fusion déterministe : rien n'est perdu. + assert [d["description"] for d in result["diagnostics"]] == ["dx a.pdf", "dx b.pdf"] + assert result["patient"]["nom"] == "DUPONT" + assert calls[0] == medical_synthesis.SYSTEM_PROMPT_SYNTHESIS + + +def test_long_documents_are_split_for_summaries(): + text = "\n\n".join(f"Paragraphe {i} " + "mot " * 200 for i in range(60)) + parts = medical_synthesis._split_document("long.pdf", text, max_tokens=2000) + assert len(parts) > 1 + assert parts[0][0] == f"long.pdf (partie 1/{len(parts)})" + assert "".join(p for _, p in parts).replace("\n", "") == text.replace("\n", "") + + +def test_expired_jobs_take_their_debug_logs_with_them(workspace): + tmp_path, uploads, _ = workspace + job_id = _start_job(tmp_path, uploads) + debug_dir = Path(orchestrator.JOBS[job_id]["_debug_dir"]) + assert (debug_dir / "markdown" / "01_fiche.md").exists() + orchestrator.JOBS[job_id]["completed_at"] = time.time() - orchestrator.JOB_RETENTION_SECONDS - 1 + orchestrator._purge_expired_jobs() + assert job_id not in orchestrator.JOBS + assert not debug_dir.exists() + + +def test_embedding_outage_degrades_instead_of_failing(workspace, monkeypatch): + tmp_path, uploads, services = workspace + real = services.handler + + def handler(request): + if request.url.path.endswith("/embed"): + return httpx.Response(503, text="TEI down") + return real(request) + monkeypatch.setattr(services, "handler", handler) + + job_id = _start_job(tmp_path, uploads) + job = orchestrator.JOBS[job_id] + assert job["status"] == "completed", job.get("message") + assert any("Recherche sémantique" in w for w in job["warnings"]) + assert read_fields(Path(job["file_path"]))["lastName"]["V"] == "DUPONT" + + +def test_field_error_warning_is_recomputed_on_each_pass(): + job = {"warnings": ["Document illisible, ignoré : x.pdf"]} + orchestrator._record_field_errors(job, [{"id": "1.1", "error": "extraction_failed"}, {"id": "1.2"}]) + assert job["warnings"][-1].startswith("1 champ(s)") + orchestrator._record_field_errors(job, [{"id": "1.1", "result": {}}]) + assert job["warnings"] == ["Document illisible, ignoré : x.pdf"] From f2738906ee9c90c58591e07f6f689daf2781f6f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:09:34 +0000 Subject: [PATCH 07/13] fix(frontend): send the job token to chat and survive transient polling errors A single failed /status poll abandoned a job still running on the server; polling now retries with backoff. A failed rerun no longer leaves the rerun button disabled, and job warnings are shown. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CAQEkcJGzQXzySYYsRCKWW --- frontend/src/App.tsx | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) 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} +
+ ))} +
+ )} +