#!/usr/bin/env python3 """ Phase 1D — Génération de données synthétiques via API OpenAI (GPT-4o). Envoie des métadonnées anonymisées FAISS à un grand modèle pour générer des exemples de raisonnement DIM complet en format ChatML. Types d'exemples générés : 1. Scénario clinique → raisonnement DIM → code CIM-10 2. Discrimination entre codes proches 3. Application des règles PMSI (DP/DAS, CMA, exclusions) Nécessite : OPENAI_API_KEY en variable d'environnement Usage : python scripts/06_generate_synthetic.py [--n 500] [--batch 5] [--model gpt-4o] [--dry-run] """ import json import os import sys import time import random import argparse from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed random.seed(42) BASE = Path(__file__).resolve().parent.parent T2A = Path("/home/dom/ai/t2a") OUT = BASE / "data" / "processed" OUT.mkdir(parents=True, exist_ok=True) # --- Prompts --- SYSTEM_PROMPT_SCENARIO = """Tu es un formateur DIM (Département d'Information Médicale) expert en codage PMSI. Tu génères des scénarios cliniques réalistes et anonymisés pour former des médecins DIM au codage CIM-10. Pour chaque code CIM-10 fourni, tu dois produire : 1. Un SCÉNARIO CLINIQUE réaliste (3-5 phrases, anonymisé, comme extrait d'un compte-rendu d'hospitalisation) 2. Un RAISONNEMENT DIM structuré montrant la démarche de codage Le raisonnement doit suivre ces étapes : - analyse_clinique : ce que le texte clinique révèle - codes_candidats : 2-3 codes CIM-10 envisageables avec leur libellé - discrimination : pourquoi le code retenu est le bon (et pas les autres) - regle_pmsi : règle PMSI applicable (DP/DAS, exclusions, conventions dague/astérisque, etc.) - code : le code CIM-10 retenu - confidence : high/medium/low - justification : synthèse en 1 phrase IMPORTANT : - Les scénarios doivent être VARIÉS (âges, sexes, contextes différents) - Anonymisés (pas de vrais noms/dates) - Médicalement cohérents - En français médical professionnel - Réponse en JSON valide uniquement""" SYSTEM_PROMPT_DISCRIM = """Tu es un formateur DIM expert en codage PMSI. Tu crées des exercices de discrimination entre codes CIM-10 proches pour former des médecins DIM. Pour chaque groupe de codes fourni, génère UN scénario clinique où le choix entre les codes est subtil, puis montre le raisonnement complet pour arriver au bon code. IMPORTANT : Réponse en JSON valide uniquement.""" SYSTEM_PROMPT_RULES = """Tu es un formateur DIM expert en règles PMSI. Tu crées des exercices d'application des règles PMSI (codage DP/DAS, CMA, séjours multi-unités, etc.). Pour chaque situation fournie, génère un scénario d'hospitalisation et montre comment les règles PMSI s'appliquent au codage. IMPORTANT : Réponse en JSON valide uniquement.""" def load_faiss_metadata(): """Charger les métadonnées FAISS.""" meta_path = T2A / "data" / "rag_index" / "metadata.json" with open(meta_path) as f: return json.load(f) def load_cim10_fhir(): """Charger les concepts FHIR pour enrichir les prompts.""" fhir_path = BASE / "data" / "raw" / "smt_cim10_fhir.json" if not fhir_path.exists(): return {} with open(fhir_path) as f: data = json.load(f) by_code = {} for c in data.get("concept", []): by_code[c["code"]] = c return by_code def load_cocoa_entries(): """Charger les entrées CoCoA parsées.""" cocoa_path = OUT / "cocoa_entries_debug.json" if not cocoa_path.exists(): return {} with open(cocoa_path) as f: return json.load(f) def clean_extrait(extrait): """Nettoyer un extrait FAISS (enlever bruit OCR, numéros de page, etc.).""" import re # Enlever les numéros de page isolés (sur leur propre ligne ou collés) extrait = re.sub(r'\n\s*\d{1,4}\s*\n', '\n', extrait) extrait = re.sub(r'^\d{1,4}\s*\n', '', extrait) # Enlever les transitions de chapitre extrait = re.sub(r'Chapitre\s+[IVX]+\b.*', '', extrait) # Enlever les lignes de classification extrait = re.sub(r'Classification Internationale.*$', '', extrait, flags=re.MULTILINE) # Enlever les lignes vides multiples extrait = re.sub(r'\n{2,}', '\n', extrait) # Tronquer au premier code d'une AUTRE catégorie lines = extrait.split('\n') first_code = None clean_lines = [] for line in lines: stripped = line.strip() # Ligne ne contenant qu'un nombre = bruit if re.match(r'^\d{1,4}$', stripped): continue m = re.match(r'^([A-Z]\d{2}(?:\.\d{1,2})?)[*†]?\s', stripped) if m: code = m.group(1) if first_code is None: first_code = code elif not code.startswith(first_code[:3]): break # On est passé à un autre groupe de codes clean_lines.append(stripped) result = '\n'.join(clean_lines).strip() # Limiter la longueur if len(result) > 400: result = result[:400].rsplit('\n', 1)[0] return result def select_codes_for_scenarios(metadata, n=500): """Sélectionner les codes CIM-10 les plus intéressants pour la génération.""" # Filtrer les entrées CIM-10 avec des extraits substantiels cim10_entries = [m for m in metadata if m.get("document") == "cim10" and len(m.get("extrait", "")) > 20] # Prioriser les codes avec des extraits riches cim10_entries.sort(key=lambda x: len(x.get("extrait", "")), reverse=True) # Prendre les codes uniques seen = set() selected = [] for m in cim10_entries: code = m["code"] if code not in seen and "." in code: # Préférer les sous-codes (plus spécifiques) seen.add(code) selected.append(m) if len(selected) >= n: break # Si pas assez de sous-codes, ajouter des catégories if len(selected) < n: for m in cim10_entries: code = m["code"] if code not in seen: seen.add(code) selected.append(m) if len(selected) >= n: break random.shuffle(selected) return selected[:n] def select_discrimination_groups(metadata, cocoa_entries, n=100): """Sélectionner des groupes de codes proches pour la discrimination.""" # Grouper par catégorie parente (3 premiers caractères) by_parent = {} for m in metadata: if m.get("document") != "cim10": continue code = m.get("code", "") if "." in code: parent = code.split(".")[0] by_parent.setdefault(parent, []).append(m) # Sélectionner les groupes avec 2-6 sous-codes groups = [] for parent, children in by_parent.items(): if 2 <= len(children) <= 6: groups.append({ "parent": parent, "codes": [{"code": c["code"], "extrait": clean_extrait(c["extrait"])[:150]} for c in children] }) random.shuffle(groups) return groups[:n] def build_scenario_prompt(codes_batch, fhir_by_code, cocoa_entries): """Construire le prompt pour un batch de codes (scénarios cliniques).""" items = [] for meta in codes_batch: code = meta["code"] # Source primaire : FHIR (propre) fhir = fhir_by_code.get(code, {}) display = fhir.get("display", "") # Source secondaire : CoCoA (riche) cocoa = cocoa_entries.get(code, {}) cocoa_desc = cocoa.get("description", "") exclusions = cocoa.get("exclusions", [])[:4] synonyms = cocoa.get("synonyms", [])[:5] comprend = cocoa.get("comprend", [])[:3] severity = cocoa.get("severity") clinical = " ".join(cocoa.get("clinical_text", []))[:200] # Utiliser la meilleure description disponible desc = display or cocoa_desc or clean_extrait(meta["extrait"])[:200] item = f"CODE: {code}\nLIBELLÉ: {desc}" if synonyms: item += f"\nSYNONYMES: {'; '.join(synonyms)}" if comprend: item += f"\nCOMPREND: {'; '.join(comprend)}" if exclusions: item += f"\nEXCLUSIONS: {'; '.join(exclusions)}" if severity: item += f"\nSÉVÉRITÉ CMA: {severity}" if clinical: item += f"\nDESCRIPTION CLINIQUE: {clinical}" items.append(item) codes_text = "\n---\n".join(items) prompt = f"""Génère un scénario clinique et un raisonnement DIM pour chacun des {len(codes_batch)} codes suivants. {codes_text} Réponds en JSON avec cette structure exacte : {{"scenarios": [ {{ "code": "le code CIM-10", "scenario_clinique": "Texte du scénario clinique réaliste (3-5 phrases)", "raisonnement": {{ "analyse_clinique": "Analyse des éléments cliniques pertinents", "codes_candidats": "2-3 codes envisagés avec libellés", "discrimination": "Pourquoi ce code et pas les autres", "regle_pmsi": "Règle PMSI applicable", "code_retenu": "le code", "confidence": "high", "justification": "Synthèse en 1 phrase" }} }}, ... ]}} Le tableau "scenarios" DOIT contenir exactement {len(codes_batch)} objets, un par code.""" return prompt def build_discrimination_prompt(group, fhir_by_code): """Construire le prompt pour un exercice de discrimination.""" codes_text = "\n".join( f"- {c['code']}: {c['extrait']}" for c in group["codes"] ) prompt = f"""Voici un groupe de codes CIM-10 de la catégorie {group['parent']} : {codes_text} Génère UN scénario clinique réaliste où le choix entre ces codes est subtil et demande une réflexion. Puis montre le raisonnement DIM complet pour arriver au bon code. Réponds en JSON : {{ "scenario_clinique": "Le scénario (5-8 phrases, réaliste, anonymisé)", "codes_en_jeu": ["code1", "code2"], "raisonnement": {{ "analyse_clinique": "...", "codes_candidats": "...", "discrimination": "Explication détaillée de pourquoi un code est préféré", "regle_pmsi": "Règle PMSI applicable", "code_retenu": "le code correct", "confidence": "high/medium", "justification": "..." }} }} JSON valide uniquement.""" return prompt def call_openai(client, model, system_prompt, user_prompt, temperature=0.7): """Appeler l'API OpenAI.""" response = client.chat.completions.create( model=model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], temperature=temperature, max_tokens=4096, response_format={"type": "json_object"}, ) content = response.choices[0].message.content return json.loads(content) def _extract_items(result): """Extraire la liste d'items depuis la réponse JSON (gère différents formats).""" if isinstance(result, list): return result if isinstance(result, dict): # Chercher récursivement un tableau d'items for v in result.values(): if isinstance(v, list) and v and isinstance(v[0], dict): return v # Si pas de tableau, c'est peut-être un seul item if "scenario_clinique" in result or "raisonnement" in result: return [result] # Dernier recours : aplatir les valeurs dict items = [] for v in result.values(): if isinstance(v, dict) and ("scenario_clinique" in v or "raisonnement" in v): items.append(v) if items: return items return [] def convert_to_chatml(scenario_data): """Convertir un résultat de génération en format ChatML.""" if not isinstance(scenario_data, dict): return None system_msg = "Tu es un médecin DIM expert en codage CIM-10 pour le PMSI français. Tu codes les diagnostics en suivant une démarche structurée." scenario = scenario_data.get("scenario_clinique", "") raisonnement = scenario_data.get("raisonnement", {}) # Si pas de scenario_clinique, chercher dans d'autres clés possibles if not scenario: scenario = scenario_data.get("scenario", scenario_data.get("texte_clinique", "")) # Si le raisonnement est directement dans le dict (pas imbriqué) if not raisonnement and "analyse_clinique" in scenario_data: raisonnement = {k: v for k, v in scenario_data.items() if k != "scenario_clinique"} if not scenario or not raisonnement: return None user_msg = f"Code ce diagnostic en CIM-10.\n\nTEXTE CLINIQUE : {scenario}" assistant_msg = json.dumps(raisonnement, ensure_ascii=False) return { "messages": [ {"role": "system", "content": system_msg}, {"role": "user", "content": user_msg}, {"role": "assistant", "content": assistant_msg}, ] } def process_scenario_batch(client, model, batch, fhir_by_code, cocoa_entries, batch_idx): """Traiter un batch de codes pour générer des scénarios.""" prompt = build_scenario_prompt(batch, fhir_by_code, cocoa_entries) try: result = call_openai(client, model, SYSTEM_PROMPT_SCENARIO, prompt) # Le résultat peut être un tableau ou un dict avec une clé contenant le tableau items = _extract_items(result) examples = [] for item in items: chatml = convert_to_chatml(item) if chatml: examples.append(chatml) else: print(f" [Batch {batch_idx}] Item non converti: {list(item.keys()) if isinstance(item, dict) else type(item)}") if len(examples) < len(batch): print(f" [Batch {batch_idx}] {len(examples)}/{len(batch)} exemples récupérés") return examples except Exception as e: print(f" [Batch {batch_idx}] Erreur: {e}") return [] def process_discrimination_batch(client, model, group, fhir_by_code, batch_idx): """Traiter un groupe pour générer un exercice de discrimination.""" prompt = build_discrimination_prompt(group, fhir_by_code) try: result = call_openai(client, model, SYSTEM_PROMPT_DISCRIM, prompt) chatml = convert_to_chatml(result) return [chatml] if chatml else [] except Exception as e: print(f" [Discrim {batch_idx}] Erreur: {e}") return [] def main(): parser = argparse.ArgumentParser(description="Génération de données synthétiques via OpenAI") parser.add_argument("--n", type=int, default=500, help="Nombre de scénarios à générer") parser.add_argument("--n-discrim", type=int, default=100, help="Nombre d'exercices de discrimination") parser.add_argument("--batch", type=int, default=5, help="Codes par batch (scénarios)") parser.add_argument("--model", default="gpt-4o", help="Modèle OpenAI") parser.add_argument("--workers", type=int, default=3, help="Workers parallèles") parser.add_argument("--dry-run", action="store_true", help="Afficher les prompts sans appeler l'API") parser.add_argument("--resume", action="store_true", help="Reprendre depuis la dernière exécution") args = parser.parse_args() # Vérifier la clé API api_key = os.environ.get("OPENAI_API_KEY") if not api_key and not args.dry_run: print("Erreur: OPENAI_API_KEY non définie.") print(" export OPENAI_API_KEY='sk-...'") sys.exit(1) # Charger les données print("Chargement des données...") metadata = load_faiss_metadata() fhir_by_code = load_cim10_fhir() cocoa_entries = load_cocoa_entries() print(f" FAISS: {len(metadata)} entrées") print(f" FHIR: {len(fhir_by_code)} concepts") print(f" CoCoA: {len(cocoa_entries)} entrées") # Sélectionner les codes print(f"\nSélection de {args.n} codes pour les scénarios...") selected_codes = select_codes_for_scenarios(metadata, n=args.n) print(f" {len(selected_codes)} codes sélectionnés") print(f"Sélection de {args.n_discrim} groupes pour la discrimination...") discrim_groups = select_discrimination_groups(metadata, cocoa_entries, n=args.n_discrim) print(f" {len(discrim_groups)} groupes sélectionnés") # Découper en batches scenario_batches = [ selected_codes[i:i + args.batch] for i in range(0, len(selected_codes), args.batch) ] print(f"\n{len(scenario_batches)} batches de scénarios ({args.batch} codes/batch)") print(f"{len(discrim_groups)} exercices de discrimination") # Fichier de sortie (avec reprise possible) output_path = OUT / "synthetic_chatml.jsonl" existing_count = 0 if args.resume and output_path.exists(): with open(output_path) as f: existing_count = sum(1 for _ in f) print(f"\nReprise: {existing_count} exemples existants") if args.dry_run: # Mode dry-run : montrer des exemples de prompts print("\n=== DRY RUN ===") print("\n--- Exemple de prompt scénario ---") prompt = build_scenario_prompt(scenario_batches[0], fhir_by_code, cocoa_entries) print(prompt[:2000]) print("\n--- Exemple de prompt discrimination ---") if discrim_groups: prompt = build_discrimination_prompt(discrim_groups[0], fhir_by_code) print(prompt[:1500]) return # Initialiser le client OpenAI from openai import OpenAI client = OpenAI(api_key=api_key) all_examples = [] total_batches = len(scenario_batches) + len(discrim_groups) completed = 0 errors = 0 # Ouvrir le fichier en mode append mode = "a" if args.resume else "w" with open(output_path, mode) as fh: # Phase 1 : Scénarios cliniques print(f"\n{'='*50}") print(f"Phase 1 : Génération des scénarios cliniques...") print(f"{'='*50}") with ThreadPoolExecutor(max_workers=args.workers) as executor: futures = {} for i, batch in enumerate(scenario_batches): future = executor.submit( process_scenario_batch, client, args.model, batch, fhir_by_code, cocoa_entries, i ) futures[future] = i for future in as_completed(futures): batch_idx = futures[future] try: examples = future.result() for ex in examples: fh.write(json.dumps(ex, ensure_ascii=False) + "\n") all_examples.append(ex) completed += 1 if completed % 10 == 0: print(f" [{completed}/{len(scenario_batches)}] {len(all_examples)} exemples générés...") except Exception as e: errors += 1 print(f" [Batch {batch_idx}] Exception: {e}") print(f" Scénarios: {len(all_examples)} exemples") # Phase 2 : Discrimination print(f"\n{'='*50}") print(f"Phase 2 : Génération des exercices de discrimination...") print(f"{'='*50}") discrim_count = 0 with ThreadPoolExecutor(max_workers=args.workers) as executor: futures = {} for i, group in enumerate(discrim_groups): future = executor.submit( process_discrimination_batch, client, args.model, group, fhir_by_code, i ) futures[future] = i for future in as_completed(futures): batch_idx = futures[future] try: examples = future.result() for ex in examples: fh.write(json.dumps(ex, ensure_ascii=False) + "\n") all_examples.append(ex) discrim_count += 1 except Exception as e: errors += 1 print(f" [Discrim {batch_idx}] Exception: {e}") print(f" Discrimination: {discrim_count} exemples") # Stats finales total = len(all_examples) + existing_count print(f"\n{'='*50}") print(f"Génération terminée !") print(f" Nouveaux exemples : {len(all_examples)}") if existing_count: print(f" Existants (reprise): {existing_count}") print(f" Total : {total}") print(f" Erreurs : {errors}") print(f" Fichier : {output_path}") if output_path.exists(): print(f" Taille : {output_path.stat().st_size / 1024:.0f} Ko") if __name__ == "__main__": main()