CoolFace
Apppublic

shashanks/medical_coding

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
inference.py447 linesDownload Raw Back to root
1"""2Inference Script — Medical Coding Auditor Environment3=======================================================4MANDATORY ENVIRONMENT VARIABLES:5    API_BASE_URL      The API endpoint for the LLM (default: "https://api.openai.com/v1").6    MODEL_NAME        The model identifier to use for inference (default: "gpt-4.1-mini").7    HF_TOKEN          Your Hugging Face / API key (used as API key for LLM calls). No default.8    LOCAL_IMAGE_NAME  (optional) Docker image name if launching env via from_docker_image().9    ENV_BASE_URL      (optional) Base URL of the running env server (default: http://localhost:7860).10 11STDOUT FORMAT (required by hackathon spec):12    [START] task=<task_id> env=medical_coding model=<model_name>13    [STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>14    [END] success=<true|false> steps=<n> rewards=<r1,r2,...>15"""16 17import asyncio18import json19import os20import textwrap21from typing import Any, Dict, List, Optional22 23from openai import OpenAI24 25# ---------------------------------------------------------------------------26# Configuration27# ---------------------------------------------------------------------------28API_BASE_URL = os.getenv("API_BASE_URL", "https://api.openai.com/v1")29MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4.1-mini")30HF_TOKEN = os.getenv("HF_TOKEN")31 32if HF_TOKEN is None:33    raise ValueError("HF_TOKEN environment variable is required")34 35# Optional — if you use from_docker_image():36LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME")37 38ENV_BASE_URL = os.getenv("ENV_BASE_URL", "http://localhost:7860")39BENCHMARK = "medical_coding"40 41TASK_IDS = [42    "easy_demographic",43    "medium_ncci_conflict",44    "medium_excludes1",45    "hard_specificity_untraceable",46    "expert_multi_error",47]48 49MAX_STEPS_PER_TASK: Dict[str, int] = {50    "easy_demographic": 10,51    "medium_ncci_conflict": 15,52    "medium_excludes1": 15,53    "hard_specificity_untraceable": 20,54    "expert_multi_error": 25,55}56 57SUCCESS_THRESHOLD = 0.558TEMPERATURE = 0.259MAX_TOKENS = 40060 61# ---------------------------------------------------------------------------62# Logging (required stdout format)63# ---------------------------------------------------------------------------64 65def log_start(task: str, env: str, model: str) -> None:66    print(f"[START] task={task} env={env} model={model}", flush=True)67 68 69def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:70    error_val = error if error else "null"71    done_val = str(done).lower()72    print(73        f"[STEP] step={step} action={action} reward={reward:.2f} done={done_val} error={error_val}",74        flush=True,75    )76 77 78def log_end(success: bool, steps: int, rewards: List[float]) -> None:79    rewards_str = ",".join(f"{r:.2f}" for r in rewards)80    print(81        f"[END] success={str(success).lower()} steps={steps} rewards={rewards_str}",82        flush=True,83    )84 85 86# ---------------------------------------------------------------------------87# LLM prompting88# ---------------------------------------------------------------------------89 90SYSTEM_PROMPT = textwrap.dedent("""91You are an expert Medical Coding Auditor performing a pre-bill compliance review.92 93Your task: review proposed ICD-10-CM and CPT billing codes against the patient's94clinical note and demographics. Identify coding errors WITHOUT assigning new codes.95 96Available tools (respond with exactly ONE JSON object per turn):97 981. query_guideline — look up official coding guidelines for a specific code.99   {"action_type": "query_guideline", "code": "<CODE>"}100 1012. check_ncci_edits — check if two CPT codes have a CMS NCCI PTP bundling conflict.102   {"action_type": "check_ncci_edits", "code1": "<CPT1>", "code2": "<CPT2>"}103 1043. flag_error — record a confirmed coding error in the audit report.105   {"action_type": "flag_error", "code": "<CODE>", "error_type": "<TYPE>", "justification": "<REASON>"}106   error_type values:107     demographic_mismatch  — code inapplicable to patient's sex or age108     excludes1_conflict    — two mutually exclusive ICD-10-CM diagnosis codes109     ncci_edit             — CMS NCCI PTP bundling violation110     specificity_error     — wrong 7th character or insufficient code specificity111     untraceable_code      — code does not exist in any official code set112 1134. ask_clarifying_question — ask the physician for missing clinical information when the note is ambiguous.114   {"action_type": "ask_clarifying_question", "question": "<YOUR QUESTION>"}115   Use when you need to confirm: patient sex/age, encounter type (initial vs. follow-up), or diagnosis history.116 1175. extract_evidence — highlight an exact text span from the clinical note as evidence for a coding decision.118   {"action_type": "extract_evidence", "evidence_text": "<EXACT SUBSTRING FROM NOTE>"}119   Use before flagging an error to ground your reasoning in specific documentation.120 1216. submit_audit — submit the completed audit report when you are done.122   {"action_type": "submit_audit"}123 124RULES:125- Only query or flag codes that appear in the proposed_codes list.126- Query each code's guidelines before flagging it.127- For CPT code pairs, use check_ncci_edits to find bundling conflicts.128- If query_guideline returns "CODE NOT FOUND" or "INVALID/UNTRACEABLE", flag it as untraceable_code.129- Check patient sex — maternity (O-codes) ONLY apply to female patients.130- Check 7th character on injury codes against the clinical note (initial vs. subsequent encounter).131- Use ask_clarifying_question if demographics or encounter type are unclear before flagging.132- Use extract_evidence to quote the exact clinical note text that supports each flag.133- Submit the audit only after reviewing all proposed codes.134- Respond with ONLY a valid JSON object — no markdown, no extra text.135""").strip()136 137 138def build_user_prompt(obs: Dict[str, Any], step: int, history: List[str]) -> str:139    proposed = obs.get("proposed_codes", {})140    codes_summary = "\n".join(141        f"  {code}: {info.get('description', '')} [{info.get('code_type', '')}]"142        for code, info in proposed.items()143    )144    draft = obs.get("draft_report", [])145    draft_summary = json.dumps(draft, indent=2) if draft else "  (none yet)"146    queried = obs.get("codes_queried", [])147    pairs_checked = obs.get("pairs_checked", [])148    last_result = obs.get("tool_result", "")[:700]149 150    history_block = "\n".join(history[-5:]) if history else "None"151 152    return textwrap.dedent(f"""153    === STEP {step} ===154    PATIENT: age={obs.get('patient_demographics', {}).get('age')}, sex={obs.get('patient_demographics', {}).get('sex')}155    CLINICAL NOTE:156    {obs.get('clinical_note', '')[:900]}157 158    PROPOSED CODES TO AUDIT:159    {codes_summary}160 161    Already queried: {queried}162    NCCI pairs checked: {pairs_checked}163 164    DRAFT AUDIT REPORT:165    {draft_summary}166 167    LAST TOOL RESULT:168    {last_result}169 170    RECENT HISTORY:171    {history_block}172 173    What is your next action? Reply with exactly one JSON object.174    """).strip()175 176 177def parse_action(text: str) -> Optional[Dict[str, Any]]:178    """Extract a JSON action from the LLM response."""179    text = text.strip()180    try:181        obj = json.loads(text)182        if isinstance(obj, dict) and "action_type" in obj:183            return obj184    except json.JSONDecodeError:185        pass186    start, end = text.find("{"), text.rfind("}") + 1187    if start >= 0 and end > start:188        try:189            obj = json.loads(text[start:end])190            if isinstance(obj, dict) and "action_type" in obj:191                return obj192        except json.JSONDecodeError:193            pass194    return None195 196 197def get_llm_action(198    client: OpenAI,199    obs: Dict[str, Any],200    step: int,201    history: List[str],202) -> Dict[str, Any]:203    """Call the LLM and parse its action."""204    user_prompt = build_user_prompt(obs, step, history)205    try:206        completion = client.chat.completions.create(207            model=MODEL_NAME,208            messages=[209                {"role": "system", "content": SYSTEM_PROMPT},210                {"role": "user", "content": user_prompt},211            ],212            temperature=TEMPERATURE,213            max_tokens=MAX_TOKENS,214        )215        text = (completion.choices[0].message.content or "").strip()216        action = parse_action(text)217        if action:218            return action219        print(f"[DEBUG] Could not parse action from LLM output: {text[:300]}", flush=True)220    except Exception as exc:221        print(f"[DEBUG] LLM error at step {step}: {exc}", flush=True)222 223    # Fallback: query unchecked codes, then submit224    queried = obs.get("codes_queried", [])225    unchecked = [c for c in obs.get("proposed_codes", {}) if c not in queried]226    if unchecked:227        return {"action_type": "query_guideline", "code": unchecked[0]}228    return {"action_type": "submit_audit"}229 230 231# ---------------------------------------------------------------------------232# WebSocket-based environment runner233# ---------------------------------------------------------------------------234 235async def run_task_websocket(236    ws_url: str,237    task_id: str,238    client: OpenAI,239) -> float:240    """241    Run one task episode over a WebSocket connection to maintain state.242    Returns the grader_score from the terminal observation (already in [0.0, 1.0]).243    """244    import websockets245 246    max_steps = MAX_STEPS_PER_TASK[task_id]247    rewards: List[float] = []248    steps_taken = 0249    score = 0.0250    success = False251 252    log_start(task=task_id, env=BENCHMARK, model=MODEL_NAME)253 254    try:255        async with websockets.connect(ws_url, max_size=10 * 1024 * 1024) as ws:256            # Reset with task_id257            await ws.send(json.dumps({"type": "reset", "data": {"task_id": task_id}}))258            raw = await ws.recv()259            response = json.loads(raw)260            obs_payload = response.get("data", {})261            obs = obs_payload.get("observation", obs_payload)262            done = obs_payload.get("done", False)263 264            history: List[str] = []265 266            for step in range(1, max_steps + 1):267                if done:268                    break269 270                action_dict = get_llm_action(client, obs, step, history)271                action_str = json.dumps(action_dict)272 273                # Send step274                await ws.send(json.dumps({"type": "step", "data": action_dict}))275                raw = await ws.recv()276                step_response = json.loads(raw)277                step_data = step_response.get("data", {})278 279                obs = step_data.get("observation", obs)280                reward = step_data.get("reward") or 0.0281                done = step_data.get("done", False)282                error = obs.get("last_action_error") if isinstance(obs, dict) else None283 284                rewards.append(reward)285                steps_taken = step286 287                log_step(step=step, action=action_str, reward=reward, done=done, error=error)288 289                history.append(290                    f"Step {step}: {action_str[:120]} → reward={reward:+.2f}"291                    + (f" [ERROR: {error}]" if error else "")292                )293 294                if done:295                    # Use grader_score from terminal observation as the score296                    grader = obs.get("grader_score") if isinstance(obs, dict) else None297                    if grader is not None:298                        score = float(grader)299                    break300 301        success = score >= SUCCESS_THRESHOLD302 303    except Exception as exc:304        print(f"[DEBUG] WebSocket task {task_id} error: {exc}", flush=True)305 306    finally:307        log_end(success=success, steps=steps_taken, rewards=rewards)308 309    return score310 311 312# ---------------------------------------------------------------------------313# HTTP-based fallback runner (stateless — demonstrates single-step interactions)314# ---------------------------------------------------------------------------315 316async def run_task_http(317    base_url: str,318    task_id: str,319    client: OpenAI,320) -> float:321    """322    HTTP-based stateless runner. Since the OpenEnv HTTP endpoints are stateless323    (each request creates a fresh environment), this runner passes the full324    state from the reset observation into a stateful local environment instance325    for demonstration.326    """327    # Import environment directly for stateful HTTP-like simulation328    import sys329    import os330    env_dir = os.path.dirname(os.path.abspath(__file__))331    if env_dir not in sys.path:332        sys.path.insert(0, env_dir)333 334    from server.environment import MedicalCodingEnvironment335    from models import MedicalCodingAction336 337    env = MedicalCodingEnvironment()338    max_steps = MAX_STEPS_PER_TASK[task_id]339    rewards: List[float] = []340    steps_taken = 0341    score = 0.0342    success = False343 344    log_start(task=task_id, env=BENCHMARK, model=MODEL_NAME)345 346    try:347        obs_obj = env.reset(task_id=task_id)348        obs = obs_obj.model_dump()349        done = obs_obj.done350        history: List[str] = []351 352        for step in range(1, max_steps + 1):353            if done:354                break355 356            action_dict = get_llm_action(client, obs, step, history)357            action_str = json.dumps(action_dict)358 359            try:360                action = MedicalCodingAction(**action_dict)361                step_obs = env.step(action)362                obs = step_obs.model_dump()363                reward = step_obs.reward or 0.0364                done = step_obs.done365                error = step_obs.last_action_error366            except Exception as e:367                reward = -0.2368                done = False369                error = str(e)370                print(f"[DEBUG] Step parse error: {e}", flush=True)371 372            rewards.append(reward)373            steps_taken = step374 375            log_step(step=step, action=action_str, reward=reward, done=done, error=error)376 377            history.append(378                f"Step {step}: {action_str[:120]} → reward={reward:+.2f}"379                + (f" [ERROR: {error}]" if error else "")380            )381 382            if done:383                # Use grader_score from terminal observation as the score384                if step_obs.grader_score is not None:385                    score = float(step_obs.grader_score)386                break387 388    except Exception as exc:389        print(f"[DEBUG] HTTP task {task_id} error: {exc}", flush=True)390 391    finally:392        success = score >= SUCCESS_THRESHOLD393        log_end(success=success, steps=steps_taken, rewards=rewards)394 395    return score396 397 398# ---------------------------------------------------------------------------399# Main400# ---------------------------------------------------------------------------401 402async def main() -> None:403    """Run baseline inference across all three tasks."""404    client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)405 406    # If LOCAL_IMAGE_NAME is set, launch env from Docker image407    if LOCAL_IMAGE_NAME:408        from client import MedicalCodingEnv409        env = await MedicalCodingEnv.from_docker_image(LOCAL_IMAGE_NAME)410        base_url = env.base_url.rstrip("/")411    else:412        base_url = ENV_BASE_URL.rstrip("/")413 414    # Try WebSocket mode first, fall back to HTTP-local mode415    ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://") + "/ws"416 417    # Check if websockets library is available418    try:419        import websockets420        use_ws = True421    except ImportError:422        use_ws = False423        print("[DEBUG] websockets not installed, using HTTP/local mode", flush=True)424 425    task_scores: Dict[str, float] = {}426 427    for task_id in TASK_IDS:428        if use_ws:429            score = await run_task_websocket(ws_url, task_id, client)430        else:431            score = await run_task_http(base_url, task_id, client)432        task_scores[task_id] = score433 434    print("\n" + "=" * 60, flush=True)435    print("BASELINE SCORES SUMMARY", flush=True)436    print("=" * 60, flush=True)437    for task_id, score in task_scores.items():438        status = "PASS" if score >= SUCCESS_THRESHOLD else "FAIL"439        print(f"  [{status}] {task_id}: {score:.3f}", flush=True)440    avg = sum(task_scores.values()) / len(task_scores)441    print(f"  Average: {avg:.3f}", flush=True)442    print("=" * 60, flush=True)443 444 445if __name__ == "__main__":446    asyncio.run(main())447