shashanks/medical_coding
0
1"""2Randomized Case Generator for the Medical Coding Auditor Environment.3 4Generates procedurally-created task scenarios by composing error templates5from the existing guideline data. Each generated case is deterministic given6the same seed (derived from episode_id), so grading remains reproducible.7 8Supports difficulty targeting: random_easy, random_medium, random_hard, random_expert,9or plain "random" for a random difficulty.10"""11 12from __future__ import annotations13 14import hashlib15import random16from typing import Any, Dict, List, Optional, Tuple17 18# ---------------------------------------------------------------------------19# Patient templates20# ---------------------------------------------------------------------------21 22_FIRST_NAMES_M = ["James", "Robert", "Michael", "David", "William", "Thomas", "Richard", "Charles"]23_FIRST_NAMES_F = ["Mary", "Patricia", "Jennifer", "Linda", "Elizabeth", "Susan", "Karen", "Nancy"]24_LAST_INITIALS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"25_INSURANCES = [26 "Blue Cross PPO", "Medicare Part B", "Aetna HMO",27 "United Healthcare PPO", "Cigna EPO", "Humana Gold Plus",28 "Anthem Blue Shield", "Tricare Standard",29]30 31 32def _make_patient(rng: random.Random, sex: Optional[str] = None) -> Dict[str, Any]:33 """Generate a random patient with demographics."""34 if sex is None:35 sex = rng.choice(["male", "female"])36 age = rng.randint(22, 78)37 first = rng.choice(_FIRST_NAMES_M if sex == "male" else _FIRST_NAMES_F)38 last_init = rng.choice(_LAST_INITIALS)39 return {40 "age": age,41 "sex": sex,42 "mrn": f"MRN-GEN-{rng.randint(1000, 9999)}",43 "insurance": rng.choice(_INSURANCES),44 "_name": f"{first} {last_init}.",45 }46 47 48# ---------------------------------------------------------------------------49# Error templates — each returns (proposed_codes_fragment, expected_error, note_snippet)50# ---------------------------------------------------------------------------51 52def _error_demographic_mismatch(53 rng: random.Random, patient: Dict[str, Any], data: Dict[str, Any],54) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Any], str]:55 """O80 on a male patient."""56 patient["sex"] = "male" # force male for this error57 codes = {58 "O80": {"description": "Encounter for full-term uncomplicated delivery", "code_type": "ICD-10-CM"},59 }60 error = {61 "code": "O80",62 "error_type": "demographic_mismatch",63 "description": "O80 is a maternity code applicable only to female patients. Cannot be assigned to a male patient.",64 "key_terms": ["female", "male", "maternity", "obstetric", "delivery", "sex", "gender", "demographic"],65 }66 title = "Mr." if patient["sex"] == "male" else "Ms."67 note = (68 f"HISTORY OF PRESENT ILLNESS: {title} {patient['_name']} is a "69 f"{patient['age']}-year-old {patient['sex']} presenting for a routine wellness visit. "70 f"No obstetric or gynecological history is documented."71 )72 return codes, error, note73 74 75def _error_excludes1_copd_asthma(76 rng: random.Random, patient: Dict[str, Any], data: Dict[str, Any],77) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Any], str]:78 """J44.1 + J45.20 Excludes1 conflict."""79 codes = {80 "J44.1": {"description": "COPD with acute exacerbation", "code_type": "ICD-10-CM"},81 "J45.20": {"description": "Mild intermittent asthma, uncomplicated", "code_type": "ICD-10-CM"},82 }83 error = {84 "code": "J44.1",85 "error_type": "excludes1_conflict",86 "conflicting_code": "J45.20",87 "description": "J44.1 (COPD) has Excludes1 for J45 (asthma). Mutually exclusive — cannot code together.",88 "key_terms": ["Excludes1", "mutually exclusive", "asthma", "COPD", "J45", "J44", "cannot be coded together"],89 }90 note = (91 f"HISTORY OF PRESENT ILLNESS: Patient has a 15-year history of COPD (GOLD stage II). "92 f"Presenting with acute exacerbation — increased dyspnea and sputum. "93 f"Prior asthma label from primary care was superseded by COPD diagnosis after PFTs "94 f"confirmed irreversible obstruction. Patient does NOT carry a concurrent asthma diagnosis.\n\n"95 f"SPIROMETRY: FEV1 48% predicted, post-bronchodilator improvement <10% (consistent with COPD)."96 )97 return codes, error, note98 99 100def _error_excludes1_diabetes(101 rng: random.Random, patient: Dict[str, Any], data: Dict[str, Any],102) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Any], str]:103 """E10.9 + E11.9 Excludes1 conflict."""104 codes = {105 "E10.9": {"description": "Type 1 diabetes mellitus without complications", "code_type": "ICD-10-CM"},106 "E11.9": {"description": "Type 2 diabetes mellitus without complications", "code_type": "ICD-10-CM"},107 }108 error = {109 "code": "E10.9",110 "error_type": "excludes1_conflict",111 "conflicting_code": "E11.9",112 "description": "E10.9 (Type 1 DM) has Excludes1 for E11 (Type 2 DM). Mutually exclusive.",113 "key_terms": ["Excludes1", "mutually exclusive", "Type 1", "Type 2", "E11", "E10", "diabetes"],114 }115 note = (116 f"ENDOCRINOLOGY ASSESSMENT: Patient has Type 2 diabetes mellitus, managed with metformin. "117 f"C-peptide is normal (2.3 ng/mL), GAD65 autoantibodies are negative, "118 f"ruling out autoimmune Type 1 diabetes. Diagnosis is definitively Type 2 only."119 )120 return codes, error, note121 122 123def _error_ncci_echo(124 rng: random.Random, patient: Dict[str, Any], data: Dict[str, Any],125) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Any], str]:126 """93306 + 93307 or 93306 + 93308 NCCI bundling."""127 limited = rng.choice(["93307", "93308"])128 limited_desc = (129 "Echocardiography, transthoracic, limited study without Doppler"130 if limited == "93307"131 else "Echocardiography, transthoracic, follow-up or limited study"132 )133 codes = {134 "93306": {"description": "Echocardiography, transthoracic, complete with Doppler", "code_type": "CPT"},135 limited: {"description": limited_desc, "code_type": "CPT"},136 }137 error = {138 "code": "93306",139 "error_type": "ncci_edit",140 "conflicting_code": limited,141 "description": f"93306 (complete TTE) and {limited} (limited TTE) are NCCI PTP edit — unbundling violation.",142 "key_terms": ["bundling", "unbundling", "NCCI", "PTP", "complete", "limited", limited, "comprehensive"],143 }144 note = (145 f"SERVICES RENDERED: A complete transthoracic echocardiogram was performed with 2D imaging, "146 f"M-mode, spectral Doppler, and color flow Doppler. LVEF estimated at {rng.randint(40, 60)}%. "147 f"Additionally, a limited echocardiographic study was documented for focused wall motion assessment."148 )149 return codes, error, note150 151 152def _error_ncci_em(153 rng: random.Random, patient: Dict[str, Any], data: Dict[str, Any],154) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Any], str]:155 """99213 + 99214 E/M unbundling."""156 codes = {157 "99213": {"description": "Office visit, low-moderate complexity", "code_type": "CPT"},158 "99214": {"description": "Office visit, moderate-high complexity", "code_type": "CPT"},159 }160 error = {161 "code": "99213",162 "error_type": "ncci_edit",163 "conflicting_code": "99214",164 "description": "99213 and 99214 are different E/M levels for the same service — NCCI edit, only one should be billed.",165 "key_terms": ["bundling", "NCCI", "E/M", "99213", "99214", "same service", "unbundling"],166 }167 note = (168 f"SERVICES RENDERED: An office visit was performed. Medical decision making was "169 f"moderate to high complexity given the patient's multiple chronic conditions."170 )171 return codes, error, note172 173 174def _error_specificity_fracture(175 rng: random.Random, patient: Dict[str, Any], data: Dict[str, Any],176) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Any], str]:177 """S52.501A wrong 7th character (should be D for follow-up)."""178 codes = {179 "S52.501A": {180 "description": "Fracture of lower end of right radius, initial encounter",181 "code_type": "ICD-10-CM",182 },183 }184 error = {185 "code": "S52.501A",186 "error_type": "specificity_error",187 "description": "7th character 'A' (initial) is wrong — this is a follow-up visit. Should be S52.501D (subsequent).",188 "key_terms": ["7th character", "subsequent", "initial", "follow-up", "healing", "S52.501D", "phase of care"],189 }190 weeks = rng.randint(4, 12)191 note = (192 f"HISTORY OF PRESENT ILLNESS: Patient presents for follow-up {weeks} weeks after closed "193 f"fracture of distal right radius. Cast was removed previously. X-ray shows routine healing "194 f"with appropriate callus formation.\n\n"195 f"Note: This is a SUBSEQUENT ENCOUNTER for a healing fracture — active treatment concluded {weeks} weeks ago."196 )197 return codes, error, note198 199 200def _error_untraceable_code(201 rng: random.Random, patient: Dict[str, Any], data: Dict[str, Any],202) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Any], str]:203 """A fabricated ICD-10 code that doesn't exist."""204 fake_codes = ["Z99.999", "E99.99", "J99.999", "M99.999", "R99.999"]205 fake = rng.choice(fake_codes)206 codes = {207 fake: {"description": "Unspecified condition (placeholder)", "code_type": "ICD-10-CM"},208 }209 error = {210 "code": fake,211 "error_type": "untraceable_code",212 "description": f"{fake} does not exist in the official ICD-10-CM tabular list. Untraceable code.",213 "key_terms": ["invalid", "untraceable", "not exist", "not found", "official", "billable"],214 }215 note = "" # untraceable codes don't need clinical note context216 return codes, error, note217 218 219# ---------------------------------------------------------------------------220# Filler codes (valid, no errors — false positive traps)221# ---------------------------------------------------------------------------222 223_FILLER_CODES: List[Dict[str, Any]] = [224 {"code": "Z00.00", "description": "Encounter for general adult medical examination", "code_type": "ICD-10-CM"},225 {"code": "Z87.891", "description": "Personal history of other specified conditions", "code_type": "ICD-10-CM"},226 {"code": "M79.621", "description": "Pain in right upper arm", "code_type": "ICD-10-CM"},227 {"code": "99213", "description": "Office visit, low-moderate complexity", "code_type": "CPT"},228 {"code": "99214", "description": "Office visit, moderate-high complexity", "code_type": "CPT"},229]230 231 232# ---------------------------------------------------------------------------233# Error pools per difficulty234# ---------------------------------------------------------------------------235 236_ERROR_POOL_EASY = [_error_demographic_mismatch]237_ERROR_POOL_MEDIUM = [_error_excludes1_copd_asthma, _error_excludes1_diabetes, _error_ncci_echo, _error_ncci_em]238_ERROR_POOL_HARD = [_error_specificity_fracture, _error_untraceable_code]239 240_DIFFICULTY_CONFIG = {241 "easy": {"error_count": 1, "pools": [_ERROR_POOL_EASY], "filler_count": (1, 2), "max_steps": 10},242 "medium": {"error_count": 1, "pools": [_ERROR_POOL_MEDIUM], "filler_count": (1, 2), "max_steps": 15},243 "hard": {"error_count": 2, "pools": [_ERROR_POOL_MEDIUM, _ERROR_POOL_HARD], "filler_count": (1, 2), "max_steps": 20},244 "expert": {"error_count": 3, "pools": [_ERROR_POOL_EASY, _ERROR_POOL_MEDIUM, _ERROR_POOL_HARD], "filler_count": (1, 3), "max_steps": 25},245}246 247 248# ---------------------------------------------------------------------------249# Clinical note assembly250# ---------------------------------------------------------------------------251 252def _assemble_clinical_note(253 patient: Dict[str, Any],254 error_snippets: List[str],255 rng: random.Random,256) -> str:257 """Compose a synthetic clinical note from patient info and error-specific snippets."""258 title = "Mr." if patient["sex"] == "male" else "Ms."259 age = patient["age"]260 sex = patient["sex"]261 262 parts = [263 f"REASON FOR VISIT: Comprehensive evaluation and management.",264 "",265 ]266 267 # Add error-specific clinical content268 for snippet in error_snippets:269 if snippet:270 parts.append(snippet)271 parts.append("")272 273 # Generic physical exam274 bp_sys = rng.randint(110, 150)275 bp_dia = rng.randint(70, 95)276 hr = rng.randint(62, 98)277 parts.extend([278 f"PHYSICAL EXAM: Vitals: BP {bp_sys}/{bp_dia}, HR {hr}. "279 f"General: {title} {patient['_name']} is a {age}-year-old {sex} "280 f"in no acute distress. Exam otherwise unremarkable for areas not addressed above.",281 "",282 f"ASSESSMENT AND PLAN: See individual assessments above. "283 f"Return for follow-up as indicated.",284 ])285 286 return "\n".join(parts)287 288 289# ---------------------------------------------------------------------------290# Public API291# ---------------------------------------------------------------------------292 293def generate_random_task(294 data: Dict[str, Any],295 seed: str,296 difficulty: Optional[str] = None,297) -> Dict[str, Any]:298 """299 Generate a random task scenario.300 301 Args:302 data: The loaded ground_truth_cases.json data (for guideline reference).303 seed: A string seed for deterministic generation (typically episode_id).304 difficulty: One of 'easy', 'medium', 'hard', 'expert', or None (random).305 306 Returns:307 A task dict in the same format as the fixed tasks in ground_truth_cases.json.308 """309 # Deterministic RNG from seed310 seed_int = int(hashlib.sha256(seed.encode()).hexdigest()[:16], 16)311 rng = random.Random(seed_int)312 313 # Pick difficulty314 if difficulty not in _DIFFICULTY_CONFIG:315 difficulty = rng.choice(list(_DIFFICULTY_CONFIG.keys()))316 config = _DIFFICULTY_CONFIG[difficulty]317 318 # Generate patient319 patient = _make_patient(rng)320 321 # Select error generators — pick one from each pool, up to error_count322 error_generators = []323 pools = list(config["pools"])324 for _ in range(config["error_count"]):325 if not pools:326 break327 pool = pools.pop(0)328 gen = rng.choice(pool)329 error_generators.append(gen)330 331 # Generate errors332 proposed_codes: Dict[str, Dict[str, str]] = {}333 expected_errors: List[Dict[str, Any]] = []334 note_snippets: List[str] = []335 used_codes: set = set()336 337 for gen_fn in error_generators:338 codes_frag, error, snippet = gen_fn(rng, patient, data)339 # Avoid code collisions340 if any(c in used_codes for c in codes_frag):341 continue342 proposed_codes.update(codes_frag)343 used_codes.update(codes_frag.keys())344 expected_errors.append(error)345 note_snippets.append(snippet)346 347 # Add filler codes (false-positive traps)348 filler_min, filler_max = config["filler_count"]349 n_fillers = rng.randint(filler_min, filler_max)350 available_fillers = [f for f in _FILLER_CODES if f["code"] not in used_codes]351 rng.shuffle(available_fillers)352 for filler in available_fillers[:n_fillers]:353 proposed_codes[filler["code"]] = {354 "description": filler["description"],355 "code_type": filler["code_type"],356 }357 358 # Assemble clinical note359 clinical_note = _assemble_clinical_note(patient, note_snippets, rng)360 361 # Build hints362 hints = [363 "This is a procedurally generated case — investigate all proposed codes systematically.",364 f"Expect approximately {len(expected_errors)} error(s) in this case.",365 "Not every code has an error — avoid false positives.",366 ]367 368 # Remove _name from patient before returning (internal only)369 patient_clean = {k: v for k, v in patient.items() if not k.startswith("_")}370 371 return {372 "task_id": f"random_{difficulty}_{seed[:8]}",373 "difficulty": difficulty,374 "description": f"Procedurally generated {difficulty} case with {len(expected_errors)} error(s).",375 "scenario": f"A {patient['age']}-year-old {patient['sex']} patient encounter with {len(proposed_codes)} proposed codes.",376 "patient": patient_clean,377 "clinical_note": clinical_note,378 "proposed_codes": proposed_codes,379 "expected_errors": expected_errors,380 "max_steps": config["max_steps"],381 "hints": hints,382 }383 