File size: 11,469 Bytes
0cb452d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ed20c1
0cb452d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ed20c1
0cb452d
5ed20c1
 
0cb452d
5ed20c1
 
 
 
 
 
0cb452d
5ed20c1
 
 
 
0cb452d
5ed20c1
0cb452d
5ed20c1
 
0cb452d
5ed20c1
0cb452d
 
 
5ed20c1
 
 
0cb452d
5ed20c1
 
0cb452d
 
5ed20c1
 
 
 
 
 
 
 
 
 
 
 
0cb452d
5ed20c1
0cb452d
 
5ed20c1
 
 
0cb452d
 
 
 
5ed20c1
0cb452d
 
 
5ed20c1
0cb452d
5ed20c1
0cb452d
 
 
 
5ed20c1
0cb452d
 
 
5ed20c1
0cb452d
 
 
 
 
 
 
 
 
5ed20c1
0cb452d
 
 
 
 
 
 
5ed20c1
 
 
 
 
 
 
0cb452d
 
5ed20c1
 
0cb452d
5ed20c1
 
 
0cb452d
5ed20c1
 
0cb452d
 
 
5ed20c1
 
0cb452d
 
 
 
 
 
 
 
 
 
5ed20c1
0cb452d
 
 
 
5ed20c1
 
0cb452d
 
 
5ed20c1
0cb452d
5ed20c1
 
0cb452d
 
 
 
 
 
 
 
 
 
 
5ed20c1
0cb452d
 
 
 
 
 
 
 
 
 
 
5ed20c1
0cb452d
 
 
 
 
5ed20c1
 
0cb452d
 
5ed20c1
 
0cb452d
5ed20c1
 
0cb452d
 
 
5ed20c1
0cb452d
 
 
 
 
 
 
 
 
 
5ed20c1
 
 
0cb452d
5ed20c1
0cb452d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
"""
Module: inference.py
Purpose: Root-level baseline inference script for Medical Triage Assistant.
Part of: Medical Triage Assistant — OpenEnv Round 1
Author: Team Squirrel

Overview:
    MANDATORY: Must be named inference.py and placed in project root.
    This script runs a baseline LLM agent against all 3 tasks in the
    TriageFlow environment. It reads API credentials from environment
    variables, uses the OpenAI client for LLM inference, and emits
    structured stdout logs in the exact [START]/[STEP]/[END] format
    required by the hackathon evaluator.

Dependencies:
    - openai: OpenAI client for LLM inference
    - os: Environment variable access
    - json: Action parsing

Usage:
    API_BASE_URL=<url> MODEL_NAME=<model> HF_TOKEN=<token> python inference.py
"""

import os
import sys
import json
import traceback

from openai import OpenAI

# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from triage_flow.environment import TriageEnvironment
from triage_flow.graders import grade_task
from models import TriageAction, PriorityLevel


# ============================================================================
# Required Environment Variables
# ============================================================================

API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY") or os.getenv("OPENAI_API_KEY") or ""
API_BASE_URL = os.getenv("API_BASE_URL", "https://api.openai.com/v1")
MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini")

MAX_STEPS = 20  # Per task — well within 20 min limit
BENCHMARK = "triage-flow"
TASKS = ["basic-triage", "incomplete-records-triage", "mass-casualty-triage"]


# ============================================================================
# Stdout Logging — EXACT FORMAT, NO DEVIATION
# ============================================================================

def log_start(task: str, env: str, model: str):
    """Emit [START] line. Must be exactly one line."""
    print(f"[START] task={task} env={env} model={model}", flush=True)


def log_step(step: int, action: str, reward: float, done: bool, error=None):
    """Emit [STEP] line. reward to 2 decimal places, done/success lowercase."""
    done_str = str(done).lower()
    error_str = str(error) if error else "null"
    print(
        f"[STEP]  step={step} action={action} reward={reward:.2f} "
        f"done={done_str} error={error_str}",
        flush=True,
    )


def log_end(success: bool, steps: int, score: float, rewards: list):
    """Emit [END] line. Always emitted, even on exception."""
    success_str = str(success).lower()
    rewards_str = ",".join(f"{r:.2f}" for r in rewards)
    print(
        f"[END]   success={success_str} steps={steps} score={score:.2f} "
        f"rewards={rewards_str}",
        flush=True,
    )


# ============================================================================
# LLM Agent
# ============================================================================

SYSTEM_PROMPT = """You are an AI triage assistant at a hospital emergency department intake desk.
Patients arrive one at a time. For each patient you must:

1. CLASSIFY the patient: immediate, urgent, less_urgent, non_urgent, or escalate
2. OUTPUT the full reordered priority queue of ALL patients seen so far

Classification guidelines:
- immediate: Life-threatening (chest pain + abnormal vitals, hemorrhagic shock, etc.)
- urgent: Serious but stable (low SpO2, respiratory distress, neurological signs)
- less_urgent: Needs care but can wait (lacerations, possible fractures, stable chronic conditions)
- non_urgent: Minor issues (scrapes, mild cold, anxiety-related symptoms)
- escalate: Patient has MISSING critical data (e.g., no vitals). Send to human. Patient is REMOVED from queue.

Queue ordering rules:
- All IMMEDIATE patients first, then URGENT, then LESS_URGENT, then NON_URGENT
- Within the same priority, order by clinical severity
- Escalated patients are REMOVED from the queue entirely

IMPORTANT: If a patient has missing vitals or incomplete critical data, you MUST classify as "escalate".

Respond with ONLY a JSON object:
{"classification": "<priority_or_escalate>", "reordered_queue": ["<patient_id_1>", "<patient_id_2>", ...]}

For escalation, the escalated patient should NOT appear in the queue."""


def build_user_prompt(observation_dict: dict) -> str:
    """Build the user prompt from the current observation."""
    patient = observation_dict.get("incoming_patient", {})
    current_queue = observation_dict.get("current_queue", [])
    step_num = observation_dict.get("step_number", 0)
    total = observation_dict.get("total_expected_patients", 0)
    feedback = observation_dict.get("previous_feedback", "")
    task = observation_dict.get("task_name", "")

    # Check for missing data
    missing_info = []
    if patient:
        if patient.get("vitals") is None:
            missing_info.append("VITALS ARE MISSING")
        if patient.get("history") is None:
            missing_info.append("HISTORY IS MISSING")
        if patient.get("allergies") is None:
            missing_info.append("ALLERGIES ARE MISSING")

    missing_str = "\n⚠️ WARNING: " + ", ".join(missing_info) if missing_info else ""

    prompt = f"""Task: {task}
Step: {step_num + 1} of {total}
Previous feedback: {feedback}

Current Queue: {json.dumps(current_queue)}

New Patient Arriving:
- ID: {patient.get('patient_id', 'N/A')}
- Age: {patient.get('age', 'N/A')}
- Chief Complaint: {patient.get('chief_complaint', 'N/A')}
- Symptoms: {', '.join(patient.get('symptoms', []))}
- Vitals: {json.dumps(patient.get('vitals')) if patient.get('vitals') else 'MISSING - CONSIDER ESCALATION'}
- History: {patient.get('history', 'MISSING')}
- Medications: {patient.get('medications', 'MISSING')}
- Allergies: {patient.get('allergies', 'MISSING')}
- Data Complete: {patient.get('info_complete', 'N/A')}{missing_str}

Classify this patient and output the full reordered queue. Respond with ONLY a JSON object."""

    return prompt


def parse_llm_action(response_text: str, current_queue: list, patient_id: str) -> TriageAction:
    """
    Parse the LLM's response into a TriageAction.

    Falls back to a safe default if parsing fails.
    """
    try:
        text = response_text.strip()
        # Handle markdown code blocks
        if "```json" in text:
            text = text.split("```json")[1].split("```")[0].strip()
        elif "```" in text:
            text = text.split("```")[1].split("```")[0].strip()

        # Find JSON object
        start = text.find("{")
        end = text.rfind("}") + 1
        if start >= 0 and end > start:
            text = text[start:end]

        data = json.loads(text)

        classification = data.get("classification", "less_urgent").lower().strip()
        queue = data.get("reordered_queue", current_queue)

        # Validate classification
        valid_classes = {"immediate", "urgent", "less_urgent", "non_urgent", "escalate"}
        if classification not in valid_classes:
            classification = "less_urgent"

        return TriageAction(
            classification=classification,
            reordered_queue=queue,
        )
    except Exception:
        # Fallback: classify as less_urgent, append to queue
        fallback_queue = list(current_queue) + [patient_id]
        return TriageAction(
            classification="less_urgent",
            reordered_queue=fallback_queue,
        )


def get_llm_action(client: OpenAI, observation_dict: dict, current_queue: list, patient_id: str) -> TriageAction:
    """Get an action from the LLM for the current observation."""
    user_prompt = build_user_prompt(observation_dict)

    try:
        completion = client.chat.completions.create(
            model=MODEL_NAME,
            messages=[
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": user_prompt},
            ],
            temperature=0.1,
            max_tokens=300,
            stream=False,
        )
        text = (completion.choices[0].message.content or "").strip()
        if not text:
            text = '{"classification": "less_urgent", "reordered_queue": []}'
        return parse_llm_action(text, current_queue, patient_id)
    except Exception as exc:
        print(f"[DEBUG] Model request failed: {exc}", flush=True)
        # Fallback action
        fallback_queue = list(current_queue) + [patient_id]
        return TriageAction(
            classification="less_urgent",
            reordered_queue=fallback_queue,
        )


# ============================================================================
# Task Runner
# ============================================================================

def run_task(task_name: str, client: OpenAI):
    """
    Run a single task against the environment.

    Always emits [START] and [END] lines, even on exception.
    """
    env = TriageEnvironment()
    rewards = []
    steps_taken = 0
    score = 0.0
    success = False

    log_start(task=task_name, env=BENCHMARK, model=MODEL_NAME)

    try:
        obs = env.reset(task_name=task_name)
        current_queue = list(obs.current_queue)

        for step in range(1, MAX_STEPS + 1):
            if obs.done:
                break

            patient = obs.incoming_patient or {}
            patient_id = patient.get("patient_id", "")

            obs_dict = {
                "incoming_patient": patient,
                "current_queue": current_queue,
                "step_number": obs.step_number,
                "total_expected_patients": obs.total_expected_patients,
                "previous_feedback": obs.previous_feedback or "",
                "task_name": obs.task_name,
            }

            action = get_llm_action(client, obs_dict, current_queue, patient_id)
            obs = env.step(action)

            reward = obs.reward if obs.reward is not None else 0.0
            done = obs.done
            error = None

            reward_val = round(reward, 2)
            rewards.append(reward_val)
            steps_taken = step

            # Update current queue from observation
            current_queue = list(obs.current_queue)

            # Build action string for logging
            action_str = f"{action.classification}({patient_id})[queue={len(action.reordered_queue)}]"

            log_step(
                step=step,
                action=action_str,
                reward=reward_val,
                done=done,
                error=error,
            )

            if done:
                break

        # Compute final score using the grader
        state_dict = env.state.model_dump()
        score = grade_task(task_name, state_dict)
        score = round(score, 2)
        success = score >= 0.5

    except Exception as exc:
        print(f"[DEBUG] Task {task_name} error: {exc}", flush=True)
        traceback.print_exc(file=sys.stderr)
        score = 0.0
        success = False

    finally:
        log_end(
            success=success,
            steps=steps_taken,
            score=score,
            rewards=rewards,
        )


# ============================================================================
# Main Entry Point
# ============================================================================

if __name__ == "__main__":
    client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)

    for task in TASKS:
        run_task(task, client)