This commit is contained in:
oussi
2026-04-27 17:32:21 +02:00
parent feb7277fe9
commit 8192834f31
7 changed files with 5689 additions and 837 deletions

View File

@@ -298,14 +298,157 @@ Réponds UNIQUEMENT avec un objet JSON valide, sans texte avant ni après.
{"n_ogc":"","date_concertation":"","argumentaire_medecin_controleur":""}\
"""
PROMPT_SEJOUR_MANUSCRIT = """\
Tu es un assistant d'extraction de données médicales.
Cette page est intitulée "Séjour d'hospitalisation complète".
Elle comporte deux colonnes de texte entièrement manuscrit :
- Colonne gauche : "Commentaires du médecin contrôleur"
- Colonne droite : "Commentaires du médecin du DIM"
RÈGLES STRICTES :
- Le texte peut déborder largement EN DESSOUS du tableau et légèrement sur les côtés des colonnes : inclure TOUT le texte visible de chaque colonne, y compris celui qui dépasse les bordures.
- Retranscrire le texte manuscrit tel quel, y compris abréviations et codes médicaux.
- Pour attribuer un texte débordant à la bonne colonne : le texte débordant d'une colonne lui appartient même s'il dépasse physiquement le cadre.
- Si une colonne est illisible ou vide, retourner "".
Réponds UNIQUEMENT avec un objet JSON valide, sans texte avant ni après.
{"commentaire_medecin_controleur":"","commentaire_medecin_dim":""}\
"""
PROMPTS = {
"FICHE_RECUEIL": PROMPT_FICHE_RECUEIL,
"ELEMENTS_PREUVE": PROMPT_ELEMENTS_PREUVE,
"FICHE_ADMIN_2_2": PROMPT_FICHE_ADMIN_2_2,
"FICHE_ADMIN_1_2": PROMPT_FICHE_ADMIN_1_2,
"FICHE_RECUEIL": PROMPT_FICHE_RECUEIL,
"SEJOUR_MANUSCRIT": PROMPT_SEJOUR_MANUSCRIT,
"ELEMENTS_PREUVE": PROMPT_ELEMENTS_PREUVE,
"FICHE_ADMIN_2_2": PROMPT_FICHE_ADMIN_2_2,
"FICHE_ADMIN_1_2": PROMPT_FICHE_ADMIN_1_2,
}
SKIP_TYPES = {"SEJOUR_MANUSCRIT", "FICHE_CONCERTATION_VIDE", "AUTRE"}
SKIP_TYPES = {"FICHE_CONCERTATION_VIDE", "AUTRE"}
# ─── Découpage zones FICHE_RECUEIL ───────────────────────────────────────────
def crop_zone(img: Image.Image, y_start: float, y_end: float) -> Image.Image:
W, H = img.size
return img.crop((0, int(y_start * H), W, int(y_end * H)))
PROMPT_RECUEIL_Z1 = """\
Tu es un assistant d'extraction de données médicales.
Extrait les informations d'en-tête et des tableaux "Données du séjour" et "Données du RUM"
de cette portion de fiche médicale de recueil du praticien conseil.
RÈGLES STRICTES :
- Si un champ n'a pas de valeur clairement visible, retourner "".
- Ne jamais deviner, inférer ou compléter un champ absent.
- Le champ "provenance" est très souvent vide : ne le remplir QUE si une valeur est explicitement imprimée.
- Le tableau "Données du séjour" contient ces colonnes DANS CET ORDRE EXACT, de gauche à droite :
Age(ans) | Age(jours) | Sexe | Délai dern. règles | Age gestation | Poids d'entrée |
Durée de séjour | Mode d'entrée | Provenance | Mode de sortie | Destination |
Nb séances | Nb RUM | Nb j EXH | Type EXB | Nb j EXB
RÈGLE ABSOLUE : lire chaque valeur dans sa colonne uniquement. Si une colonne est vide, retourner "".
Ne jamais décaler les valeurs vers la gauche pour compenser une cellule vide.
Réponds UNIQUEMENT avec un objet JSON valide, sans texte avant ni après.
{"n_ogc":"","etablissement":"","finess":"","date_debut_controle":"","n_champ":"","libelle_champ":"","dossier_manquant":"","date_debut_sejour":"","date_fin_sejour":"",
"sejour_etab":{"age_ans":"","age_jours":"","sexe":"","poids_entree":"","duree_sejour":"","mode_entree":"","provenance":"","mode_sortie":"","destination":"","nb_seances":"","nb_rum":"","nb_j_exh":"","type_exb":"","nb_j_exb":""},
"sejour_reco":{"age_ans":"","age_jours":"","sexe":"","poids_entree":"","duree_sejour":"","mode_entree":"","provenance":"","mode_sortie":"","destination":"","nb_seances":"","nb_rum":"","nb_j_exh":"","type_exb":"","nb_j_exb":""},
"rum_etab":{"n_rum":"","lits_dedies_sp":"","um":"","igs_ii":"","duree_rum_debut":"","duree_rum_fin":"","nature_suppl":"","nb_suppl":""},
"rum_reco":{"n_rum":"","lits_dedies_sp":"","um":"","igs_ii":"","duree_rum_debut":"","duree_rum_fin":"","nature_suppl":"","nb_suppl":""}}\
"""
PROMPT_RECUEIL_Z2 = """\
Tu es un assistant d'extraction de données médicales.
Extrait uniquement les lignes DP et DR de la section "Codage de l'Établissement"
et leur colonne "Recodage" correspondante.
RÈGLES STRICTES :
- Le formulaire comporte exactement UNE ligne pour le DP et UNE ligne pour le DR.
- Pour chaque ligne : si la colonne "Recodage" en face est vide sur le document, retourner "" pour dp_reco/dr_reco.
- Si la ligne DR est entièrement vide sur le document, retourner {"code":"","libelle":""} pour dr_etab et "" pour dr_reco.
- Ne jamais copier le code d'une autre ligne dans DP ou DR.
Réponds UNIQUEMENT avec un objet JSON valide, sans texte avant ni après.
{"dp_etab":{"code":"","libelle":""},"dr_etab":{"code":"","libelle":""},"dp_reco":{"code":""},"dr_reco":{"code":""}}\
"""
PROMPT_RECUEIL_Z3 = """\
Tu es un assistant d'extraction de données médicales.
Extrait toutes les lignes DAS et Actes de cette section de la fiche médicale de recueil.
RÈGLES STRICTES :
- Extraire TOUTES les lignes non vides, sans limite de nombre.
- Les diagnostics (codes CIM-10 courts, ex: R33, E43, Z515) vont dans "das_etab" / "das_reco".
- Les actes (codes CCAM longs, 7+ caractères commençant par lettres, ex: JDPE002, NJFA008) vont dans "actes_etab" / "actes_reco".
- Ne jamais mettre un code CCAM dans das_etab, ni un code CIM-10 dans actes_etab.
- Si la colonne "Recodage" d'une ligne est vide, ne pas créer d'entrée dans das_reco/actes_reco pour cette ligne.
- Ne pas retourner les lignes entièrement vides.
Réponds UNIQUEMENT avec un objet JSON valide, sans texte avant ni après.
{"das_etab":[{"code":"","niveau":"","libelle":""}],"actes_etab":[{"code":"","niveau":"","libelle":""}],"das_reco":[{"code":"","niveau":""}],"actes_reco":[{"code":"","niveau":""}]}\
"""
PROMPT_RECUEIL_Z4 = """\
Tu es un assistant d'extraction de données médicales.
Extrait les informations de la barre GHM/GHS et de la zone décision de cette portion de fiche médicale.
RÈGLES STRICTES :
- Pour "se_coche" : retourner "SE1", "SE2", "SE3" ou "SE4" si une case est explicitement cochée, sinon "". Ce champ est TRÈS SOUVENT vide.
- Pour les valeurs GHS (nombres) : retourner uniquement les chiffres sans point ni espace (ex: "4169" et non "4.169").
- Pour "accord_desaccord" : retourner "accord" ou "désaccord" selon la case cochée.
- Pour "atu", "ffm", "fsd" : retourner "oui" si la case est cochée, "" sinon.
- Si un champ est absent ou illisible, retourner "".
Réponds UNIQUEMENT avec un objet JSON valide, sans texte avant ni après.
{"ghm_etab":"","ghs_etab":"","ghm_reco":"","ghs_reco":"","recodage_impactant_facturation":"","ghs_injustifie":"","se_coche":"","atu":"","ffm":"","fsd":"","accord_desaccord":"","decision_finale":"","nom_praticien_conseil":""}\
"""
# (y_start, y_end, prompt, num_predict) — légère superposition aux jointures pour ne pas couper une ligne
# Z2 finit à 0.415 pour inclure les lignes DP+DR qui sont sous le header "Codage"
# Z4 commence à 0.822 pour capturer la barre GHM/GHS qui précède les cases SE
RECUEIL_ZONES = [
(0.000, 0.325, PROMPT_RECUEIL_Z1, 8192),
(0.310, 0.415, PROMPT_RECUEIL_Z2, 3000),
(0.358, 0.815, PROMPT_RECUEIL_Z3, 8192),
# Z4 commence à 0.800 pour absorber les décalages de scan sur la ligne GHM/GHS
(0.800, 1.000, PROMPT_RECUEIL_Z4, 3000),
]
def _ask_zone(zone_img: Image.Image, prompt: str, num_predict: int,
timing_record: dict | None) -> dict:
"""Appelle ask_vision sur une zone, parse le JSON, gère un retry si nécessaire."""
raw = ask_vision(prompt, zone_img, timeout=240, num_predict=num_predict,
timing_record=timing_record)
data = extract_json(raw)
if data is None:
retry_prompt = (
"Ta réponse précédente n'était pas un JSON valide. "
"Réponds UNIQUEMENT avec un objet JSON valide, sans texte avant ni après, "
"sans bloc ```json```. Voici le schéma attendu :\n\n" + prompt
)
raw2 = ask_vision(retry_prompt, zone_img, timeout=240, num_predict=num_predict,
timing_record=timing_record)
data = extract_json(raw2)
return data or {}
def extract_fiche_recueil_zones(img: Image.Image,
timing_record: dict | None = None) -> tuple[dict, list]:
"""
Extrait une FICHE_RECUEIL par découpage en 4 zones.
Retourne (merged_data, zones_timing).
"""
merged: dict = {}
zones_timing: list = []
for idx, (y0, y1, prompt, num_pred) in enumerate(RECUEIL_ZONES, start=1):
t0 = time.time()
zone_img = crop_zone(img, y0, y1)
try:
data = _ask_zone(zone_img, prompt, num_pred, timing_record)
except Exception as e:
print(f" ⚠ Zone {idx}/4 erreur : {e}")
data = {}
elapsed = round(time.time() - t0, 2)
zones_timing.append({"zone": idx, "duree_s": elapsed})
print(f" → Zone {idx}/4 OK ({elapsed:.1f}s)")
merged.update(data)
return merged, zones_timing
# ─── Traitement d'un PDF ──────────────────────────────────────────────────────
@@ -420,15 +563,24 @@ def _normalize_result(result: dict) -> None:
# se_coche : normaliser "1"→"SE1", rejeter toute valeur non SE1-4
se_raw = str(d.get("se_coche", "")).strip()
if se_raw.upper() in {"SE1", "SE2", "SE3", "SE4"}:
# Format déjà correct
d["se_coche"] = se_raw.upper()
elif se_raw in {"1", "2", "3", "4"}:
# Chiffre seul = ambigu, le modèle confond avec le rang d'un DAS → vider
d["se_coche"] = ""
elif se_raw:
# Valeur inattendue (ex: "accord", "désaccord") → vider
d["se_coche"] = ""
# accord_desaccord : forcer minuscule + alias orthographiques
acc = str(d.get("accord_desaccord", "")).strip().lower()
acc = acc.replace("é", "e").replace("desaccord", "désaccord")
if acc in {"accord", "désaccord"}:
d["accord_desaccord"] = acc
elif acc:
d["accord_desaccord"] = ""
# decision_finale : forcer minuscule
df = str(d.get("decision_finale", "")).strip().lower()
d["decision_finale"] = df
if ptype in ("FICHE_ADMIN_2_2", "FICHE_ADMIN_1_2"):
for date_field in ("date_concertation",):
if d.get(date_field):
@@ -611,62 +763,75 @@ def process_pdf(pdf_path: Path) -> tuple[dict, dict]:
print(" → Extraction en cours...")
t0 = time.time()
try:
num_predict = 12000 if page_type == "FICHE_RECUEIL" else 8192
raw = ask_vision(PROMPTS[page_type], img, timeout=240,
num_predict=num_predict, timing_record=timing)
except Exception as e:
print(f" ⚠ Erreur extraction : {e}")
duree_ext = round(time.time() - t0, 2)
page_timing["duree_extraction_s"] = duree_ext
page_timing["statut"] = "erreur_extraction"
page_timing["erreur"] = str(e)
timing["erreurs"].append({"page": i, "phase": "extraction", "type": page_type, "message": str(e)})
timing["pages"].append(page_timing)
result["pages_traitees"].append({"page": i, "type": page_type,
"data": {"erreur": str(e)}})
continue
duree_ext = round(time.time() - t0, 2)
page_timing["duree_extraction_s"] = duree_ext
print(f" → Réponse reçue ({duree_ext:.1f}s)")
data = extract_json(raw)
if data is None:
print(f" ⚠ JSON non parsable — retry en cours...")
retry_prompt = (
"Ta réponse précédente n'était pas un JSON valide. "
"Réponds UNIQUEMENT avec un objet JSON valide, sans texte avant ni après, "
"sans bloc ```json```. Voici le schéma attendu :\n\n"
+ PROMPTS[page_type]
)
if page_type == "FICHE_RECUEIL":
try:
raw2 = ask_vision(retry_prompt, img, timeout=240, num_predict=12000,
timing_record=timing)
data = extract_json(raw2)
data, zones_t = extract_fiche_recueil_zones(img, timing_record=timing)
page_timing["duree_extraction_s"] = round(time.time() - t0, 2)
page_timing["zones_timing"] = zones_t
page_timing["statut"] = "ok"
except Exception as e:
print(f" ⚠ Erreur retry : {e}")
data = None
if data is None:
print(f" ⚠ Retry échoué — raw_response conservé")
page_timing["statut"] = "json_non_parsable"
timing["erreurs"].append({
"page": i, "phase": "parsing_json", "type": page_type,
"message": f"JSON non parsable après retry : {raw[:100]}",
"retry": True,
})
data = {"raw_response": raw}
else:
print(f" ✓ Retry réussi")
page_timing["statut"] = "ok_after_retry"
timing["erreurs"].append({
"page": i, "phase": "parsing_json", "type": page_type,
"message": "JSON non parsable au 1er appel, corrigé par retry",
"retry": True, "retry_ok": True,
})
print(f" ⚠ Erreur extraction zones : {e}")
page_timing["duree_extraction_s"] = round(time.time() - t0, 2)
page_timing["statut"] = "erreur_extraction"
page_timing["erreur"] = str(e)
timing["erreurs"].append({"page": i, "phase": "extraction", "type": page_type, "message": str(e)})
timing["pages"].append(page_timing)
result["pages_traitees"].append({"page": i, "type": page_type, "data": {"erreur": str(e)}})
continue
else:
page_timing["statut"] = "ok"
try:
raw = ask_vision(PROMPTS[page_type], img, timeout=240,
num_predict=8192, timing_record=timing)
except Exception as e:
print(f" ⚠ Erreur extraction : {e}")
page_timing["duree_extraction_s"] = round(time.time() - t0, 2)
page_timing["statut"] = "erreur_extraction"
page_timing["erreur"] = str(e)
timing["erreurs"].append({"page": i, "phase": "extraction", "type": page_type, "message": str(e)})
timing["pages"].append(page_timing)
result["pages_traitees"].append({"page": i, "type": page_type, "data": {"erreur": str(e)}})
continue
page_timing["duree_extraction_s"] = round(time.time() - t0, 2)
print(f" → Réponse reçue ({page_timing['duree_extraction_s']:.1f}s)")
data = extract_json(raw)
if data is None:
print(f" ⚠ JSON non parsable — retry en cours...")
retry_prompt = (
"Ta réponse précédente n'était pas un JSON valide. "
"Réponds UNIQUEMENT avec un objet JSON valide, sans texte avant ni après, "
"sans bloc ```json```. Voici le schéma attendu :\n\n"
+ PROMPTS[page_type]
)
try:
raw2 = ask_vision(retry_prompt, img, timeout=240, num_predict=12000,
timing_record=timing)
data = extract_json(raw2)
except Exception as e:
print(f" ⚠ Erreur retry : {e}")
data = None
if data is None:
print(f" ⚠ Retry échoué — raw_response conservé")
page_timing["statut"] = "json_non_parsable"
timing["erreurs"].append({
"page": i, "phase": "parsing_json", "type": page_type,
"message": f"JSON non parsable après retry : {raw[:100]}",
"retry": True,
})
data = {"raw_response": raw}
else:
print(f" ✓ Retry réussi")
page_timing["statut"] = "ok_after_retry"
timing["erreurs"].append({
"page": i, "phase": "parsing_json", "type": page_type,
"message": "JSON non parsable au 1er appel, corrigé par retry",
"retry": True, "retry_ok": True,
})
else:
page_timing["statut"] = "ok"
timing["pages"].append(page_timing)
result["pages_traitees"].append({"page": i, "type": page_type, "data": data})
@@ -705,7 +870,8 @@ def flatten(result: dict) -> dict:
row["dr_reco_code"] = (d.get("dr_reco") or {}).get("code", "")
for k in ["ghm_etab","ghs_etab","ghm_reco","ghs_reco",
"recodage_impactant_facturation","ghs_injustifie",
"se_coche","atu","ffm","fsd","accord_desaccord","nom_praticien_conseil"]:
"se_coche","atu","ffm","fsd","accord_desaccord",
"decision_finale","nom_praticien_conseil"]:
row[k] = d.get(k, "")
general_done = True
# ── Comptages et durées agrégés sur tous les RUM ──
@@ -736,6 +902,9 @@ def flatten(result: dict) -> dict:
elif ptype == "FICHE_ADMIN_1_2":
row["admin12_date_concertation"] = d.get("date_concertation", "")
row["admin12_argumentaire"] = d.get("argumentaire_medecin_controleur", "")
elif ptype == "SEJOUR_MANUSCRIT":
row["sejour_ms_controleur"] = d.get("commentaire_medecin_controleur", "")
row["sejour_ms_dim"] = d.get("commentaire_medecin_dim", "")
# ── RÈGLE MÉTIER GHS FINAL ─────────────────────────────────────────────
# ghs_final_apres_concertation est manuscrit et souvent mal lu.