Passe de 95/3/2 (lookups/raisonnement/règles) à ~31/49/20. Dataset cible ~16K exemples denses (vs 66K de lookups avant). Modifiés : - 03_convert_cache.py : cache complet 1840 entrées (actuel + backup) - 04_build_dataset.py : subsampling agressif (CIM-10 1.5K, CCAM 1.5K, CoCoA 2K) + sélection intelligente priorisant le raisonnement - 12_generate_pipeline_examples.py : 3 templates (court + long + CPAM), cache actuel, cible ~2800 exemples Créés : - 13_generate_fascicule_reasoning.py : parsing 10 fascicules ATIH, génération Q&A raisonnement via Claude Opus 4.6 (~450 exemples) - 14_generate_negative_examples.py : 1000 exemples négatifs (symptômes/DP, redondances sémantiques, DAS non significatifs) - 15_generate_discrimination.py : 800 exercices de discrimination entre codes siblings CIM-10 via Claude Opus 4.6 - 16_parse_guide_metho.py : extraction Guide Méthodologique MCO 2026, Q&A directes + raisonnement via Claude Opus 4.6 (~500 exemples) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
374 lines
13 KiB
Python
374 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Parsing agressif des 10 fascicules ATIH → Q&A de raisonnement DIM.
|
||
|
||
Stratégie :
|
||
- Découpe chaque fascicule en paragraphes (pas sections)
|
||
- Extrait les règles de codage via regex
|
||
- Pour chaque règle extraite, génère 3 Q&A raisonnement via Claude Opus 4.6
|
||
- Question = scénario clinique appliquant la règle
|
||
- Réponse = JSON structuré {analyse_clinique, regle_pmsi, code, justification}
|
||
|
||
Cible : ~450 exemples (151 règles × 3 exercices)
|
||
|
||
Sources : data/raw/referentiels/ (10 fascicules PDF, via lien t2a)
|
||
Nécessite : ANTHROPIC_API_KEY en variable d'environnement
|
||
|
||
Usage :
|
||
python scripts/13_generate_fascicule_reasoning.py [--dry-run] [--max N]
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import random
|
||
import re
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import pdfplumber
|
||
|
||
random.seed(42)
|
||
|
||
BASE = Path(__file__).resolve().parent.parent
|
||
T2A = BASE.parent / "t2a"
|
||
REF_DIR = T2A / "data" / "referentiels"
|
||
OUTPUT = BASE / "data" / "processed" / "fascicule_reasoning_chatml.jsonl"
|
||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
MODEL = "claude-opus-4-6"
|
||
|
||
SYSTEM_PROMPT = (
|
||
"Tu es un médecin DIM expert en codage CIM-10 pour le PMSI français. "
|
||
"Tu t'appuies sur les référentiels ATIH officiels."
|
||
)
|
||
|
||
# Fascicules à parser
|
||
FASCICULES = {
|
||
"Fascicule_01_Generalites": "Généralités du codage PMSI",
|
||
"Fascicule_02_Maladies_digestives": "Maladies de l'appareil digestif",
|
||
"Fascicule_03_Tumeurs": "Tumeurs",
|
||
"Fascicule_04_Metabolisme": "Métabolisme",
|
||
"Fascicule_05_Gyneco_Obstetrique": "Gynécologie et Obstétrique",
|
||
"Fascicule_06_Neonatalogie": "Néonatalogie",
|
||
"Fascicule_07_Evolutions_2010": "Évolutions 2010",
|
||
"Fascicule_08_Maladies_infectieuses": "Maladies infectieuses",
|
||
"Fascicule_09_AVC": "Accidents vasculaires cérébraux",
|
||
"Fascicule_10_SCA_Coronariens": "Syndromes coronariens aigus",
|
||
}
|
||
|
||
# Regex pour détecter les règles de codage dans le texte
|
||
RULE_PATTERNS = [
|
||
re.compile(r"(?:on\s+code(?:ra)?|se\s+code|coder?|est\s+cod[ée](?:e)?)\s+(?:avec\s+|par\s+|en\s+)?([A-Z]\d{2}(?:\.\d{1,2})?)", re.IGNORECASE),
|
||
re.compile(r"ne\s+(?:pas|doit\s+pas|faut\s+pas)\s+coder", re.IGNORECASE),
|
||
re.compile(r"à\s+l['\u2019]exclusion\s+de", re.IGNORECASE),
|
||
re.compile(r"(?:comprend|inclut|inclus)\s+", re.IGNORECASE),
|
||
re.compile(r"(?:dans\s+ce\s+cas|en\s+cas\s+de|si\s+le\s+patient|lorsque)", re.IGNORECASE),
|
||
re.compile(r"(?:le\s+DP|diagnostic\s+principal)\s+(?:est|sera|doit\s+être)", re.IGNORECASE),
|
||
re.compile(r"(?:le\s+DAS|diagnostic\s+associé)\s+(?:est|sera|doit\s+être)", re.IGNORECASE),
|
||
re.compile(r"(?:CMA|sévérité|niveau\s+\d)", re.IGNORECASE),
|
||
re.compile(r"(?:séjour|durée|ressources?\s+supplémentaires?)", re.IGNORECASE),
|
||
]
|
||
|
||
GENERATION_PROMPT = """Tu es un formateur DIM. À partir de cet extrait du fascicule ATIH, génère EXACTEMENT 3 exercices de raisonnement distincts.
|
||
|
||
EXTRAIT (source : fascicule ATIH « {topic} ») :
|
||
{rule_text}
|
||
|
||
Génère un tableau JSON de 3 objets, chacun avec :
|
||
- "scenario" : un cas clinique réaliste et concis (2-3 phrases), DIFFÉRENT des autres
|
||
- "reponse" : un objet contenant :
|
||
- "analyse_clinique" : interprétation du cas
|
||
- "regle_pmsi" : la règle du fascicule qui s'applique
|
||
- "code" : le code CIM-10 correct (ou null si ne pas coder)
|
||
- "confidence" : "high"
|
||
- "justification" : pourquoi cette règle s'applique
|
||
|
||
Réponds UNIQUEMENT avec le tableau JSON (pas d'objet wrapper), sans texte avant/après.
|
||
Exemple de format : [{{"scenario": "...", "reponse": {{...}}}}, ...]"""
|
||
|
||
|
||
def extract_paragraphs(pdf_path: Path) -> list[str]:
|
||
"""Extraire les paragraphes d'un fascicule PDF.
|
||
|
||
Stratégie : pdfplumber ne produit pas de lignes vides entre paragraphes.
|
||
On découpe par page, puis par titres/sections détectées via heuristiques.
|
||
"""
|
||
pages_text = []
|
||
with pdfplumber.open(str(pdf_path)) as pdf:
|
||
for page in pdf.pages:
|
||
text = page.extract_text() or ""
|
||
if text.strip():
|
||
pages_text.append(text)
|
||
|
||
# Concaténer avec séparateur de page
|
||
full_text = "\n\n".join(pages_text)
|
||
|
||
# Nettoyer les lignes non pertinentes
|
||
lines = []
|
||
for line in full_text.split("\n"):
|
||
stripped = line.strip()
|
||
if "...." in stripped or "TABLE DES MATIERES" in stripped:
|
||
continue
|
||
if re.match(r"^\s*\d{1,3}\s*$", stripped):
|
||
continue
|
||
if re.match(r"^(Créé le|Mis à jour|Modifié le|FASCICULE DE CODAGE)", stripped):
|
||
continue
|
||
lines.append(line)
|
||
|
||
clean_text = "\n".join(lines)
|
||
|
||
# Découper en sections par les titres détectés
|
||
title_re = re.compile(
|
||
r"^(?:"
|
||
r"[IVX]+\.\d*\.?\s+"
|
||
r"|[IVX]+\s+[-–]\s+"
|
||
r"|\d+\.\d*\.?\s+[A-ZÀÂÉÈÊËÎÏ]"
|
||
r"|[A-ZÀÂÉÈÊËÎÏÔÙÛÜ][A-ZÀÂÉÈÊËÎÏÔÙÛÜ\s]{3,}$"
|
||
r")",
|
||
re.MULTILINE
|
||
)
|
||
|
||
paragraphs = []
|
||
|
||
matches = list(title_re.finditer(clean_text))
|
||
if matches:
|
||
for i, match in enumerate(matches):
|
||
start = match.start()
|
||
end = matches[i + 1].start() if i + 1 < len(matches) else len(clean_text)
|
||
section = clean_text[start:end].strip()
|
||
if len(section) > 100:
|
||
paragraphs.append(section)
|
||
|
||
# Fallback : découper par blocs de ~800 caractères
|
||
if len(paragraphs) < 5:
|
||
paragraphs = []
|
||
block_size = 800
|
||
current_block = []
|
||
current_len = 0
|
||
for line in clean_text.split("\n"):
|
||
current_block.append(line)
|
||
current_len += len(line) + 1
|
||
if current_len >= block_size:
|
||
para = "\n".join(current_block).strip()
|
||
if len(para) > 100:
|
||
paragraphs.append(para)
|
||
current_block = []
|
||
current_len = 0
|
||
if current_block:
|
||
para = "\n".join(current_block).strip()
|
||
if len(para) > 100:
|
||
paragraphs.append(para)
|
||
|
||
return paragraphs
|
||
|
||
|
||
def extract_rules(paragraphs: list[str]) -> list[tuple[str, int]]:
|
||
"""Extraire les paragraphes classés par pertinence (score de règle).
|
||
|
||
Returns: [(text, score)] trié par score décroissant.
|
||
"""
|
||
scored = []
|
||
for para in paragraphs:
|
||
score = sum(1 for pat in RULE_PATTERNS if pat.search(para))
|
||
codes = re.findall(r"[A-Z]\d{2}(?:\.\d{1,2})?", para)
|
||
if codes:
|
||
score += 1
|
||
if score >= 1:
|
||
text = para[:1500] if len(para) > 1500 else para
|
||
scored.append((text, score))
|
||
scored.sort(key=lambda x: -x[1])
|
||
return scored
|
||
|
||
|
||
def call_claude(client, prompt: str, max_retries: int = 2) -> str | None:
|
||
"""Appel Claude Opus 4.6 via API Anthropic avec retry."""
|
||
for attempt in range(max_retries + 1):
|
||
try:
|
||
response = client.messages.create(
|
||
model=MODEL,
|
||
max_tokens=4096,
|
||
temperature=0.7,
|
||
messages=[{"role": "user", "content": prompt}],
|
||
)
|
||
return response.content[0].text
|
||
except Exception as e:
|
||
if attempt < max_retries:
|
||
wait = 2 ** (attempt + 1)
|
||
print(f" Retry in {wait}s: {e}")
|
||
time.sleep(wait)
|
||
else:
|
||
print(f" Claude error: {e}")
|
||
return None
|
||
|
||
|
||
def parse_llm_response(response_text: str) -> list[dict]:
|
||
"""Parse la réponse JSON du LLM (tableau de 3 exercices ou objet unique)."""
|
||
if not response_text:
|
||
return []
|
||
text = response_text.strip()
|
||
if "```json" in text:
|
||
text = text.split("```json", 1)[1].split("```", 1)[0].strip()
|
||
elif "```" in text:
|
||
text = text.split("```", 1)[1].split("```", 1)[0].strip()
|
||
|
||
try:
|
||
data = json.loads(text)
|
||
if isinstance(data, list):
|
||
return [d for d in data if isinstance(d, dict) and "scenario" in d and "reponse" in d]
|
||
if isinstance(data, dict) and "scenario" in data and "reponse" in data:
|
||
return [data]
|
||
except json.JSONDecodeError:
|
||
pass
|
||
|
||
# Fallback : chercher un tableau [...]
|
||
bracket_start = text.find("[")
|
||
if bracket_start >= 0:
|
||
depth = 0
|
||
for i in range(bracket_start, len(text)):
|
||
if text[i] == "[":
|
||
depth += 1
|
||
elif text[i] == "]":
|
||
depth -= 1
|
||
if depth == 0:
|
||
try:
|
||
data = json.loads(text[bracket_start:i+1])
|
||
if isinstance(data, list):
|
||
return [d for d in data if isinstance(d, dict) and "scenario" in d]
|
||
except json.JSONDecodeError:
|
||
break
|
||
|
||
# Fallback : objet unique
|
||
brace_start = text.find("{")
|
||
if brace_start >= 0:
|
||
depth = 0
|
||
for i in range(brace_start, len(text)):
|
||
if text[i] == "{":
|
||
depth += 1
|
||
elif text[i] == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
try:
|
||
data = json.loads(text[brace_start:i+1])
|
||
if "scenario" in data:
|
||
return [data]
|
||
except json.JSONDecodeError:
|
||
break
|
||
return []
|
||
|
||
|
||
def make_chatml(scenario: str, response: dict, topic: str) -> dict:
|
||
"""Créer un exemple ChatML depuis le scénario + réponse structurée."""
|
||
user_content = (
|
||
f"Cas clinique :\n{scenario}\n\n"
|
||
f"Code ce cas en CIM-10 selon les règles du fascicule « {topic} ».\n\n"
|
||
"Réponds avec un JSON structuré contenant : analyse_clinique, regle_pmsi, code, confidence, justification."
|
||
)
|
||
assistant_content = json.dumps(response, ensure_ascii=False)
|
||
|
||
return {
|
||
"messages": [
|
||
{"role": "system", "content": SYSTEM_PROMPT},
|
||
{"role": "user", "content": user_content},
|
||
{"role": "assistant", "content": assistant_content},
|
||
]
|
||
}
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--dry-run", action="store_true", help="Pas d'appel LLM, affiche les règles extraites")
|
||
parser.add_argument("--max", type=int, default=0, help="Max règles par fascicule (0=illimité)")
|
||
args = parser.parse_args()
|
||
|
||
print("=" * 60)
|
||
print("Génération de Q&A raisonnement depuis les fascicules ATIH")
|
||
print(f"Modèle : {MODEL}")
|
||
print("=" * 60)
|
||
|
||
# Vérifier la clé API
|
||
if not args.dry_run:
|
||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||
if not api_key:
|
||
print("Erreur: ANTHROPIC_API_KEY non définie.")
|
||
print(" export ANTHROPIC_API_KEY='sk-ant-...'")
|
||
sys.exit(1)
|
||
import anthropic
|
||
client = anthropic.Anthropic(api_key=api_key)
|
||
else:
|
||
client = None
|
||
|
||
all_examples = []
|
||
total_rules = 0
|
||
|
||
for filename_part, topic in FASCICULES.items():
|
||
pdf_files = list(REF_DIR.glob(f"*{filename_part}*"))
|
||
pdf_files = [f for f in pdf_files if "redacted" not in f.name.lower() and "pseudonymise" not in str(f)]
|
||
if not pdf_files:
|
||
print(f"\n{topic} : PDF non trouvé, skip")
|
||
continue
|
||
|
||
pdf_path = pdf_files[0]
|
||
print(f"\n{'─'*40}")
|
||
print(f"{topic} ({pdf_path.name})")
|
||
|
||
paragraphs = extract_paragraphs(pdf_path)
|
||
print(f" Paragraphes extraits : {len(paragraphs)}")
|
||
|
||
scored_rules = extract_rules(paragraphs)
|
||
print(f" Règles/paragraphes pertinents : {len(scored_rules)}")
|
||
|
||
if args.max > 0:
|
||
scored_rules = scored_rules[:args.max]
|
||
|
||
total_rules += len(scored_rules)
|
||
|
||
if args.dry_run:
|
||
for i, (rule, score) in enumerate(scored_rules[:5]):
|
||
print(f" [{i+1}] (score={score}) {rule[:120]}...")
|
||
continue
|
||
|
||
# Générer les Q&A via Claude (3 exercices par règle)
|
||
n_ok = 0
|
||
n_fail = 0
|
||
for i, (rule_text, score) in enumerate(scored_rules):
|
||
prompt = GENERATION_PROMPT.format(topic=topic, rule_text=rule_text)
|
||
response_text = call_claude(client, prompt)
|
||
exercises = parse_llm_response(response_text)
|
||
|
||
for ex in exercises:
|
||
if "scenario" in ex and "reponse" in ex:
|
||
example = make_chatml(ex["scenario"], ex["reponse"], topic)
|
||
all_examples.append(example)
|
||
n_ok += 1
|
||
|
||
if not exercises:
|
||
n_fail += 1
|
||
|
||
if (i + 1) % 10 == 0:
|
||
print(f" Progression : {i+1}/{len(scored_rules)} (exemples={n_ok}, échecs={n_fail})")
|
||
|
||
print(f" Résultat : {n_ok} exemples générés, {n_fail} échecs")
|
||
|
||
if args.dry_run:
|
||
print(f"\n[DRY RUN] Total règles détectées : {total_rules}")
|
||
print("Relancez sans --dry-run pour générer les exemples avec Claude.")
|
||
return
|
||
|
||
# Mélanger et sauvegarder
|
||
random.shuffle(all_examples)
|
||
|
||
with open(OUTPUT, "w") as f:
|
||
for ex in all_examples:
|
||
f.write(json.dumps(ex, ensure_ascii=False) + "\n")
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f"Total : {len(all_examples)} exemples → {OUTPUT}")
|
||
print(f"Taille : {OUTPUT.stat().st_size / 1024:.0f} Ko")
|
||
print(f"Règles sources : {total_rules}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|