V2
This commit is contained in:
291
extraction.py
291
extraction.py
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user