feat: rééquilibrage dataset LoRA — raisonnement DIM vs mémorisation
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>
This commit is contained in:
351
scripts/16_parse_guide_metho.py
Normal file
351
scripts/16_parse_guide_metho.py
Normal file
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extraction des règles du Guide Méthodologique MCO 2026 → Q&A raisonnement.
|
||||
|
||||
Le Guide Méthodologique est le document de référence ATIH qui définit :
|
||||
- Les règles de codage du DP, DR, DAS
|
||||
- Les règles CMA et niveaux de sévérité
|
||||
- Les règles de codage des actes CCAM
|
||||
- Les cas particuliers (séjours multi-unités, transferts, etc.)
|
||||
|
||||
Stratégie :
|
||||
- Parser le PDF en sections
|
||||
- Extraire les règles de codage
|
||||
- Générer des Q&A directes (sans LLM) pour les définitions
|
||||
- Générer des Q&A de raisonnement via Claude Opus 4.6 pour les règles
|
||||
- Focus sur les règles DP/DAS/CMA les plus applicables
|
||||
|
||||
Cible : 500 exemples (183 directs + ~320 LLM)
|
||||
|
||||
Source : data/raw/guide_methodo_mco_2026.pdf
|
||||
Nécessite : ANTHROPIC_API_KEY en variable d'environnement
|
||||
|
||||
Usage :
|
||||
python scripts/16_parse_guide_metho.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
|
||||
PDF_PATH = BASE / "data" / "raw" / "guide_methodo_mco_2026.pdf"
|
||||
OUTPUT = BASE / "data" / "processed" / "guide_metho_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 PMSI. "
|
||||
"Tu t'appuies sur le Guide Méthodologique de production des informations "
|
||||
"relatives à l'activité MCO (ATIH 2026)."
|
||||
)
|
||||
|
||||
# Patterns pour détecter les règles dans le guide
|
||||
RULE_PATTERNS = [
|
||||
re.compile(r"(?:le\s+)?diagnostic\s+principal\s+(?:est|doit|sera|correspond)", re.IGNORECASE),
|
||||
re.compile(r"(?:le\s+)?diagnostic\s+(?:relié|associé)\s+(?:est|doit|sera)", re.IGNORECASE),
|
||||
re.compile(r"(?:la\s+)?CMA\s+(?:est|correspond|ne\s+peut)", re.IGNORECASE),
|
||||
re.compile(r"(?:le\s+)?DAS\s+(?:est|doit|sera|ne\s+doit\s+pas)", re.IGNORECASE),
|
||||
re.compile(r"(?:le\s+)?DP\s+(?:est|doit|sera|ne\s+doit\s+pas)", re.IGNORECASE),
|
||||
re.compile(r"(?:le\s+)?DR\s+(?:est|doit|sera)", re.IGNORECASE),
|
||||
re.compile(r"(?:on\s+)?code(?:ra)?\s+", re.IGNORECASE),
|
||||
re.compile(r"ne\s+(?:pas|doit\s+pas|faut\s+pas)\s+(?:coder|enregistrer|recueillir)", re.IGNORECASE),
|
||||
re.compile(r"séjour\s+multi-?uni", re.IGNORECASE),
|
||||
re.compile(r"(?:transfert|mutation)\s+", re.IGNORECASE),
|
||||
re.compile(r"(?:sévérité|niveau\s+de\s+sévérité|complication)", re.IGNORECASE),
|
||||
re.compile(r"(?:ressources?\s+supplémentaires?|consomm)", re.IGNORECASE),
|
||||
re.compile(r"(?:groupage|GHM|GHS|CMD)", re.IGNORECASE),
|
||||
]
|
||||
|
||||
GENERATION_PROMPT = """Tu es un formateur DIM. À partir de cette règle du Guide Méthodologique MCO, génère un exercice de raisonnement.
|
||||
|
||||
RÈGLE (source : Guide Méthodologique MCO 2026, section « {section} ») :
|
||||
{rule_text}
|
||||
|
||||
Génère un objet JSON avec :
|
||||
1. "scenario" : un cas clinique réaliste et concis (2-3 phrases) qui illustre l'application de cette règle
|
||||
2. "reponse" : un objet JSON contenant :
|
||||
- "analyse_clinique" : interprétation du cas
|
||||
- "regle_pmsi" : la règle du guide méthodologique qui s'applique (citée)
|
||||
- "code" : le code CIM-10 ou l'action de codage correcte
|
||||
- "confidence" : "high"
|
||||
- "justification" : pourquoi cette règle s'applique à ce cas
|
||||
|
||||
Réponds UNIQUEMENT avec le JSON, sans texte avant/après."""
|
||||
|
||||
# Templates pour les Q&A directes (sans LLM)
|
||||
DIRECT_QA_TEMPLATES = [
|
||||
("Quelle est la définition du {concept} selon le Guide Méthodologique MCO ?",
|
||||
"Selon le Guide Méthodologique MCO 2026 :\n\n{text}"),
|
||||
("Quelles sont les règles de codage pour {concept} selon le Guide Méthodologique ?",
|
||||
"Le Guide Méthodologique MCO 2026 précise les règles suivantes pour {concept} :\n\n{text}"),
|
||||
]
|
||||
|
||||
|
||||
def extract_sections(pdf_path: Path) -> list[tuple[str, str]]:
|
||||
"""Extraire les sections du Guide Méthodologique."""
|
||||
with pdfplumber.open(str(pdf_path)) as pdf:
|
||||
full_text = ""
|
||||
for page in pdf.pages:
|
||||
text = page.extract_text() or ""
|
||||
full_text += text + "\n\n"
|
||||
|
||||
lines = []
|
||||
for line in full_text.split("\n"):
|
||||
stripped = line.strip()
|
||||
if re.match(r"^\s*\d{1,3}\s*$", stripped):
|
||||
continue
|
||||
if re.match(r"^(Guide méthodologique|ATIH|Page\s+\d)", stripped, re.IGNORECASE):
|
||||
continue
|
||||
if "...." in stripped:
|
||||
continue
|
||||
lines.append(line)
|
||||
|
||||
clean_text = "\n".join(lines)
|
||||
|
||||
section_pattern = re.compile(
|
||||
r"^(\d+(?:\.\d+)*\.?)\s+([A-ZÀÂÉÈÊËÎÏÔÙÛÜÇ].*)",
|
||||
re.MULTILINE
|
||||
)
|
||||
|
||||
sections = []
|
||||
matches = list(section_pattern.finditer(clean_text))
|
||||
|
||||
for i, match in enumerate(matches):
|
||||
num = match.group(1).rstrip(".")
|
||||
title = match.group(2).strip()
|
||||
start = match.end()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(clean_text)
|
||||
body = clean_text[start:end].strip()
|
||||
|
||||
if len(body) > 80:
|
||||
section_title = f"{num}. {title}"
|
||||
sections.append((section_title, body))
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def extract_rule_paragraphs(sections: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
||||
"""Extraire les paragraphes contenant des règles de codage."""
|
||||
rules = []
|
||||
|
||||
for section_title, body in sections:
|
||||
paragraphs = re.split(r"\n\s*\n", body)
|
||||
|
||||
for para in paragraphs:
|
||||
para = para.strip()
|
||||
if len(para) < 80:
|
||||
continue
|
||||
|
||||
matches = sum(1 for pat in RULE_PATTERNS if pat.search(para))
|
||||
if matches >= 1:
|
||||
text = para[:1500] if len(para) > 1500 else para
|
||||
rules.append((section_title, text))
|
||||
|
||||
return rules
|
||||
|
||||
|
||||
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=2048,
|
||||
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) -> dict | None:
|
||||
"""Parse la réponse JSON du LLM."""
|
||||
if not response_text:
|
||||
return None
|
||||
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 "scenario" in data and "reponse" in data:
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Fallback
|
||||
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 None
|
||||
|
||||
|
||||
def make_chatml(system: str, user: str, assistant: str) -> dict:
|
||||
return {
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
{"role": "assistant", "content": assistant},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def generate_direct_qa(sections: list[tuple[str, str]]) -> list[dict]:
|
||||
"""Générer des Q&A directes depuis les sections (sans LLM)."""
|
||||
examples = []
|
||||
|
||||
concept_keywords = {
|
||||
"diagnostic principal": ["DP", "diagnostic principal"],
|
||||
"diagnostic relié": ["DR", "diagnostic relié"],
|
||||
"diagnostic associé significatif": ["DAS", "diagnostic associé"],
|
||||
"complication ou morbidité associée": ["CMA", "complication", "sévérité"],
|
||||
"séjour multi-unités": ["multi-unité", "transfert", "mutation"],
|
||||
"groupage GHM": ["GHM", "GHS", "groupage", "CMD"],
|
||||
"actes CCAM": ["CCAM", "acte", "procédure"],
|
||||
}
|
||||
|
||||
for section_title, body in sections:
|
||||
body_lower = body.lower()
|
||||
|
||||
for concept, keywords in concept_keywords.items():
|
||||
if any(kw.lower() in body_lower for kw in keywords):
|
||||
text = body[:2000] if len(body) > 2000 else body
|
||||
|
||||
tmpl_q, tmpl_a = random.choice(DIRECT_QA_TEMPLATES)
|
||||
user = tmpl_q.format(concept=f"{concept} (section {section_title})")
|
||||
assistant = tmpl_a.format(concept=concept, text=text)
|
||||
|
||||
examples.append(make_chatml(SYSTEM_PROMPT, user, assistant))
|
||||
break
|
||||
|
||||
return examples
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dry-run", action="store_true", help="Pas d'appel LLM")
|
||||
parser.add_argument("--max", type=int, default=500, help="Max exemples à générer via LLM")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("Parsing du Guide Méthodologique MCO 2026")
|
||||
print(f"Modèle : {MODEL}")
|
||||
print("=" * 60)
|
||||
|
||||
if not PDF_PATH.exists():
|
||||
print(f"PDF non trouvé : {PDF_PATH}")
|
||||
return
|
||||
|
||||
# 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
|
||||
|
||||
print(f"\nParsing de {PDF_PATH.name} ({PDF_PATH.stat().st_size / 1024 / 1024:.1f} Mo)...")
|
||||
sections = extract_sections(PDF_PATH)
|
||||
print(f" Sections extraites : {len(sections)}")
|
||||
|
||||
# Q&A directes (sans LLM)
|
||||
print("\nGénération des Q&A directes (sans LLM)...")
|
||||
direct_examples = generate_direct_qa(sections)
|
||||
print(f" → {len(direct_examples)} exemples directs")
|
||||
|
||||
# Extraire les paragraphes de règles
|
||||
print("\nExtraction des règles de codage...")
|
||||
rules = extract_rule_paragraphs(sections)
|
||||
print(f" Règles détectées : {len(rules)}")
|
||||
|
||||
if args.dry_run:
|
||||
for i, (section, rule) in enumerate(rules[:20]):
|
||||
print(f" [{i+1}] [{section}] {rule[:100]}...")
|
||||
print(f"\n[DRY RUN] {len(rules)} règles à traiter. Relancez sans --dry-run.")
|
||||
return
|
||||
|
||||
# Limiter le nombre de règles pour LLM
|
||||
if len(rules) > args.max:
|
||||
random.shuffle(rules)
|
||||
rules = rules[:args.max]
|
||||
|
||||
# Générer les Q&A via Claude
|
||||
print(f"\nGénération via Claude ({len(rules)} règles)...")
|
||||
llm_examples = []
|
||||
n_ok = 0
|
||||
n_fail = 0
|
||||
|
||||
for i, (section_title, rule_text) in enumerate(rules):
|
||||
prompt = GENERATION_PROMPT.format(section=section_title, rule_text=rule_text)
|
||||
response_text = call_claude(client, prompt)
|
||||
parsed = parse_llm_response(response_text)
|
||||
|
||||
if parsed and "scenario" in parsed and "reponse" in parsed:
|
||||
user_content = (
|
||||
f"Cas clinique :\n{parsed['scenario']}\n\n"
|
||||
f"Applique les règles du Guide Méthodologique MCO (section « {section_title} »)."
|
||||
)
|
||||
assistant_content = json.dumps(parsed["reponse"], ensure_ascii=False)
|
||||
llm_examples.append(make_chatml(SYSTEM_PROMPT, user_content, assistant_content))
|
||||
n_ok += 1
|
||||
else:
|
||||
n_fail += 1
|
||||
|
||||
if (i + 1) % 50 == 0:
|
||||
print(f" Progression : {i+1}/{len(rules)} (ok={n_ok}, fail={n_fail})")
|
||||
|
||||
print(f" Résultat LLM : {n_ok} exemples, {n_fail} échecs")
|
||||
|
||||
# Fusionner
|
||||
all_examples = direct_examples + llm_examples
|
||||
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" Directs (sans LLM) : {len(direct_examples)}")
|
||||
print(f" LLM (Claude) : {len(llm_examples)}")
|
||||
print(f"Taille : {OUTPUT.stat().st_size / 1024:.0f} Ko")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user