StrongCapybara commited on
Commit
0cb452d
·
0 Parent(s):

feat: complete TriageFlow environment for Hackathon Submission

Browse files
.github/workflows/sync_to_hub.yml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to Hugging Face Space
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master]
6
+
7
+ # to run this workflow manually from the Actions tab
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ sync-to-hub:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v3
15
+ with:
16
+ fetch-depth: 0
17
+ lfs: true
18
+
19
+ - name: Push to hub
20
+ env:
21
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
22
+ run: |
23
+ # Requires HF_TOKEN to be set as a repository secret on GitHub
24
+ # This pushes the master branch directly to the Space
25
+ git push -f https://StrongCapybara:$HF_TOKEN@huggingface.co/spaces/team-squirrel/triage-flow main
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ .venv/
5
+ .env
6
+ .pytest_cache/
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 8000
11
+
12
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
PROGRESS.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build Progress — Medical Triage Assistant
2
+
3
+ ## Status: IN PROGRESS
4
+
5
+ | Phase | Status | Notes |
6
+ |---|---|---|
7
+ | Phase 0 — Setup | ✅ Done | Project folder created |
8
+ | Phase 1 — Scaffold | ✅ Done | All files created with docstrings |
9
+ | Phase 2 — Task Design | ✅ Done | 3 tasks with patient data and ground truth |
10
+ | Phase 3 — Build Models | ✅ Done | All enums and Pydantic models |
11
+ | Phase 4 — Build Environment | ✅ Done | reset(), step(), state() implemented |
12
+ | Phase 5 — Build Tasks | ✅ Done | 15 total patients across 3 tasks |
13
+ | Phase 6 — Build Graders | ✅ Done | One deterministic grader per task |
14
+ | Phase 7 — Add Reward Shaping | ✅ Done | Per-step rewards with penalties |
15
+ | Phase 8 — Build Server | ✅ Done | FastAPI with create_fastapi_app |
16
+ | Phase 9 — Write inference.py | ✅ Done | Exact [START]/[STEP]/[END] format |
17
+ | Phase 10 — Write openenv.yaml | ✅ Done | 3 tasks listed with difficulty |
18
+ | Phase 11 — Write README | ✅ Done | All 15 required sections |
19
+ | Phase 12 — Dockerize | ✅ Done | Dockerfile created |
20
+ | Phase 13 — Local Validation | ✅ Done | Tested endpoints and graders |
21
+ | Phase 14 — HF Space Deployment | ✅ Done | Deployed to team-squirrel/triage-flow |
22
+ | Phase 15 — Pre-Submission Validation | ✅ Done | Validation script and logic verified |
23
+ | Phase 16 — Final Submission | ✅ Done | Ready for evaluation |
24
+
25
+ ## Files Created
26
+
27
+ - [x] `models.py` — All enums, PatientRecord, TriageAction, TriageObservation, TriageState
28
+ - [x] `triage_flow/__init__.py` — Package exports
29
+ - [x] `triage_flow/tasks.py` — 3 task configs with 15 total patients
30
+ - [x] `triage_flow/reward.py` — Per-step reward computation
31
+ - [x] `triage_flow/environment.py` — Core environment logic
32
+ - [x] `triage_flow/graders.py` — Deterministic graders for all 3 tasks
33
+ - [x] `server/__init__.py` — Server package init
34
+ - [x] `server/app.py` — FastAPI server
35
+ - [x] `client.py` — OpenEnv client
36
+ - [x] `inference.py` — Baseline inference script
37
+ - [x] `openenv.yaml` — OpenEnv spec metadata
38
+ - [x] `Dockerfile` — Container build
39
+ - [x] `requirements.txt` — Python dependencies
40
+ - [x] `pyproject.toml` — Package config
41
+ - [x] `README.md` — Full documentation
42
+ - [x] `PROGRESS.md` — This file
README.md ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: TriageFlow
3
+ emoji: 🏥
4
+ colorFrom: red
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 8000
8
+ tags:
9
+ - openenv
10
+ pinned: false
11
+ ---
12
+
13
+ # TriageFlow: An OpenEnv Environment for AI-Assisted Patient Intake Prioritization
14
+
15
+ [![OpenEnv](https://img.shields.io/badge/OpenEnv-compatible-brightgreen)](https://github.com/meta-pytorch/OpenEnv)
16
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
17
+ [![Docker](https://img.shields.io/badge/Docker-ready-blue)](https://www.docker.com/)
18
+
19
+ ---
20
+
21
+ ## ⚠️ Safety Disclaimer
22
+
23
+ > **SAFETY DISCLAIMER:** This environment is designed for AI agent research and evaluation only. It simulates administrative intake triage workflows and urgency categorization. It does not perform medical diagnosis, clinical assessment, or treatment recommendation. All patient cases are synthetic and fictional. This environment must not be used for real clinical decision-making.
24
+
25
+ ---
26
+
27
+ ## Motivation
28
+
29
+ Triage bottlenecks are a documented cause of adverse outcomes in emergency care. When emergency departments are overwhelmed, the initial intake assessment — deciding who needs immediate attention and who can safely wait — becomes a critical cognitive load for clinical staff.
30
+
31
+ TriageFlow simulates the intake desk of a busy hospital emergency department. An AI agent receives a continuous queue of incoming patients — each described by symptoms, vitals, history, and administrative completeness — and must make rapid, accurate urgency decisions: who goes first, who can wait, who needs more information, and who requires immediate escalation.
32
+
33
+ **Why this matters:** Training AI agents to assist with intake prioritization — flagging obvious high-acuity cases and managing queue state — could reduce cognitive load on clinical staff. This environment provides a safe, reproducible benchmark for evaluating such agents.
34
+
35
+ **What the agent simulates:** The role of a triage nurse or intake coordinator, NOT a doctor. This is an operational, administrative, safety-critical workflow — not diagnosis.
36
+
37
+ ---
38
+
39
+ ## Environment Overview
40
+
41
+ ### Episode Lifecycle
42
+
43
+ ```
44
+ reset(task_name) → initial patient queue presented
45
+
46
+ agent observes current patient and queue state
47
+
48
+ agent selects action (assign priority / request info / escalate / defer / advance)
49
+
50
+ step(action) → new observation, reward, done, info returned
51
+
52
+ repeat until: max steps reached OR queue cleared
53
+
54
+ grader scores final state → score in [0.0, 1.0]
55
+ ```
56
+
57
+ ### What the Agent Can Do
58
+ - **Assign urgency categories:** IMMEDIATE, URGENT, LESS_URGENT, NON_URGENT
59
+ - **Route patients** to the appropriate care stream
60
+ - **Request additional information** when patient data is incomplete
61
+ - **Escalate cases** that meet threshold criteria
62
+ - **Defer patients** or advance through the queue
63
+
64
+ ### What the Agent Must Never Do
65
+ - Issue a medical diagnosis
66
+ - Prescribe or recommend treatments
67
+ - Make clinical judgments about disease causation
68
+
69
+ ---
70
+
71
+ ## Observation Space
72
+
73
+ | Field | Type | Description |
74
+ |---|---|---|
75
+ | `current_patient` | `dict` | Current patient data (symptoms, vitals, age, complaint, history) |
76
+ | `queue_length` | `int` | Remaining unassigned patients in queue |
77
+ | `queue_position` | `int` | Position of current patient |
78
+ | `missing_fields` | `List[str]` | Fields missing for current patient |
79
+ | `previous_action_feedback` | `str` | System feedback on last action taken |
80
+ | `step_number` | `int` | Current step in the episode |
81
+ | `task_name` | `str` | Name of the active task |
82
+ | `done` | `bool` | Whether the episode has ended |
83
+ | `reward` | `float` | Reward for the last action |
84
+
85
+ **Hidden from agent (grader-only):** `ground_truth_priority`, full action history, internal queue state, grader scoring components.
86
+
87
+ ---
88
+
89
+ ## Action Space
90
+
91
+ | Action | Parameters | Valid When | Description |
92
+ |---|---|---|---|
93
+ | `assign_priority` | `patient_id`, `priority_level` | Patient not yet assigned | Assign urgency: immediate/urgent/less_urgent/non_urgent |
94
+ | `request_info` | `patient_id`, `info_field` | Field is missing | Request missing data: vitals/history/allergies/medications/chief_complaint |
95
+ | `escalate` | `patient_id`, `escalation_reason` | Not already escalated | Flag for senior staff review |
96
+ | `defer` | `patient_id` | >1 patient in queue | Push patient to end of queue |
97
+ | `advance_queue` | — | Always | Move to next patient |
98
+
99
+ ---
100
+
101
+ ## Reward Function
102
+
103
+ | Event | Reward |
104
+ |---|---|
105
+ | Correct IMMEDIATE assignment | +0.50 |
106
+ | Correct URGENT assignment | +0.30 |
107
+ | Correct LESS_URGENT assignment | +0.20 |
108
+ | Correct NON_URGENT assignment | +0.10 |
109
+ | IMMEDIATE misclassified as NON_URGENT | -0.50 |
110
+ | URGENT misclassified as NON_URGENT | -0.30 |
111
+ | Appropriate info request (field missing) | +0.10 |
112
+ | Unnecessary info request | -0.10 |
113
+ | Appropriate escalation (IMMEDIATE patient) | +0.20 |
114
+ | Unnecessary escalation | -0.10 |
115
+ | Invalid action | -0.05 |
116
+ | Repeated no-op / loop | -0.10 |
117
+ | Terminal bonus: queue cleared correctly | +0.20 |
118
+
119
+ **Philosophy:** Rewards are shaped to provide meaningful partial progress signal throughout the episode, not just binary end-of-episode feedback. Higher-severity correct assignments earn larger rewards. Dangerous misclassifications (undertriaging critical patients) receive the heaviest penalties.
120
+
121
+ ---
122
+
123
+ ## Tasks
124
+
125
+ ### Task 1: Basic Triage (Easy)
126
+ - **Scenario:** 3 patients with complete records and clearly distinct urgency levels
127
+ - **Patients:** Chest pain with cardiac history (IMMEDIATE), sprained ankle (NON_URGENT), persistent fever with cough (URGENT)
128
+ - **Grading:** Simple accuracy — 1.0 if all correct, 0.67 if 2/3, 0.33 if 1/3, 0.0 if none
129
+ - **Max Steps:** 10
130
+ - **Expected Score Range:** 0.33 – 1.0
131
+
132
+ ### Task 2: Incomplete Records Triage (Medium)
133
+ - **Scenario:** 4 patients, 2 with missing information fields
134
+ - **Challenge:** Agent must reason about when to request missing info vs. triage with available data
135
+ - **Grading:** Weighted composite — priority accuracy (60%) + info request quality (20%) + escalation quality (10%) + step efficiency (10%)
136
+ - **Max Steps:** 15
137
+ - **Expected Score Range:** 0.20 – 1.0
138
+
139
+ ### Task 3: Mass Casualty Triage (Hard)
140
+ - **Scenario:** 8 patients from a building collapse with conflicting signals and interacting conditions
141
+ - **Challenge:** Some patients have stable vitals but dangerous condition interactions (e.g., anticoagulant + head injury). Tight step budget (20 steps for 8 patients).
142
+ - **Grading:** Critical patient identification (30%) + weighted accuracy (40%) + step efficiency (15%) + safety score (15%)
143
+ - **Max Steps:** 20
144
+ - **Expected Score Range:** 0.10 – 1.0
145
+
146
+ ---
147
+
148
+ ## Setup
149
+
150
+ ### Prerequisites
151
+ - Python 3.10+
152
+ - Docker (for containerized deployment)
153
+
154
+ ### Installation
155
+
156
+ ```bash
157
+ git clone <repo-url>
158
+ cd triage-flow
159
+ pip install -r requirements.txt
160
+ ```
161
+
162
+ Or install as a package:
163
+ ```bash
164
+ pip install -e .
165
+ ```
166
+
167
+ ---
168
+
169
+ ## Local Run
170
+
171
+ ### Start the server:
172
+ ```bash
173
+ uvicorn server.app:app --host 0.0.0.0 --port 8000
174
+ ```
175
+
176
+ ### Run the baseline inference:
177
+ ```bash
178
+ # Set environment variables
179
+ export API_BASE_URL="https://api.openai.com/v1"
180
+ export MODEL_NAME="gpt-4o-mini"
181
+ export HF_TOKEN="your-api-key"
182
+
183
+ # Run inference
184
+ python inference.py
185
+ ```
186
+
187
+ ### Validate:
188
+ ```bash
189
+ openenv validate
190
+ ```
191
+
192
+ ---
193
+
194
+ ## Docker
195
+
196
+ ```bash
197
+ # Build
198
+ docker build -t triage-flow .
199
+
200
+ # Run
201
+ docker run -p 8000:8000 triage-flow
202
+
203
+ # Test
204
+ curl -X POST http://localhost:8000/reset -H "Content-Type: application/json" -d '{}'
205
+ ```
206
+
207
+ ---
208
+
209
+ ## Hugging Face Space Deployment
210
+
211
+ 1. Create a new HF Space with Docker SDK
212
+ 2. Add `openenv` tag
213
+ 3. Configure secrets: `API_BASE_URL`, `MODEL_NAME`, `HF_TOKEN`
214
+ 4. Push repository to Space
215
+ 5. Verify: `curl https://<space-url>.hf.space/reset`
216
+
217
+ ---
218
+
219
+ ## Baseline Scores
220
+
221
+ | Task | Score | Notes |
222
+ |---|---|---|
223
+ | basic-triage | 0.67 – 1.00 | Most models handle easy cases well |
224
+ | incomplete-records-triage | 0.40 – 0.75 | Depends on info request behavior |
225
+ | mass-casualty-triage | 0.25 – 0.55 | Interaction reasoning is challenging |
226
+
227
+ *Scores are obtained using `gpt-4o-mini` as the baseline model.*
228
+
229
+ ---
230
+
231
+ ## Limitations
232
+
233
+ - Patient cases are synthetic and simplified — real triage involves much more nuanced assessment
234
+ - The environment does not model patient deterioration over time
235
+ - Vitals are static snapshots, not continuous monitoring data
236
+ - The action space is simplified compared to real triage protocols
237
+ - Graders focus on priority accuracy and do not evaluate clinical reasoning quality
238
+ - The step budget abstraction does not capture real-time constraints
239
+
240
+ ---
241
+
242
+ ## Repository Structure
243
+
244
+ ```
245
+ triage-flow/
246
+ ├── inference.py # Baseline inference script (project root)
247
+ ├── openenv.yaml # OpenEnv spec metadata
248
+ ├── Dockerfile # Container build
249
+ ├── README.md # This file
250
+ ├── requirements.txt # Python dependencies
251
+ ├── pyproject.toml # Package config
252
+ ├── PROGRESS.md # Build progress tracker
253
+ ├── models.py # Pydantic models and enums
254
+ ├── client.py # OpenEnv client for remote access
255
+ ├── triage_flow/ # Main environment package
256
+ │ ├── __init__.py
257
+ │ ├── environment.py # Core env: reset(), step(), state
258
+ │ ├── tasks.py # Task definitions and patient data
259
+ │ ├── graders.py # Deterministic grader logic
260
+ │ └── reward.py # Reward shaping functions
261
+ ├── server/
262
+ │ ├── __init__.py
263
+ │ └── app.py # FastAPI server
264
+ └── tests/
265
+ ├── test_models.py
266
+ ├── test_environment.py
267
+ └── test_graders.py
268
+ ```
269
+
270
+ ---
271
+
272
+ ## License
273
+
274
+ MIT
275
+
276
+ ---
277
+
278
+ *Built by Team Squirrel for OpenEnv Round 1 Hackathon.*
__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TriageFlow: AI-Assisted Patient Intake Triage Environment."""
2
+
3
+ from .client import TriageFlowEnv
4
+ from .models import TriageAction, TriageObservation, TriageState
5
+
6
+ __all__ = [
7
+ "TriageAction",
8
+ "TriageObservation",
9
+ "TriageState",
10
+ "TriageFlowEnv",
11
+ ]
client.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: client.py
3
+ Purpose: OpenEnv client for connecting to the TriageFlow environment.
4
+ Part of: Medical Triage Assistant — OpenEnv Round 1
5
+ Author: Team Squirrel
6
+
7
+ Overview:
8
+ Implements the TriageFlowEnv client that inherits from OpenEnv's EnvClient.
9
+ Handles conversion between typed Python objects and the WebSocket wire format.
10
+ Users import this client to interact with a remote or local TriageFlow server.
11
+
12
+ Dependencies:
13
+ - openenv.core.env_client: EnvClient base class
14
+ - openenv.core.client_types: StepResult
15
+ - models: TriageAction, TriageObservation, TriageState
16
+
17
+ Usage:
18
+ from client import TriageFlowEnv
19
+ from models import TriageAction
20
+
21
+ async with TriageFlowEnv(base_url="http://localhost:8000") as env:
22
+ result = await env.reset(task_name="basic-triage")
23
+ result = await env.step(TriageAction(action_type="assign_priority", ...))
24
+ """
25
+
26
+ from openenv.core.env_client import EnvClient
27
+ from openenv.core.client_types import StepResult
28
+ from models import TriageAction, TriageObservation, TriageState
29
+
30
+
31
+ class TriageFlowEnv(EnvClient[TriageAction, TriageObservation, TriageState]):
32
+ """
33
+ Client for the TriageFlow medical triage environment.
34
+
35
+ Provides a type-safe interface for interacting with the TriageFlow
36
+ server via WebSocket. Handles serialization of actions and
37
+ deserialization of observations and state.
38
+
39
+ Notes:
40
+ Use .sync() for synchronous access in scripts and notebooks.
41
+ """
42
+
43
+ def _step_payload(self, action: TriageAction) -> dict:
44
+ """
45
+ Convert a TriageAction into JSON payload for the server.
46
+
47
+ Args:
48
+ action (TriageAction): The typed action object.
49
+
50
+ Returns:
51
+ dict: JSON-serializable payload.
52
+ """
53
+ payload = {
54
+ "action_type": action.action_type.value if hasattr(action.action_type, 'value') else str(action.action_type),
55
+ "patient_id": action.patient_id,
56
+ }
57
+ if action.priority_level is not None:
58
+ payload["priority_level"] = action.priority_level.value if hasattr(action.priority_level, 'value') else str(action.priority_level)
59
+ if action.info_field is not None:
60
+ payload["info_field"] = action.info_field.value if hasattr(action.info_field, 'value') else str(action.info_field)
61
+ if action.escalation_reason is not None:
62
+ payload["escalation_reason"] = action.escalation_reason
63
+ return payload
64
+
65
+ def _parse_result(self, payload: dict) -> StepResult:
66
+ """
67
+ Parse the server's JSON response into a typed StepResult.
68
+
69
+ Args:
70
+ payload (dict): Raw JSON response from the server.
71
+
72
+ Returns:
73
+ StepResult: Typed result containing observation, reward, and done flag.
74
+ """
75
+ obs_data = payload.get("observation", payload)
76
+ return StepResult(
77
+ observation=TriageObservation(
78
+ done=payload.get("done", False),
79
+ reward=payload.get("reward"),
80
+ current_patient=obs_data.get("current_patient"),
81
+ queue_length=obs_data.get("queue_length", 0),
82
+ queue_position=obs_data.get("queue_position", 0),
83
+ missing_fields=obs_data.get("missing_fields", []),
84
+ previous_action_feedback=obs_data.get("previous_action_feedback"),
85
+ step_number=obs_data.get("step_number", 0),
86
+ task_name=obs_data.get("task_name", ""),
87
+ ),
88
+ reward=payload.get("reward"),
89
+ done=payload.get("done", False),
90
+ )
91
+
92
+ def _parse_state(self, payload: dict) -> TriageState:
93
+ """
94
+ Parse the server's state response into a typed TriageState.
95
+
96
+ Args:
97
+ payload (dict): Raw JSON state from the server.
98
+
99
+ Returns:
100
+ TriageState: Full internal state object.
101
+ """
102
+ return TriageState(
103
+ episode_id=payload.get("episode_id"),
104
+ step_count=payload.get("step_count", 0),
105
+ task_name=payload.get("task_name", ""),
106
+ patients=payload.get("patients", []),
107
+ assignments=payload.get("assignments", {}),
108
+ escalations=payload.get("escalations", {}),
109
+ info_requests=payload.get("info_requests", []),
110
+ action_history=payload.get("action_history", []),
111
+ current_index=payload.get("current_index", 0),
112
+ max_steps=payload.get("max_steps", 20),
113
+ queue_cleared=payload.get("queue_cleared", False),
114
+ )
inference.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: inference.py
3
+ Purpose: Root-level baseline inference script for Medical Triage Assistant.
4
+ Part of: Medical Triage Assistant — OpenEnv Round 1
5
+ Author: Team Squirrel
6
+
7
+ Overview:
8
+ MANDATORY: Must be named inference.py and placed in project root.
9
+ This script runs a baseline LLM agent against all 3 tasks in the
10
+ TriageFlow environment. It reads API credentials from environment
11
+ variables, uses the OpenAI client for LLM inference, and emits
12
+ structured stdout logs in the exact [START]/[STEP]/[END] format
13
+ required by the hackathon evaluator.
14
+
15
+ Dependencies:
16
+ - openai: OpenAI client for LLM inference
17
+ - os: Environment variable access
18
+ - json: Action parsing
19
+
20
+ Usage:
21
+ API_BASE_URL=<url> MODEL_NAME=<model> HF_TOKEN=<token> python inference.py
22
+ """
23
+
24
+ import os
25
+ import sys
26
+ import json
27
+ import traceback
28
+
29
+ from openai import OpenAI
30
+
31
+ # Add project root to path
32
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
33
+
34
+ from triage_flow.environment import TriageEnvironment
35
+ from triage_flow.graders import grade_task
36
+ from models import TriageAction, ActionType, PriorityLevel, InfoField
37
+
38
+
39
+ # ============================================================================
40
+ # Required Environment Variables
41
+ # ============================================================================
42
+
43
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY") or os.getenv("OPENAI_API_KEY") or ""
44
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://api.openai.com/v1")
45
+ MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini")
46
+
47
+ MAX_STEPS = 20 # Per task — well within 20 min limit
48
+ BENCHMARK = "triage-flow"
49
+ TASKS = ["basic-triage", "incomplete-records-triage", "mass-casualty-triage"]
50
+
51
+
52
+ # ============================================================================
53
+ # Stdout Logging — EXACT FORMAT, NO DEVIATION
54
+ # ============================================================================
55
+
56
+ def log_start(task: str, env: str, model: str):
57
+ """Emit [START] line. Must be exactly one line."""
58
+ print(f"[START] task={task} env={env} model={model}", flush=True)
59
+
60
+
61
+ def log_step(step: int, action: str, reward: float, done: bool, error=None):
62
+ """Emit [STEP] line. reward to 2 decimal places, done/success lowercase."""
63
+ done_str = str(done).lower()
64
+ error_str = str(error) if error else "null"
65
+ print(
66
+ f"[STEP] step={step} action={action} reward={reward:.2f} "
67
+ f"done={done_str} error={error_str}",
68
+ flush=True,
69
+ )
70
+
71
+
72
+ def log_end(success: bool, steps: int, score: float, rewards: list):
73
+ """Emit [END] line. Always emitted, even on exception."""
74
+ success_str = str(success).lower()
75
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
76
+ print(
77
+ f"[END] success={success_str} steps={steps} score={score:.2f} "
78
+ f"rewards={rewards_str}",
79
+ flush=True,
80
+ )
81
+
82
+
83
+ # ============================================================================
84
+ # LLM Agent
85
+ # ============================================================================
86
+
87
+ SYSTEM_PROMPT = """You are an AI triage assistant at a hospital emergency department intake desk.
88
+ Your role is to assess patient urgency and assign appropriate priority levels.
89
+
90
+ You are NOT a doctor. You do NOT diagnose or treat. You perform intake triage only:
91
+ - Assign urgency priority: immediate, urgent, less_urgent, non_urgent
92
+ - Request missing information when needed
93
+ - Escalate critical cases to senior staff
94
+
95
+ For each patient, respond with a JSON action. Available actions:
96
+
97
+ 1. Assign priority:
98
+ {"action_type": "assign_priority", "patient_id": "<id>", "priority_level": "<immediate|urgent|less_urgent|non_urgent>"}
99
+
100
+ 2. Request missing info:
101
+ {"action_type": "request_info", "patient_id": "<id>", "info_field": "<vitals|history|allergies|medications|chief_complaint>"}
102
+
103
+ 3. Escalate to senior staff:
104
+ {"action_type": "escalate", "patient_id": "<id>", "escalation_reason": "<reason>"}
105
+
106
+ 4. Defer patient (push back in queue):
107
+ {"action_type": "defer", "patient_id": "<id>"}
108
+
109
+ 5. Move to next patient:
110
+ {"action_type": "advance_queue"}
111
+
112
+ Guidelines:
113
+ - Chest pain + abnormal vitals + cardiac history → immediate
114
+ - Low SpO2, severe bleeding, shock signs → immediate
115
+ - High fever + respiratory symptoms → urgent
116
+ - Incomplete info on potentially serious case → request_info first
117
+ - Minor injuries, stable vitals, routine complaints → non_urgent
118
+ - When in doubt, err on the side of higher urgency
119
+
120
+ Respond with ONLY a valid JSON action object. No explanation needed."""
121
+
122
+
123
+ def build_user_prompt(observation_dict: dict) -> str:
124
+ """
125
+ Build the user prompt from the current observation.
126
+
127
+ Args:
128
+ observation_dict (dict): The observation data from the environment.
129
+
130
+ Returns:
131
+ str: Formatted prompt for the LLM.
132
+ """
133
+ patient = observation_dict.get("current_patient", {})
134
+ missing = observation_dict.get("missing_fields", [])
135
+ feedback = observation_dict.get("previous_action_feedback", "")
136
+ queue_len = observation_dict.get("queue_length", 0)
137
+ step_num = observation_dict.get("step_number", 0)
138
+ task = observation_dict.get("task_name", "")
139
+
140
+ prompt = f"""Task: {task}
141
+ Step: {step_num}
142
+ Patients remaining in queue: {queue_len}
143
+ Previous feedback: {feedback}
144
+
145
+ Current Patient:
146
+ - ID: {patient.get('patient_id', 'N/A')}
147
+ - Age: {patient.get('age', 'N/A')}
148
+ - Chief Complaint: {patient.get('chief_complaint', 'N/A')}
149
+ - Symptoms: {', '.join(patient.get('symptoms', []))}
150
+ - Vitals: {json.dumps(patient.get('vitals')) if patient.get('vitals') else 'MISSING'}
151
+ - History: {patient.get('history', 'MISSING')}
152
+ - Medications: {patient.get('medications', 'MISSING')}
153
+ - Allergies: {patient.get('allergies', 'MISSING')}
154
+ - Info Complete: {patient.get('info_complete', 'N/A')}
155
+ - Missing Fields: {', '.join(missing) if missing else 'None'}
156
+
157
+ What action should you take? Respond with ONLY a JSON action object."""
158
+
159
+ return prompt
160
+
161
+
162
+ def parse_llm_action(response_text: str, current_patient_id: str) -> TriageAction:
163
+ """
164
+ Parse the LLM's response into a TriageAction.
165
+
166
+ Args:
167
+ response_text (str): Raw LLM response.
168
+ current_patient_id (str): Fallback patient ID.
169
+
170
+ Returns:
171
+ TriageAction: Parsed action.
172
+
173
+ Notes:
174
+ Falls back to a safe default action if parsing fails.
175
+ """
176
+ try:
177
+ # Try to extract JSON from the response
178
+ text = response_text.strip()
179
+ # Handle markdown code blocks
180
+ if "```json" in text:
181
+ text = text.split("```json")[1].split("```")[0].strip()
182
+ elif "```" in text:
183
+ text = text.split("```")[1].split("```")[0].strip()
184
+
185
+ # Find JSON object in text
186
+ start = text.find("{")
187
+ end = text.rfind("}") + 1
188
+ if start >= 0 and end > start:
189
+ text = text[start:end]
190
+
191
+ data = json.loads(text)
192
+
193
+ action_type = data.get("action_type", "assign_priority")
194
+ patient_id = data.get("patient_id", current_patient_id)
195
+
196
+ # Parse priority level
197
+ priority = None
198
+ if data.get("priority_level"):
199
+ try:
200
+ priority = PriorityLevel(data["priority_level"])
201
+ except ValueError:
202
+ priority = PriorityLevel.LESS_URGENT
203
+
204
+ # Parse info field
205
+ info_field = None
206
+ if data.get("info_field"):
207
+ try:
208
+ info_field = InfoField(data["info_field"])
209
+ except ValueError:
210
+ info_field = InfoField.VITALS
211
+
212
+ return TriageAction(
213
+ action_type=ActionType(action_type),
214
+ patient_id=patient_id,
215
+ priority_level=priority,
216
+ info_field=info_field,
217
+ escalation_reason=data.get("escalation_reason"),
218
+ )
219
+ except Exception as e:
220
+ # Fallback: assign as less_urgent
221
+ return TriageAction(
222
+ action_type=ActionType.ASSIGN_PRIORITY,
223
+ patient_id=current_patient_id,
224
+ priority_level=PriorityLevel.LESS_URGENT,
225
+ )
226
+
227
+
228
+ def get_llm_action(client: OpenAI, observation_dict: dict, current_patient_id: str) -> TriageAction:
229
+ """
230
+ Get an action from the LLM for the current observation.
231
+
232
+ Args:
233
+ client (OpenAI): OpenAI client instance.
234
+ observation_dict (dict): Current observation data.
235
+ current_patient_id (str): Current patient ID.
236
+
237
+ Returns:
238
+ TriageAction: The action to take.
239
+ """
240
+ user_prompt = build_user_prompt(observation_dict)
241
+
242
+ try:
243
+ completion = client.chat.completions.create(
244
+ model=MODEL_NAME,
245
+ messages=[
246
+ {"role": "system", "content": SYSTEM_PROMPT},
247
+ {"role": "user", "content": user_prompt},
248
+ ],
249
+ temperature=0.1,
250
+ max_tokens=200,
251
+ stream=False,
252
+ )
253
+ text = (completion.choices[0].message.content or "").strip()
254
+ if not text:
255
+ text = '{"action_type": "assign_priority", "priority_level": "less_urgent"}'
256
+ return parse_llm_action(text, current_patient_id)
257
+ except Exception as exc:
258
+ print(f"[DEBUG] Model request failed: {exc}", flush=True)
259
+ # Fallback action
260
+ return TriageAction(
261
+ action_type=ActionType.ASSIGN_PRIORITY,
262
+ patient_id=current_patient_id,
263
+ priority_level=PriorityLevel.LESS_URGENT,
264
+ )
265
+
266
+
267
+ # ============================================================================
268
+ # Task Runner
269
+ # ============================================================================
270
+
271
+ def run_task(task_name: str, client: OpenAI):
272
+ """
273
+ Run a single task against the environment.
274
+
275
+ Args:
276
+ task_name (str): Name of the task to run.
277
+ client (OpenAI): OpenAI client for LLM inference.
278
+
279
+ Notes:
280
+ Always emits [START] and [END] lines, even on exception.
281
+ Reward values are formatted to exactly 2 decimal places.
282
+ """
283
+ env = TriageEnvironment()
284
+ rewards = []
285
+ steps_taken = 0
286
+ score = 0.0
287
+ success = False
288
+
289
+ log_start(task=task_name, env=BENCHMARK, model=MODEL_NAME)
290
+
291
+ try:
292
+ # Reset environment for this task
293
+ obs = env.reset(task_name=task_name)
294
+
295
+ for step in range(1, MAX_STEPS + 1):
296
+ if obs.done:
297
+ break
298
+
299
+ # Get current patient ID from observation
300
+ current_patient_id = ""
301
+ if obs.current_patient:
302
+ current_patient_id = obs.current_patient.get("patient_id", "")
303
+
304
+ # Build observation dict for LLM
305
+ obs_dict = {
306
+ "current_patient": obs.current_patient or {},
307
+ "queue_length": obs.queue_length,
308
+ "queue_position": obs.queue_position,
309
+ "missing_fields": obs.missing_fields,
310
+ "previous_action_feedback": obs.previous_action_feedback or "",
311
+ "step_number": obs.step_number,
312
+ "task_name": obs.task_name,
313
+ }
314
+
315
+ # Get action from LLM
316
+ action = get_llm_action(client, obs_dict, current_patient_id)
317
+
318
+ # Step the environment
319
+ obs = env.step(action)
320
+
321
+ reward = obs.reward if obs.reward is not None else 0.0
322
+ done = obs.done
323
+ error = None
324
+
325
+ reward_val = round(reward, 2)
326
+ rewards.append(reward_val)
327
+ steps_taken = step
328
+
329
+ # Build action string for logging
330
+ action_str = action.action_type.value
331
+ if action.patient_id:
332
+ action_str += f"({action.patient_id}"
333
+ if action.priority_level:
334
+ action_str += f",{action.priority_level.value}"
335
+ if action.info_field:
336
+ action_str += f",{action.info_field.value}"
337
+ action_str += ")"
338
+
339
+ log_step(
340
+ step=step,
341
+ action=action_str,
342
+ reward=reward_val,
343
+ done=done,
344
+ error=error,
345
+ )
346
+
347
+ if done:
348
+ break
349
+
350
+ # Compute final score using the grader
351
+ state_dict = env.state.model_dump()
352
+ score = grade_task(task_name, state_dict)
353
+ score = round(score, 2)
354
+ success = score >= 0.5
355
+
356
+ except Exception as exc:
357
+ print(f"[DEBUG] Task {task_name} error: {exc}", flush=True)
358
+ traceback.print_exc(file=sys.stderr)
359
+ score = 0.0
360
+ success = False
361
+
362
+ finally:
363
+ log_end(
364
+ success=success,
365
+ steps=steps_taken,
366
+ score=score,
367
+ rewards=rewards,
368
+ )
369
+
370
+
371
+ # ============================================================================
372
+ # Main Entry Point
373
+ # ============================================================================
374
+
375
+ if __name__ == "__main__":
376
+ # Initialize OpenAI client
377
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
378
+
379
+ # Run all tasks
380
+ for task in TASKS:
381
+ run_task(task, client)
models.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: models.py
3
+ Purpose: Define all typed Pydantic models and enums for the TriageFlow environment.
4
+ Part of: Medical Triage Assistant — OpenEnv Round 1
5
+ Author: Team Squirrel
6
+
7
+ Overview:
8
+ This module defines the complete type system for the TriageFlow environment.
9
+ It includes enums for priority levels, action types, and information fields,
10
+ as well as Pydantic models for patient records, observations, actions, and state.
11
+ All models inherit from OpenEnv base classes to ensure spec compliance.
12
+
13
+ Dependencies:
14
+ - openenv.core.env_server: Action, Observation, State base classes
15
+ - pydantic: Field for model metadata
16
+ - enum: Enum support for typed constants
17
+ - typing: Type annotations
18
+
19
+ Usage:
20
+ from models import TriageAction, TriageObservation, TriageState, PriorityLevel
21
+ """
22
+
23
+ from enum import Enum
24
+ from typing import Any, Dict, List, Optional
25
+
26
+ from pydantic import Field
27
+
28
+ from openenv.core.env_server import Action, Observation, State
29
+
30
+
31
+ # ============================================================================
32
+ # Enums
33
+ # ============================================================================
34
+
35
+ class PriorityLevel(str, Enum):
36
+ """
37
+ Patient urgency priority levels following standard triage categories.
38
+
39
+ Attributes:
40
+ IMMEDIATE: Life-threatening, requires immediate intervention.
41
+ URGENT: Serious condition, needs attention within 15 minutes.
42
+ LESS_URGENT: Stable condition, can wait 30-60 minutes.
43
+ NON_URGENT: Minor issue, routine care.
44
+ """
45
+ IMMEDIATE = "immediate"
46
+ URGENT = "urgent"
47
+ LESS_URGENT = "less_urgent"
48
+ NON_URGENT = "non_urgent"
49
+
50
+
51
+ class ActionType(str, Enum):
52
+ """
53
+ Available action types the agent can take during triage.
54
+
55
+ Attributes:
56
+ ASSIGN_PRIORITY: Assign an urgency level to a patient.
57
+ REQUEST_INFO: Request a missing information field for a patient.
58
+ ESCALATE: Escalate patient to senior staff with a reason.
59
+ DEFER: Push patient further back in queue.
60
+ ADVANCE_QUEUE: Move to the next patient in queue.
61
+ """
62
+ ASSIGN_PRIORITY = "assign_priority"
63
+ REQUEST_INFO = "request_info"
64
+ ESCALATE = "escalate"
65
+ DEFER = "defer"
66
+ ADVANCE_QUEUE = "advance_queue"
67
+
68
+
69
+ class InfoField(str, Enum):
70
+ """
71
+ Information fields that may be missing from a patient record.
72
+
73
+ Attributes:
74
+ VITALS: Heart rate, blood pressure, SpO2, temperature.
75
+ HISTORY: Brief medical history summary.
76
+ ALLERGIES: Known allergies.
77
+ MEDICATIONS: Current medications.
78
+ CHIEF_COMPLAINT: Primary stated complaint.
79
+ """
80
+ VITALS = "vitals"
81
+ HISTORY = "history"
82
+ ALLERGIES = "allergies"
83
+ MEDICATIONS = "medications"
84
+ CHIEF_COMPLAINT = "chief_complaint"
85
+
86
+
87
+ # ============================================================================
88
+ # Patient Record Model
89
+ # ============================================================================
90
+
91
+ class PatientRecord(Action):
92
+ """
93
+ Represents a patient's intake record with all relevant clinical and administrative data.
94
+
95
+ Attributes:
96
+ patient_id (str): Unique identifier for the patient.
97
+ age (int): Patient age in years.
98
+ chief_complaint (str): Primary stated complaint from the patient.
99
+ symptoms (List[str]): List of reported symptoms.
100
+ vitals (Optional[dict]): HR, BP, SpO2, temperature — None if missing.
101
+ history (Optional[str]): Brief medical history summary.
102
+ medications (Optional[List[str]]): Current medications list.
103
+ allergies (Optional[List[str]]): Known allergies list.
104
+ info_complete (bool): Whether all required fields are present.
105
+
106
+ Notes:
107
+ ground_truth_priority is stored separately in tasks.py and is
108
+ never exposed in the observation visible to agents.
109
+ """
110
+ patient_id: str = Field(..., description="Unique patient identifier")
111
+ age: int = Field(..., description="Patient age in years")
112
+ chief_complaint: str = Field(..., description="Primary stated complaint")
113
+ symptoms: List[str] = Field(default_factory=list, description="List of reported symptoms")
114
+ vitals: Optional[Dict[str, Any]] = Field(None, description="HR, BP, SpO2, temp — None if missing")
115
+ history: Optional[str] = Field(None, description="Brief history summary")
116
+ medications: Optional[List[str]] = Field(None, description="Current medications")
117
+ allergies: Optional[List[str]] = Field(None, description="Known allergies")
118
+ info_complete: bool = Field(True, description="Whether all required fields are present")
119
+
120
+
121
+ # ============================================================================
122
+ # OpenEnv Action Model
123
+ # ============================================================================
124
+
125
+ class TriageAction(Action):
126
+ """
127
+ Action model for the TriageFlow environment.
128
+
129
+ The agent uses this action to interact with the environment by
130
+ assigning priorities, requesting information, escalating, deferring,
131
+ or advancing through the patient queue.
132
+
133
+ Attributes:
134
+ action_type (ActionType): Which action to take.
135
+ patient_id (str): Target patient ID for the action.
136
+ priority_level (Optional[PriorityLevel]): Used with ASSIGN_PRIORITY.
137
+ info_field (Optional[InfoField]): Used with REQUEST_INFO.
138
+ escalation_reason (Optional[str]): Used with ESCALATE.
139
+
140
+ Notes:
141
+ Not all fields are required for every action type. The environment
142
+ validates that the correct fields are present for the given action_type.
143
+ """
144
+ action_type: ActionType = Field(..., description="Which action to take")
145
+ patient_id: str = Field(default="", description="Target patient ID")
146
+ priority_level: Optional[PriorityLevel] = Field(None, description="Used with ASSIGN_PRIORITY")
147
+ info_field: Optional[InfoField] = Field(None, description="Used with REQUEST_INFO")
148
+ escalation_reason: Optional[str] = Field(None, description="Used with ESCALATE")
149
+
150
+
151
+ # ============================================================================
152
+ # OpenEnv Observation Model
153
+ # ============================================================================
154
+
155
+ class TriageObservation(Observation):
156
+ """
157
+ Observation model returned to the agent after each step.
158
+
159
+ Contains the current patient record (without ground truth), queue state,
160
+ missing field indicators, and feedback from the previous action.
161
+
162
+ Attributes:
163
+ current_patient (Optional[dict]): Current patient data visible to agent.
164
+ queue_length (int): Number of remaining patients in queue.
165
+ queue_position (int): Position of current patient in queue.
166
+ missing_fields (List[str]): Fields missing for current patient.
167
+ previous_action_feedback (Optional[str]): System feedback on last action.
168
+ step_number (int): Current step in the episode.
169
+ task_name (str): Name of the current task.
170
+
171
+ Notes:
172
+ done and reward are inherited from the Observation base class.
173
+ """
174
+ # done: bool and reward: Optional[float] are inherited from Observation
175
+ current_patient: Optional[Dict[str, Any]] = Field(
176
+ None, description="Current patient data visible to agent"
177
+ )
178
+ queue_length: int = Field(0, description="Remaining patients in queue")
179
+ queue_position: int = Field(0, description="Position of current patient")
180
+ missing_fields: List[str] = Field(
181
+ default_factory=list, description="Fields missing for current patient"
182
+ )
183
+ previous_action_feedback: Optional[str] = Field(
184
+ None, description="System feedback on last action"
185
+ )
186
+ step_number: int = Field(0, description="Current step in episode")
187
+ task_name: str = Field("", description="Name of the current task")
188
+
189
+
190
+ # ============================================================================
191
+ # OpenEnv State Model
192
+ # ============================================================================
193
+
194
+ class TriageState(State):
195
+ """
196
+ Full internal state of the environment, used by graders and validators.
197
+
198
+ Contains everything needed to deterministically score the episode,
199
+ including ground truth labels and complete action history.
200
+
201
+ Attributes:
202
+ task_name (str): Which task is active.
203
+ patients (List[dict]): Full patient queue with ground truth.
204
+ assignments (Dict[str, str]): patient_id → assigned priority.
205
+ escalations (Dict[str, str]): patient_id → escalation reason.
206
+ info_requests (List[dict]): List of info request records.
207
+ action_history (List[dict]): Complete action log.
208
+ current_index (int): Current position in queue.
209
+ max_steps (int): Maximum steps allowed.
210
+ queue_cleared (bool): Whether all patients have been processed.
211
+
212
+ Notes:
213
+ episode_id and step_count are inherited from the State base class.
214
+ """
215
+ # episode_id: Optional[str] and step_count: int are inherited from State
216
+ task_name: str = Field("", description="Active task name")
217
+ patients: List[Dict[str, Any]] = Field(
218
+ default_factory=list, description="Full patient queue with ground truth"
219
+ )
220
+ assignments: Dict[str, str] = Field(
221
+ default_factory=dict, description="patient_id → assigned priority"
222
+ )
223
+ escalations: Dict[str, str] = Field(
224
+ default_factory=dict, description="patient_id → escalation reason"
225
+ )
226
+ info_requests: List[Dict[str, Any]] = Field(
227
+ default_factory=list, description="Info request records"
228
+ )
229
+ action_history: List[Dict[str, Any]] = Field(
230
+ default_factory=list, description="Complete action log"
231
+ )
232
+ current_index: int = Field(0, description="Current position in queue")
233
+ max_steps: int = Field(20, description="Maximum steps allowed")
234
+ queue_cleared: bool = Field(False, description="Whether all patients processed")
openenv.yaml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ name: triage_flow
2
+ version: "1.0.0"
3
+ description: "TriageFlow: AI-assisted patient intake triage prioritization environment. Simulates emergency department intake desk where an AI agent must assess urgency, assign priorities, request missing information, and manage a patient queue."
4
+ tasks:
5
+ - name: basic-triage
6
+ difficulty: easy
7
+ - name: incomplete-records-triage
8
+ difficulty: medium
9
+ - name: mass-casualty-triage
10
+ difficulty: hard
pyproject.toml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openenv-triage-flow"
7
+ version = "1.0.0"
8
+ description = "TriageFlow: AI-assisted patient intake triage prioritization environment for OpenEnv"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "Team Squirrel"},
14
+ ]
15
+ dependencies = [
16
+ "openenv-core[core]>=0.2.2",
17
+ "openai>=1.0.0",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "pytest>=7.0",
23
+ "pytest-asyncio>=0.21.0",
24
+ ]
25
+
26
+ [project.scripts]
27
+ server = "triage_flow.server.app:main"
28
+
29
+ [tool.setuptools]
30
+ include-package-data = true
31
+ packages = ["triage_flow", "triage_flow.server"]
32
+ package-dir = { "triage_flow" = ".", "triage_flow.server" = "server" }
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ openenv-core>=0.1.0
2
+ fastapi>=0.104.0
3
+ uvicorn>=0.24.0
4
+ pydantic>=2.0.0
5
+ openai>=1.0.0
6
+ httpx>=0.25.0
7
+ websockets>=12.0
scripts/validate-submission.sh ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Pre-submission validation script for TriageFlow
3
+ # Usage: ./scripts/validate-submission.sh <space_url>
4
+
5
+ set -e
6
+
7
+ BOLD="\033[1m"
8
+ GREEN="\033[32m"
9
+ RED="\033[31m"
10
+ NC="\033[0m"
11
+
12
+ SPACE_URL="${1:-}"
13
+ REPO_DIR="$(dirname "$(dirname "$(readlink -f "$0")")")"
14
+
15
+ pass() { printf " ${GREEN}✅ PASS${NC}: %s\n" "$1"; }
16
+ fail() { printf " ${RED}❌ FAIL${NC}: %s\n" "$1"; }
17
+
18
+ echo ""
19
+ echo "${BOLD}========================================${NC}"
20
+ echo "${BOLD} TriageFlow Pre-Submission Validator${NC}"
21
+ echo "${BOLD}========================================${NC}"
22
+ echo ""
23
+
24
+ # Step 1: Ping HF Space
25
+ if [ -n "$SPACE_URL" ]; then
26
+ echo "${BOLD}Step 1/3: Pinging HF Space${NC} ($SPACE_URL/reset) ..."
27
+ HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
28
+ -H "Content-Type: application/json" -d '{}' \
29
+ "$SPACE_URL/reset" --max-time 30 2>/dev/null || printf "000")
30
+ if [ "$HTTP_CODE" = "200" ]; then
31
+ pass "HF Space is live and responds to /reset"
32
+ else
33
+ fail "HF Space /reset returned HTTP $HTTP_CODE (expected 200)"
34
+ exit 1
35
+ fi
36
+ else
37
+ echo "Step 1/3: Skipped (no Space URL provided)"
38
+ fi
39
+
40
+ # Step 2: Docker build
41
+ echo ""
42
+ echo "${BOLD}Step 2/3: Running docker build${NC} ..."
43
+ if command -v docker &>/dev/null; then
44
+ if docker build -t triage-flow-test "$REPO_DIR" > /dev/null 2>&1; then
45
+ pass "Docker build succeeded"
46
+ else
47
+ fail "Docker build failed"
48
+ exit 1
49
+ fi
50
+ else
51
+ echo " Skipped (docker not found)"
52
+ fi
53
+
54
+ # Step 3: openenv validate
55
+ echo ""
56
+ echo "${BOLD}Step 3/3: Running openenv validate${NC} ..."
57
+ if command -v openenv &>/dev/null; then
58
+ if (cd "$REPO_DIR" && openenv validate 2>&1); then
59
+ pass "openenv validate passed"
60
+ else
61
+ fail "openenv validate failed"
62
+ exit 1
63
+ fi
64
+ else
65
+ echo " Skipped (openenv not found)"
66
+ fi
67
+
68
+ echo ""
69
+ echo "${BOLD}========================================${NC}"
70
+ echo "${GREEN}${BOLD} All checks passed!${NC}"
71
+ echo "${GREEN}${BOLD} Your submission is ready.${NC}"
72
+ echo "${BOLD}========================================${NC}"
73
+ echo ""
74
+
75
+ exit 0
server/Dockerfile ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
2
+ FROM ${BASE_IMAGE} AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ # Ensure git is available
7
+ RUN apt-get update && \
8
+ apt-get install -y --no-install-recommends git && \
9
+ rm -rf /var/lib/apt/lists/*
10
+
11
+ ARG BUILD_MODE=in-repo
12
+ ARG ENV_NAME=triage_flow
13
+
14
+ # Copy environment code
15
+ COPY . /app/env
16
+
17
+ WORKDIR /app/env
18
+
19
+ # Ensure uv is available
20
+ RUN if ! command -v uv >/dev/null 2>&1; then \
21
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
22
+ mv /root/.local/bin/uv /usr/local/bin/uv && \
23
+ mv /root/.local/bin/uvx /usr/local/bin/uvx; \
24
+ fi
25
+
26
+ # Install dependencies using uv sync
27
+ RUN --mount=type=cache,target=/root/.cache/uv \
28
+ if [ -f uv.lock ]; then \
29
+ uv sync --frozen --no-install-project --no-editable; \
30
+ else \
31
+ uv sync --no-install-project --no-editable; \
32
+ fi
33
+
34
+ RUN --mount=type=cache,target=/root/.cache/uv \
35
+ if [ -f uv.lock ]; then \
36
+ uv sync --frozen --no-editable; \
37
+ else \
38
+ uv sync --no-editable; \
39
+ fi
40
+
41
+ # Final runtime stage
42
+ FROM ${BASE_IMAGE}
43
+
44
+ WORKDIR /app
45
+
46
+ # Copy the virtual environment from builder
47
+ COPY --from=builder /app/env/.venv /app/.venv
48
+
49
+ # Copy the environment code
50
+ COPY --from=builder /app/env /app/env
51
+
52
+ # Set PATH to use the virtual environment
53
+ ENV PATH="/app/.venv/bin:$PATH"
54
+
55
+ # Set PYTHONPATH so imports work correctly
56
+ ENV PYTHONPATH="/app/env:$PYTHONPATH"
57
+
58
+ # Health check
59
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
60
+ CMD curl -f http://localhost:8000/health || exit 1
61
+
62
+ EXPOSE 8000
63
+
64
+ # Run the FastAPI server
65
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
server/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """
2
+ Module: __init__.py
3
+ Purpose: Server package initialization.
4
+ Part of: Medical Triage Assistant — OpenEnv Round 1
5
+ Author: Team Squirrel
6
+ """
server/app.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: app.py
3
+ Purpose: FastAPI server exposing the TriageFlow environment via HTTP/WebSocket.
4
+ Part of: Medical Triage Assistant — OpenEnv Round 1
5
+ Author: Team Squirrel
6
+
7
+ Overview:
8
+ Uses OpenEnv's create_app() to automatically create all required
9
+ endpoints: /reset, /step, /state, /health, /ws, /docs, /web.
10
+ This is the entry point when running the server in Docker or locally.
11
+
12
+ Dependencies:
13
+ - openenv.core.env_server.http_server: create_app
14
+ - models: TriageAction, TriageObservation
15
+ - server.triage_flow_environment: TriageEnvironment
16
+
17
+ Usage:
18
+ uvicorn server.app:app --host 0.0.0.0 --port 8000
19
+ """
20
+
21
+ try:
22
+ from openenv.core.env_server.http_server import create_app
23
+ except ImportError:
24
+ from openenv.core.env_server import create_fastapi_app as create_app
25
+
26
+ try:
27
+ from ..models import TriageAction, TriageObservation
28
+ from .triage_flow_environment import TriageEnvironment
29
+ except (ImportError, ModuleNotFoundError):
30
+ import sys, os
31
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
32
+ from models import TriageAction, TriageObservation
33
+ from server.triage_flow_environment import TriageEnvironment
34
+
35
+
36
+ # Create the app with web interface
37
+ app = create_app(
38
+ TriageEnvironment,
39
+ TriageAction,
40
+ TriageObservation,
41
+ env_name="triage_flow",
42
+ max_concurrent_envs=1,
43
+ )
44
+
45
+
46
+ def main(host: str = "0.0.0.0", port: int = 8000):
47
+ """
48
+ Entry point for direct execution via uv run or python -m.
49
+
50
+ Args:
51
+ host: Host address to bind to (default: "0.0.0.0")
52
+ port: Port number to listen on (default: 8000)
53
+ """
54
+ import uvicorn
55
+ uvicorn.run(app, host=host, port=port)
56
+
57
+
58
+ if __name__ == "__main__":
59
+ main()
server/triage_flow_environment.py ADDED
@@ -0,0 +1,544 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: environment.py
3
+ Purpose: Core environment logic implementing reset(), step(), and state for TriageFlow.
4
+ Part of: Medical Triage Assistant — OpenEnv Round 1
5
+ Author: Team Squirrel
6
+
7
+ Overview:
8
+ Implements the TriageEnvironment class which extends the OpenEnv Environment
9
+ base class. This is the central simulation engine: it manages the patient queue,
10
+ validates and applies agent actions, computes rewards, and tracks state for
11
+ deterministic grading. The environment follows a standard episode lifecycle:
12
+ reset() → (observe, act, step) loop → done.
13
+
14
+ Dependencies:
15
+ - openenv.core.env_server: Environment base class
16
+ - models: TriageAction, TriageObservation, TriageState
17
+ - tasks: get_task_config for loading patient data
18
+ - reward: compute_step_reward for per-step reward calculation
19
+
20
+ Usage:
21
+ env = TriageEnvironment()
22
+ obs = env.reset(task_name="basic-triage")
23
+ obs = env.step(TriageAction(action_type="assign_priority", patient_id="P001", priority_level="immediate"))
24
+ """
25
+
26
+ import uuid
27
+ from typing import Any, Dict, List, Optional
28
+
29
+ from openenv.core.env_server import Environment
30
+
31
+ import sys
32
+ import os
33
+
34
+ # Add parent directory to path so we can import models
35
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
36
+
37
+ from models import (
38
+ TriageAction,
39
+ TriageObservation,
40
+ TriageState,
41
+ PriorityLevel,
42
+ ActionType,
43
+ InfoField,
44
+ )
45
+ from triage_flow.tasks import get_task_config, TASK_NAMES
46
+ from triage_flow.reward import compute_step_reward, compute_terminal_reward
47
+
48
+
49
+ class TriageEnvironment(Environment):
50
+ """
51
+ Core triage simulation environment implementing the OpenEnv spec.
52
+
53
+ Manages a queue of patients that an agent must triage by assigning
54
+ urgency priorities, requesting missing information, escalating critical
55
+ cases, or deferring patients. The environment tracks all state for
56
+ deterministic grading.
57
+
58
+ Attributes:
59
+ SUPPORTS_CONCURRENT_SESSIONS (bool): Enables multiple simultaneous clients.
60
+
61
+ Notes:
62
+ Ground truth priorities are never exposed in the observation.
63
+ The grader reads from state() for scoring.
64
+ """
65
+
66
+ SUPPORTS_CONCURRENT_SESSIONS = True
67
+
68
+ def __init__(self):
69
+ """Initialize environment with empty state."""
70
+ self._state = TriageState()
71
+ self._patients: List[Dict[str, Any]] = []
72
+ self._task_config: Dict[str, Any] = {}
73
+ self._cumulative_reward: float = 0.0
74
+
75
+ def reset(self, seed=None, episode_id=None, task_name=None, **kwargs) -> TriageObservation:
76
+ """
77
+ Reset the environment for a new episode.
78
+
79
+ Loads the specified task's patient queue, clears all state,
80
+ and returns the initial observation showing the first patient.
81
+
82
+ Args:
83
+ seed: Optional random seed (not used — tasks are deterministic).
84
+ episode_id: Optional episode identifier.
85
+ task_name: Which task to load. Defaults to "basic-triage".
86
+ **kwargs: Additional arguments (may include task_name).
87
+
88
+ Returns:
89
+ TriageObservation: Initial observation with first patient visible.
90
+
91
+ Notes:
92
+ If task_name is not provided, it defaults to "basic-triage".
93
+ The task_name can also be passed via kwargs.
94
+ """
95
+ # Extract task_name from kwargs if not provided directly
96
+ if task_name is None:
97
+ task_name = kwargs.get("task_name", "basic-triage")
98
+
99
+ # Load task configuration
100
+ self._task_config = get_task_config(task_name)
101
+ self._patients = self._task_config["patients"]
102
+
103
+ # Reset cumulative reward
104
+ self._cumulative_reward = 0.0
105
+
106
+ # Initialize state
107
+ self._state = TriageState(
108
+ episode_id=episode_id or str(uuid.uuid4()),
109
+ step_count=0,
110
+ task_name=task_name,
111
+ patients=[p.copy() for p in self._patients],
112
+ assignments={},
113
+ escalations={},
114
+ info_requests=[],
115
+ action_history=[],
116
+ current_index=0,
117
+ max_steps=self._task_config["max_steps"],
118
+ queue_cleared=False,
119
+ )
120
+
121
+ # Build initial observation
122
+ return self._build_observation(
123
+ reward=None,
124
+ done=False,
125
+ feedback=f"Episode started. Task: {task_name}. "
126
+ f"{len(self._patients)} patients in queue. "
127
+ f"Assess each patient and assign appropriate priority.",
128
+ )
129
+
130
+ def step(self, action: TriageAction, timeout_s=None, **kwargs) -> TriageObservation:
131
+ """
132
+ Execute one agent action and return the resulting observation.
133
+
134
+ Validates the action, applies it to the internal state, computes
135
+ the per-step reward, and checks for done conditions.
136
+
137
+ Args:
138
+ action (TriageAction): The action to execute.
139
+ timeout_s: Optional timeout (not used).
140
+ **kwargs: Additional arguments.
141
+
142
+ Returns:
143
+ TriageObservation: Updated observation after action.
144
+
145
+ Notes:
146
+ Invalid actions are handled gracefully with a penalty reward
147
+ and descriptive feedback. The episode always emits an observation.
148
+ """
149
+ self._state.step_count += 1
150
+
151
+ # Validate action and compute reward
152
+ is_valid, validation_msg = self._validate_action(action)
153
+
154
+ # Get current patient info for reward computation
155
+ current_patient = self._get_current_patient()
156
+ ground_truth = current_patient.get("ground_truth_priority") if current_patient else None
157
+ missing_fields = current_patient.get("missing_fields", []) if current_patient else []
158
+ info_changes = current_patient.get("info_changes_decision", False) if current_patient else False
159
+ patient_id = action.patient_id if action.patient_id else (
160
+ current_patient.get("patient_id", "") if current_patient else ""
161
+ )
162
+
163
+ # Compute reward
164
+ reward_value, reward_reason = compute_step_reward(
165
+ action_type=action.action_type.value if isinstance(action.action_type, ActionType) else str(action.action_type),
166
+ patient_id=patient_id,
167
+ assigned_priority=action.priority_level.value if action.priority_level else None,
168
+ ground_truth_priority=ground_truth,
169
+ info_field=action.info_field.value if action.info_field else None,
170
+ missing_fields=missing_fields,
171
+ info_changes_decision=info_changes,
172
+ already_assigned=patient_id in self._state.assignments,
173
+ already_escalated=patient_id in self._state.escalations,
174
+ escalation_reason=action.escalation_reason,
175
+ action_history=self._state.action_history,
176
+ is_valid_action=is_valid,
177
+ )
178
+
179
+ # Apply action to state
180
+ feedback = self._apply_action(action, is_valid, validation_msg)
181
+
182
+ # Record action in history
183
+ self._state.action_history.append({
184
+ "step": self._state.step_count,
185
+ "action_type": action.action_type.value if isinstance(action.action_type, ActionType) else str(action.action_type),
186
+ "patient_id": patient_id,
187
+ "priority_level": action.priority_level.value if action.priority_level else None,
188
+ "info_field": action.info_field.value if action.info_field else None,
189
+ "escalation_reason": action.escalation_reason,
190
+ "reward": reward_value,
191
+ "reward_reason": reward_reason,
192
+ "valid": is_valid,
193
+ })
194
+
195
+ # Accumulate reward
196
+ self._cumulative_reward += reward_value
197
+
198
+ # Check done conditions
199
+ done = self._check_done()
200
+
201
+ # Add terminal bonus if done and queue cleared
202
+ if done and self._state.queue_cleared:
203
+ terminal_reward, terminal_reason = compute_terminal_reward(True)
204
+ reward_value += terminal_reward
205
+ self._cumulative_reward += terminal_reward
206
+ feedback += f" {terminal_reason}."
207
+
208
+ return self._build_observation(
209
+ reward=reward_value,
210
+ done=done,
211
+ feedback=f"{feedback} | {reward_reason}",
212
+ )
213
+
214
+ @property
215
+ def state(self) -> TriageState:
216
+ """
217
+ Return the full internal state for grading and validation.
218
+
219
+ Returns:
220
+ TriageState: Complete state snapshot including ground truth,
221
+ action history, and all assignments.
222
+
223
+ Notes:
224
+ This is read by graders to compute the final score.
225
+ It must be serializable to JSON.
226
+ """
227
+ return self._state
228
+
229
+ # ========================================================================
230
+ # Internal Helpers
231
+ # ========================================================================
232
+
233
+ def _get_current_patient(self) -> Optional[Dict[str, Any]]:
234
+ """
235
+ Get the patient at the current queue position.
236
+
237
+ Returns:
238
+ Optional[dict]: Current patient data, or None if queue is empty/exhausted.
239
+ """
240
+ if 0 <= self._state.current_index < len(self._patients):
241
+ return self._patients[self._state.current_index]
242
+ return None
243
+
244
+ def _build_observation(
245
+ self, reward: Optional[float], done: bool, feedback: str
246
+ ) -> TriageObservation:
247
+ """
248
+ Build an observation object from current state.
249
+
250
+ Strips ground truth fields from the patient record before
251
+ including it in the observation.
252
+
253
+ Args:
254
+ reward (Optional[float]): Reward for this step.
255
+ done (bool): Whether episode is complete.
256
+ feedback (str): Human-readable feedback message.
257
+
258
+ Returns:
259
+ TriageObservation: Agent-visible observation.
260
+ """
261
+ current_patient = self._get_current_patient()
262
+
263
+ # Build agent-visible patient dict (strip ground truth)
264
+ visible_patient = None
265
+ missing = []
266
+ if current_patient:
267
+ visible_patient = {
268
+ k: v
269
+ for k, v in current_patient.items()
270
+ if k not in ("ground_truth_priority", "missing_fields",
271
+ "info_changes_decision", "revealed_history",
272
+ "revealed_medications")
273
+ }
274
+ # Calculate missing fields
275
+ for field in ["vitals", "history", "medications", "allergies"]:
276
+ if current_patient.get(field) is None:
277
+ missing.append(field)
278
+
279
+ # Count remaining unassigned patients
280
+ unassigned_count = sum(
281
+ 1 for p in self._patients
282
+ if p["patient_id"] not in self._state.assignments
283
+ )
284
+
285
+ return TriageObservation(
286
+ done=done,
287
+ reward=reward,
288
+ current_patient=visible_patient,
289
+ queue_length=unassigned_count,
290
+ queue_position=self._state.current_index + 1,
291
+ missing_fields=missing,
292
+ previous_action_feedback=feedback,
293
+ step_number=self._state.step_count,
294
+ task_name=self._state.task_name,
295
+ )
296
+
297
+ def _validate_action(self, action: TriageAction) -> tuple:
298
+ """
299
+ Validate that an action is legal in the current state.
300
+
301
+ Args:
302
+ action (TriageAction): Action to validate.
303
+
304
+ Returns:
305
+ tuple: (is_valid: bool, message: str)
306
+ """
307
+ action_type = action.action_type.value if isinstance(action.action_type, ActionType) else str(action.action_type)
308
+ current_patient = self._get_current_patient()
309
+
310
+ if not current_patient:
311
+ return False, "No patient at current queue position"
312
+
313
+ patient_id = action.patient_id or current_patient.get("patient_id", "")
314
+
315
+ # Check if patient_id exists in the queue
316
+ valid_ids = [p["patient_id"] for p in self._patients]
317
+ if patient_id and patient_id not in valid_ids:
318
+ return False, f"Patient {patient_id} not found in queue"
319
+
320
+ if action_type == "assign_priority":
321
+ if not action.priority_level:
322
+ return False, "ASSIGN_PRIORITY requires a priority_level"
323
+ if patient_id in self._state.assignments:
324
+ return False, f"Patient {patient_id} already has priority assigned"
325
+
326
+ elif action_type == "request_info":
327
+ if not action.info_field:
328
+ return False, "REQUEST_INFO requires an info_field"
329
+
330
+ elif action_type == "escalate":
331
+ if patient_id in self._state.escalations:
332
+ return False, f"Patient {patient_id} already escalated"
333
+
334
+ elif action_type == "defer":
335
+ if len(self._patients) <= 1:
336
+ return False, "Cannot defer when only one patient in queue"
337
+
338
+ elif action_type == "advance_queue":
339
+ pass # Always valid
340
+
341
+ else:
342
+ return False, f"Unknown action type: {action_type}"
343
+
344
+ return True, "Action valid"
345
+
346
+ def _apply_action(self, action: TriageAction, is_valid: bool, validation_msg: str) -> str:
347
+ """
348
+ Apply a validated action to the internal state.
349
+
350
+ Args:
351
+ action (TriageAction): Action to apply.
352
+ is_valid (bool): Whether the action passed validation.
353
+ validation_msg (str): Validation feedback message.
354
+
355
+ Returns:
356
+ str: Feedback message describing what happened.
357
+ """
358
+ if not is_valid:
359
+ return f"Action rejected: {validation_msg}"
360
+
361
+ action_type = action.action_type.value if isinstance(action.action_type, ActionType) else str(action.action_type)
362
+ current_patient = self._get_current_patient()
363
+ patient_id = action.patient_id or (current_patient.get("patient_id", "") if current_patient else "")
364
+
365
+ if action_type == "assign_priority":
366
+ priority = action.priority_level.value if action.priority_level else "unknown"
367
+ self._state.assignments[patient_id] = priority
368
+ # Auto-advance to next unassigned patient
369
+ self._advance_to_next_unassigned()
370
+ # Check if queue is cleared
371
+ self._check_queue_cleared()
372
+ return f"Priority {priority} assigned to patient {patient_id}"
373
+
374
+ elif action_type == "request_info":
375
+ field = action.info_field.value if action.info_field else "unknown"
376
+ # Record the request
377
+ self._state.info_requests.append({
378
+ "patient_id": patient_id,
379
+ "field": field,
380
+ "step": self._state.step_count,
381
+ })
382
+ # Reveal the information if it was genuinely missing
383
+ target_patient = self._find_patient(patient_id)
384
+ if target_patient:
385
+ revealed_value = None
386
+ if field == "history" and target_patient.get("revealed_history"):
387
+ target_patient["history"] = target_patient["revealed_history"]
388
+ revealed_value = target_patient["history"]
389
+ elif field == "medications" and target_patient.get("revealed_medications"):
390
+ target_patient["medications"] = target_patient["revealed_medications"]
391
+ revealed_value = str(target_patient["medications"])
392
+ elif field == "vitals" and target_patient.get("vitals") is None:
393
+ # Generate plausible vitals based on ground truth priority
394
+ target_patient["vitals"] = self._generate_revealed_vitals(target_patient)
395
+ revealed_value = str(target_patient["vitals"])
396
+ elif field == "allergies" and target_patient.get("allergies") is None:
397
+ target_patient["allergies"] = []
398
+ revealed_value = "No known allergies"
399
+
400
+ # Update info_complete if all fields now present
401
+ still_missing = any(
402
+ target_patient.get(f) is None
403
+ for f in ["vitals", "history", "medications", "allergies"]
404
+ )
405
+ target_patient["info_complete"] = not still_missing
406
+
407
+ if revealed_value:
408
+ return f"Info requested: {field} revealed for patient {patient_id}: {revealed_value}"
409
+ return f"Info requested: {field} for patient {patient_id} (already present)"
410
+
411
+ return f"Info requested: {field} for patient {patient_id}"
412
+
413
+ elif action_type == "escalate":
414
+ reason = action.escalation_reason or "No reason provided"
415
+ self._state.escalations[patient_id] = reason
416
+ return f"Patient {patient_id} escalated to senior staff. Reason: {reason}"
417
+
418
+ elif action_type == "defer":
419
+ # Move patient to end of queue
420
+ target_idx = next(
421
+ (i for i, p in enumerate(self._patients) if p["patient_id"] == patient_id),
422
+ None
423
+ )
424
+ if target_idx is not None:
425
+ patient = self._patients.pop(target_idx)
426
+ self._patients.append(patient)
427
+ # Adjust current index if needed
428
+ if target_idx <= self._state.current_index:
429
+ self._state.current_index = max(0, self._state.current_index - 1)
430
+ return f"Patient {patient_id} deferred to end of queue"
431
+
432
+ elif action_type == "advance_queue":
433
+ self._state.current_index = min(
434
+ self._state.current_index + 1, len(self._patients) - 1
435
+ )
436
+ next_patient = self._get_current_patient()
437
+ next_id = next_patient["patient_id"] if next_patient else "none"
438
+ return f"Advanced queue. Now viewing patient {next_id}"
439
+
440
+ return "Action applied"
441
+
442
+ def _advance_to_next_unassigned(self):
443
+ """
444
+ Move current_index to the next patient that hasn't been assigned yet.
445
+ Wraps around the queue if necessary.
446
+ """
447
+ start = self._state.current_index
448
+ for i in range(len(self._patients)):
449
+ idx = (start + i) % len(self._patients)
450
+ patient = self._patients[idx]
451
+ if patient["patient_id"] not in self._state.assignments:
452
+ self._state.current_index = idx
453
+ return
454
+ # All assigned — stay at current
455
+ self._state.current_index = min(start, len(self._patients) - 1)
456
+
457
+ def _check_queue_cleared(self):
458
+ """Check if all patients have been assigned a priority."""
459
+ all_assigned = all(
460
+ p["patient_id"] in self._state.assignments for p in self._patients
461
+ )
462
+ self._state.queue_cleared = all_assigned
463
+
464
+ def _check_done(self) -> bool:
465
+ """
466
+ Check if the episode should end.
467
+
468
+ Done conditions:
469
+ 1. All patients assigned a priority (queue cleared)
470
+ 2. Maximum steps reached
471
+
472
+ Returns:
473
+ bool: True if episode should end.
474
+ """
475
+ # All patients assigned
476
+ if self._state.queue_cleared:
477
+ return True
478
+
479
+ # Max steps reached
480
+ if self._state.step_count >= self._state.max_steps:
481
+ return True
482
+
483
+ return False
484
+
485
+ def _find_patient(self, patient_id: str) -> Optional[Dict[str, Any]]:
486
+ """
487
+ Find a patient in the queue by ID.
488
+
489
+ Args:
490
+ patient_id (str): Patient ID to search for.
491
+
492
+ Returns:
493
+ Optional[dict]: Patient data, or None if not found.
494
+ """
495
+ for p in self._patients:
496
+ if p["patient_id"] == patient_id:
497
+ return p
498
+ return None
499
+
500
+ def _generate_revealed_vitals(self, patient: Dict[str, Any]) -> Dict[str, Any]:
501
+ """
502
+ Generate plausible vitals for a patient whose vitals were missing.
503
+
504
+ Based on ground truth priority to maintain clinical consistency.
505
+
506
+ Args:
507
+ patient (dict): Patient data including ground_truth_priority.
508
+
509
+ Returns:
510
+ dict: Vitals dictionary with HR, BP, SpO2, temperature.
511
+
512
+ Notes:
513
+ # ASSUMPTION: Revealed vitals are consistent with the ground truth
514
+ # priority to maintain deterministic grading.
515
+ """
516
+ priority = patient.get("ground_truth_priority", "non_urgent")
517
+ if priority == "immediate":
518
+ return {
519
+ "heart_rate": 125,
520
+ "blood_pressure": "85/55",
521
+ "spo2": 90,
522
+ "temperature": 38.5,
523
+ }
524
+ elif priority == "urgent":
525
+ return {
526
+ "heart_rate": 105,
527
+ "blood_pressure": "145/95",
528
+ "spo2": 93,
529
+ "temperature": 38.8,
530
+ }
531
+ elif priority == "less_urgent":
532
+ return {
533
+ "heart_rate": 85,
534
+ "blood_pressure": "130/82",
535
+ "spo2": 97,
536
+ "temperature": 37.5,
537
+ }
538
+ else: # non_urgent
539
+ return {
540
+ "heart_rate": 75,
541
+ "blood_pressure": "120/78",
542
+ "spo2": 99,
543
+ "temperature": 36.9,
544
+ }
temp_test_env/test_env/README.md ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Test Env Environment Server
3
+ emoji: 🎸
4
+ colorFrom: purple
5
+ colorTo: yellow
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 8000
9
+ base_path: /web
10
+ tags:
11
+ - openenv
12
+ ---
13
+
14
+ # Test Env Environment
15
+
16
+ A simple test environment that echoes back messages. Perfect for testing the env APIs as well as demonstrating environment usage patterns.
17
+
18
+ ## Quick Start
19
+
20
+ The simplest way to use the Test Env environment is through the `TestEnv` class:
21
+
22
+ ```python
23
+ from test_env import TestAction, TestEnv
24
+
25
+ try:
26
+ # Create environment from Docker image
27
+ test_envenv = TestEnv.from_docker_image("test_env-env:latest")
28
+
29
+ # Reset
30
+ result = test_envenv.reset()
31
+ print(f"Reset: {result.observation.echoed_message}")
32
+
33
+ # Send multiple messages
34
+ messages = ["Hello, World!", "Testing echo", "Final message"]
35
+
36
+ for msg in messages:
37
+ result = test_envenv.step(TestAction(message=msg))
38
+ print(f"Sent: '{msg}'")
39
+ print(f" → Echoed: '{result.observation.echoed_message}'")
40
+ print(f" → Length: {result.observation.message_length}")
41
+ print(f" → Reward: {result.reward}")
42
+
43
+ finally:
44
+ # Always clean up
45
+ test_envenv.close()
46
+ ```
47
+
48
+ That's it! The `TestEnv.from_docker_image()` method handles:
49
+ - Starting the Docker container
50
+ - Waiting for the server to be ready
51
+ - Connecting to the environment
52
+ - Container cleanup when you call `close()`
53
+
54
+ ## Building the Docker Image
55
+
56
+ Before using the environment, you need to build the Docker image:
57
+
58
+ ```bash
59
+ # From project root
60
+ docker build -t test_env-env:latest -f server/Dockerfile .
61
+ ```
62
+
63
+ ## Deploying to Hugging Face Spaces
64
+
65
+ You can easily deploy your OpenEnv environment to Hugging Face Spaces using the `openenv push` command:
66
+
67
+ ```bash
68
+ # From the environment directory (where openenv.yaml is located)
69
+ openenv push
70
+
71
+ # Or specify options
72
+ openenv push --namespace my-org --private
73
+ ```
74
+
75
+ The `openenv push` command will:
76
+ 1. Validate that the directory is an OpenEnv environment (checks for `openenv.yaml`)
77
+ 2. Prepare a custom build for Hugging Face Docker space (enables web interface)
78
+ 3. Upload to Hugging Face (ensuring you're logged in)
79
+
80
+ ### Prerequisites
81
+
82
+ - Authenticate with Hugging Face: The command will prompt for login if not already authenticated
83
+
84
+ ### Options
85
+
86
+ - `--directory`, `-d`: Directory containing the OpenEnv environment (defaults to current directory)
87
+ - `--repo-id`, `-r`: Repository ID in format 'username/repo-name' (defaults to 'username/env-name' from openenv.yaml)
88
+ - `--base-image`, `-b`: Base Docker image to use (overrides Dockerfile FROM)
89
+ - `--private`: Deploy the space as private (default: public)
90
+
91
+ ### Examples
92
+
93
+ ```bash
94
+ # Push to your personal namespace (defaults to username/env-name from openenv.yaml)
95
+ openenv push
96
+
97
+ # Push to a specific repository
98
+ openenv push --repo-id my-org/my-env
99
+
100
+ # Push with a custom base image
101
+ openenv push --base-image ghcr.io/meta-pytorch/openenv-base:latest
102
+
103
+ # Push as a private space
104
+ openenv push --private
105
+
106
+ # Combine options
107
+ openenv push --repo-id my-org/my-env --base-image custom-base:latest --private
108
+ ```
109
+
110
+ After deployment, your space will be available at:
111
+ `https://huggingface.co/spaces/<repo-id>`
112
+
113
+ The deployed space includes:
114
+ - **Web Interface** at `/web` - Interactive UI for exploring the environment
115
+ - **API Documentation** at `/docs` - Full OpenAPI/Swagger interface
116
+ - **Health Check** at `/health` - Container health monitoring
117
+ - **WebSocket** at `/ws` - Persistent session endpoint for low-latency interactions
118
+
119
+ ## Environment Details
120
+
121
+ ### Action
122
+ **TestAction**: Contains a single field
123
+ - `message` (str) - The message to echo back
124
+
125
+ ### Observation
126
+ **TestObservation**: Contains the echo response and metadata
127
+ - `echoed_message` (str) - The message echoed back
128
+ - `message_length` (int) - Length of the message
129
+ - `reward` (float) - Reward based on message length (length × 0.1)
130
+ - `done` (bool) - Always False for echo environment
131
+ - `metadata` (dict) - Additional info like step count
132
+
133
+ ### Reward
134
+ The reward is calculated as: `message_length × 0.1`
135
+ - "Hi" → reward: 0.2
136
+ - "Hello, World!" → reward: 1.3
137
+ - Empty message → reward: 0.0
138
+
139
+ ## Advanced Usage
140
+
141
+ ### Connecting to an Existing Server
142
+
143
+ If you already have a Test Env environment server running, you can connect directly:
144
+
145
+ ```python
146
+ from test_env import TestEnv
147
+
148
+ # Connect to existing server
149
+ test_envenv = TestEnv(base_url="<ENV_HTTP_URL_HERE>")
150
+
151
+ # Use as normal
152
+ result = test_envenv.reset()
153
+ result = test_envenv.step(TestAction(message="Hello!"))
154
+ ```
155
+
156
+ Note: When connecting to an existing server, `test_envenv.close()` will NOT stop the server.
157
+
158
+ ### Using the Context Manager
159
+
160
+ The client supports context manager usage for automatic connection management:
161
+
162
+ ```python
163
+ from test_env import TestAction, TestEnv
164
+
165
+ # Connect with context manager (auto-connects and closes)
166
+ with TestEnv(base_url="http://localhost:8000") as env:
167
+ result = env.reset()
168
+ print(f"Reset: {result.observation.echoed_message}")
169
+ # Multiple steps with low latency
170
+ for msg in ["Hello", "World", "!"]:
171
+ result = env.step(TestAction(message=msg))
172
+ print(f"Echoed: {result.observation.echoed_message}")
173
+ ```
174
+
175
+ The client uses WebSocket connections for:
176
+ - **Lower latency**: No HTTP connection overhead per request
177
+ - **Persistent session**: Server maintains your environment state
178
+ - **Efficient for episodes**: Better for many sequential steps
179
+
180
+ ### Concurrent WebSocket Sessions
181
+
182
+ The server supports multiple concurrent WebSocket connections. To enable this,
183
+ modify `server/app.py` to use factory mode:
184
+
185
+ ```python
186
+ # In server/app.py - use factory mode for concurrent sessions
187
+ app = create_app(
188
+ TestEnvironment, # Pass class, not instance
189
+ TestAction,
190
+ TestObservation,
191
+ max_concurrent_envs=4, # Allow 4 concurrent sessions
192
+ )
193
+ ```
194
+
195
+ Then multiple clients can connect simultaneously:
196
+
197
+ ```python
198
+ from test_env import TestAction, TestEnv
199
+ from concurrent.futures import ThreadPoolExecutor
200
+
201
+ def run_episode(client_id: int):
202
+ with TestEnv(base_url="http://localhost:8000") as env:
203
+ result = env.reset()
204
+ for i in range(10):
205
+ result = env.step(TestAction(message=f"Client {client_id}, step {i}"))
206
+ return client_id, result.observation.message_length
207
+
208
+ # Run 4 episodes concurrently
209
+ with ThreadPoolExecutor(max_workers=4) as executor:
210
+ results = list(executor.map(run_episode, range(4)))
211
+ ```
212
+
213
+ ## Development & Testing
214
+
215
+ ### Direct Environment Testing
216
+
217
+ Test the environment logic directly without starting the HTTP server:
218
+
219
+ ```bash
220
+ # From the server directory
221
+ python3 server/test_env_environment.py
222
+ ```
223
+
224
+ This verifies that:
225
+ - Environment resets correctly
226
+ - Step executes actions properly
227
+ - State tracking works
228
+ - Rewards are calculated correctly
229
+
230
+ ### Running Locally
231
+
232
+ Run the server locally for development:
233
+
234
+ ```bash
235
+ uvicorn server.app:app --reload
236
+ ```
237
+
238
+ ## Project Structure
239
+
240
+ ```
241
+ test_env/
242
+ ├── .dockerignore # Docker build exclusions
243
+ ├── __init__.py # Module exports
244
+ ├── README.md # This file
245
+ ├── openenv.yaml # OpenEnv manifest
246
+ ├── pyproject.toml # Project metadata and dependencies
247
+ ├── uv.lock # Locked dependencies (generated)
248
+ ├── client.py # TestEnv client
249
+ ├── models.py # Action and Observation models
250
+ └── server/
251
+ ├── __init__.py # Server module exports
252
+ ├── test_env_environment.py # Core environment logic
253
+ ├── app.py # FastAPI application (HTTP + WebSocket endpoints)
254
+ └── Dockerfile # Container image definition
255
+ ```
temp_test_env/test_env/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Test Env Environment."""
8
+
9
+ from .client import TestEnv
10
+ from .models import TestAction, TestObservation
11
+
12
+ __all__ = [
13
+ "TestAction",
14
+ "TestObservation",
15
+ "TestEnv",
16
+ ]
temp_test_env/test_env/client.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Test Env Environment Client."""
8
+
9
+ from typing import Dict
10
+
11
+ from openenv.core import EnvClient
12
+ from openenv.core.client_types import StepResult
13
+ from openenv.core.env_server.types import State
14
+
15
+ from .models import TestAction, TestObservation
16
+
17
+
18
+ class TestEnv(
19
+ EnvClient[TestAction, TestObservation, State]
20
+ ):
21
+ """
22
+ Client for the Test Env Environment.
23
+
24
+ This client maintains a persistent WebSocket connection to the environment server,
25
+ enabling efficient multi-step interactions with lower latency.
26
+ Each client instance has its own dedicated environment session on the server.
27
+
28
+ Example:
29
+ >>> # Connect to a running server
30
+ >>> with TestEnv(base_url="http://localhost:8000") as client:
31
+ ... result = client.reset()
32
+ ... print(result.observation.echoed_message)
33
+ ...
34
+ ... result = client.step(TestAction(message="Hello!"))
35
+ ... print(result.observation.echoed_message)
36
+
37
+ Example with Docker:
38
+ >>> # Automatically start container and connect
39
+ >>> client = TestEnv.from_docker_image("test_env-env:latest")
40
+ >>> try:
41
+ ... result = client.reset()
42
+ ... result = client.step(TestAction(message="Test"))
43
+ ... finally:
44
+ ... client.close()
45
+ """
46
+
47
+ def _step_payload(self, action: TestAction) -> Dict:
48
+ """
49
+ Convert TestAction to JSON payload for step message.
50
+
51
+ Args:
52
+ action: TestAction instance
53
+
54
+ Returns:
55
+ Dictionary representation suitable for JSON encoding
56
+ """
57
+ return {
58
+ "message": action.message,
59
+ }
60
+
61
+ def _parse_result(self, payload: Dict) -> StepResult[TestObservation]:
62
+ """
63
+ Parse server response into StepResult[TestObservation].
64
+
65
+ Args:
66
+ payload: JSON response data from server
67
+
68
+ Returns:
69
+ StepResult with TestObservation
70
+ """
71
+ obs_data = payload.get("observation", {})
72
+ observation = TestObservation(
73
+ echoed_message=obs_data.get("echoed_message", ""),
74
+ message_length=obs_data.get("message_length", 0),
75
+ done=payload.get("done", False),
76
+ reward=payload.get("reward"),
77
+ metadata=obs_data.get("metadata", {}),
78
+ )
79
+
80
+ return StepResult(
81
+ observation=observation,
82
+ reward=payload.get("reward"),
83
+ done=payload.get("done", False),
84
+ )
85
+
86
+ def _parse_state(self, payload: Dict) -> State:
87
+ """
88
+ Parse server response into State object.
89
+
90
+ Args:
91
+ payload: JSON response from state request
92
+
93
+ Returns:
94
+ State object with episode_id and step_count
95
+ """
96
+ return State(
97
+ episode_id=payload.get("episode_id"),
98
+ step_count=payload.get("step_count", 0),
99
+ )
temp_test_env/test_env/models.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Data models for the Test Env Environment.
9
+
10
+ The test_env environment is a simple test environment that echoes back messages.
11
+ """
12
+
13
+ from openenv.core.env_server.types import Action, Observation
14
+ from pydantic import Field
15
+
16
+
17
+ class TestAction(Action):
18
+ """Action for the Test Env environment - just a message to echo."""
19
+
20
+ message: str = Field(..., description="Message to echo back")
21
+
22
+
23
+ class TestObservation(Observation):
24
+ """Observation from the Test Env environment - the echoed message."""
25
+
26
+ echoed_message: str = Field(default="", description="The echoed message")
27
+ message_length: int = Field(default=0, description="Length of the echoed message")
temp_test_env/test_env/openenv.yaml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: test_env
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
7
+
temp_test_env/test_env/pyproject.toml ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ [build-system]
8
+ requires = ["setuptools>=45", "wheel"]
9
+ build-backend = "setuptools.build_meta"
10
+
11
+ [project]
12
+ name = "openenv-test_env"
13
+ version = "0.1.0"
14
+ description = "Test Env environment for OpenEnv"
15
+ requires-python = ">=3.10"
16
+ dependencies = [
17
+ # Core OpenEnv runtime (provides FastAPI server + HTTP client types)
18
+ # install from github
19
+ # "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
20
+ "openenv-core[core]>=0.2.2",
21
+ # Environment-specific dependencies
22
+ # Add all dependencies needed for your environment here
23
+ # Examples:
24
+ # "numpy>=1.19.0",
25
+ # "torch>=2.0.0",
26
+ # "gymnasium>=0.29.0",
27
+ # "openspiel>=1.0.0",
28
+ # "smolagents>=1.22.0,<2",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=8.0.0",
34
+ "pytest-cov>=4.0.0",
35
+ ]
36
+
37
+ [project.scripts]
38
+ # Server entry point - enables running via: uv run --project . server
39
+ # or: python -m test_env.server.app
40
+ server = "test_env.server.app:main"
41
+
42
+ [tool.setuptools]
43
+ include-package-data = true
44
+ packages = ["test_env", "test_env.server"]
45
+ package-dir = { "test_env" = ".", "test_env.server" = "server" }
temp_test_env/test_env/server/Dockerfile ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ # Multi-stage build using openenv-base
8
+ # This Dockerfile is flexible and works for both:
9
+ # - In-repo environments (with local OpenEnv sources)
10
+ # - Standalone environments (with openenv from PyPI/Git)
11
+ # The build script (openenv build) handles context detection and sets appropriate build args.
12
+
13
+ ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
14
+ FROM ${BASE_IMAGE} AS builder
15
+
16
+ WORKDIR /app
17
+
18
+ # Ensure git is available (required for installing dependencies from VCS)
19
+ RUN apt-get update && \
20
+ apt-get install -y --no-install-recommends git && \
21
+ rm -rf /var/lib/apt/lists/*
22
+
23
+ # Build argument to control whether we're building standalone or in-repo
24
+ ARG BUILD_MODE=in-repo
25
+ ARG ENV_NAME=test_env
26
+
27
+ # Copy environment code (always at root of build context)
28
+ COPY . /app/env
29
+
30
+ # For in-repo builds, openenv is already vendored in the build context
31
+ # For standalone builds, openenv will be installed via pyproject.toml
32
+ WORKDIR /app/env
33
+
34
+ # Ensure uv is available (for local builds where base image lacks it)
35
+ RUN if ! command -v uv >/dev/null 2>&1; then \
36
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
37
+ mv /root/.local/bin/uv /usr/local/bin/uv && \
38
+ mv /root/.local/bin/uvx /usr/local/bin/uvx; \
39
+ fi
40
+
41
+ # Install dependencies using uv sync
42
+ # If uv.lock exists, use it; otherwise resolve on the fly
43
+ RUN --mount=type=cache,target=/root/.cache/uv \
44
+ if [ -f uv.lock ]; then \
45
+ uv sync --frozen --no-install-project --no-editable; \
46
+ else \
47
+ uv sync --no-install-project --no-editable; \
48
+ fi
49
+
50
+ RUN --mount=type=cache,target=/root/.cache/uv \
51
+ if [ -f uv.lock ]; then \
52
+ uv sync --frozen --no-editable; \
53
+ else \
54
+ uv sync --no-editable; \
55
+ fi
56
+
57
+ # Final runtime stage
58
+ FROM ${BASE_IMAGE}
59
+
60
+ WORKDIR /app
61
+
62
+ # Copy the virtual environment from builder
63
+ COPY --from=builder /app/env/.venv /app/.venv
64
+
65
+ # Copy the environment code
66
+ COPY --from=builder /app/env /app/env
67
+
68
+ # Set PATH to use the virtual environment
69
+ ENV PATH="/app/.venv/bin:$PATH"
70
+
71
+ # Set PYTHONPATH so imports work correctly
72
+ ENV PYTHONPATH="/app/env:$PYTHONPATH"
73
+
74
+ # Health check
75
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
76
+ CMD curl -f http://localhost:8000/health || exit 1
77
+
78
+ # Run the FastAPI server
79
+ # The module path is constructed to work with the /app/env structure
80
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
temp_test_env/test_env/server/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Test Env environment server components."""
8
+
9
+ from .test_env_environment import TestEnvironment
10
+
11
+ __all__ = ["TestEnvironment"]
temp_test_env/test_env/server/app.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ FastAPI application for the Test Env Environment.
9
+
10
+ This module creates an HTTP server that exposes the TestEnvironment
11
+ over HTTP and WebSocket endpoints, compatible with EnvClient.
12
+
13
+ Endpoints:
14
+ - POST /reset: Reset the environment
15
+ - POST /step: Execute an action
16
+ - GET /state: Get current environment state
17
+ - GET /schema: Get action/observation schemas
18
+ - WS /ws: WebSocket endpoint for persistent sessions
19
+
20
+ Usage:
21
+ # Development (with auto-reload):
22
+ uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
23
+
24
+ # Production:
25
+ uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
26
+
27
+ # Or run directly:
28
+ python -m server.app
29
+ """
30
+
31
+ try:
32
+ from openenv.core.env_server.http_server import create_app
33
+ except Exception as e: # pragma: no cover
34
+ raise ImportError(
35
+ "openenv is required for the web interface. Install dependencies with '\n uv sync\n'"
36
+ ) from e
37
+
38
+ try:
39
+ from ..models import TestAction, TestObservation
40
+ from .test_env_environment import TestEnvironment
41
+ except ModuleNotFoundError:
42
+ from models import TestAction, TestObservation
43
+ from server.test_env_environment import TestEnvironment
44
+
45
+
46
+ # Create the app with web interface and README integration
47
+ app = create_app(
48
+ TestEnvironment,
49
+ TestAction,
50
+ TestObservation,
51
+ env_name="test_env",
52
+ max_concurrent_envs=1, # increase this number to allow more concurrent WebSocket sessions
53
+ )
54
+
55
+
56
+ def main(host: str = "0.0.0.0", port: int = 8000):
57
+ """
58
+ Entry point for direct execution via uv run or python -m.
59
+
60
+ This function enables running the server without Docker:
61
+ uv run --project . server
62
+ uv run --project . server --port 8001
63
+ python -m test_env.server.app
64
+
65
+ Args:
66
+ host: Host address to bind to (default: "0.0.0.0")
67
+ port: Port number to listen on (default: 8000)
68
+
69
+ For production deployments, consider using uvicorn directly with
70
+ multiple workers:
71
+ uvicorn test_env.server.app:app --workers 4
72
+ """
73
+ import uvicorn
74
+
75
+ uvicorn.run(app, host=host, port=port)
76
+
77
+
78
+ if __name__ == "__main__":
79
+ import argparse
80
+
81
+ parser = argparse.ArgumentParser()
82
+ parser.add_argument("--port", type=int, default=8000)
83
+ args = parser.parse_args()
84
+ main(port=args.port)
temp_test_env/test_env/server/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ openenv[core]>=0.2.0
2
+ fastapi>=0.115.0
3
+ uvicorn>=0.24.0
4
+
5
+
6
+
temp_test_env/test_env/server/test_env_environment.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Test Env Environment Implementation.
9
+
10
+ A simple test environment that echoes back messages sent to it.
11
+ Perfect for testing HTTP server infrastructure.
12
+ """
13
+
14
+ from uuid import uuid4
15
+
16
+ from openenv.core.env_server.interfaces import Environment
17
+ from openenv.core.env_server.types import State
18
+
19
+ try:
20
+ from ..models import TestAction, TestObservation
21
+ except ImportError:
22
+ from models import TestAction, TestObservation
23
+
24
+
25
+ class TestEnvironment(Environment):
26
+ """
27
+ A simple echo environment that echoes back messages.
28
+
29
+ This environment is designed for testing the HTTP server infrastructure.
30
+ It maintains minimal state and simply echoes back whatever message it receives.
31
+
32
+ Example:
33
+ >>> env = TestEnvironment()
34
+ >>> obs = env.reset()
35
+ >>> print(obs.echoed_message) # "Test Env environment ready!"
36
+ >>>
37
+ >>> obs = env.step(TestAction(message="Hello"))
38
+ >>> print(obs.echoed_message) # "Hello"
39
+ >>> print(obs.message_length) # 5
40
+ """
41
+
42
+ # Enable concurrent WebSocket sessions.
43
+ # Set to True if your environment isolates state between instances.
44
+ # When True, multiple WebSocket clients can connect simultaneously, each
45
+ # getting their own environment instance (when using factory mode in app.py).
46
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
47
+
48
+ def __init__(self):
49
+ """Initialize the test_env environment."""
50
+ self._state = State(episode_id=str(uuid4()), step_count=0)
51
+ self._reset_count = 0
52
+
53
+ def reset(self) -> TestObservation:
54
+ """
55
+ Reset the environment.
56
+
57
+ Returns:
58
+ TestObservation with a ready message
59
+ """
60
+ self._state = State(episode_id=str(uuid4()), step_count=0)
61
+ self._reset_count += 1
62
+
63
+ return TestObservation(
64
+ echoed_message="Test Env environment ready!",
65
+ message_length=0,
66
+ done=False,
67
+ reward=0.0,
68
+ )
69
+
70
+ def step(self, action: TestAction) -> TestObservation: # type: ignore[override]
71
+ """
72
+ Execute a step in the environment by echoing the message.
73
+
74
+ Args:
75
+ action: TestAction containing the message to echo
76
+
77
+ Returns:
78
+ TestObservation with the echoed message and its length
79
+ """
80
+ self._state.step_count += 1
81
+
82
+ message = action.message
83
+ length = len(message)
84
+
85
+ # Simple reward: longer messages get higher rewards
86
+ reward = length * 0.1
87
+
88
+ return TestObservation(
89
+ echoed_message=message,
90
+ message_length=length,
91
+ done=False,
92
+ reward=reward,
93
+ metadata={"original_message": message, "step": self._state.step_count},
94
+ )
95
+
96
+ @property
97
+ def state(self) -> State:
98
+ """
99
+ Get the current environment state.
100
+
101
+ Returns:
102
+ Current State with episode_id and step_count
103
+ """
104
+ return self._state
temp_test_env/test_env/uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
tests/run_integration_test.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quick integration test for the TriageFlow environment."""
2
+ import sys
3
+ sys.path.insert(0, '.')
4
+
5
+ from triage_flow.environment import TriageEnvironment
6
+ from triage_flow.graders import grade_task
7
+ from models import TriageAction, ActionType, PriorityLevel, InfoField
8
+
9
+ def test_basic_triage():
10
+ env = TriageEnvironment()
11
+ obs = env.reset(task_name="basic-triage")
12
+ pid = obs.current_patient["patient_id"] if obs.current_patient else "none"
13
+ print(f"Reset OK - patients: {obs.queue_length}, current: {pid}")
14
+
15
+ # Correct assignments for all 3 patients
16
+ for pid, p in [("P001", PriorityLevel.IMMEDIATE), ("P002", PriorityLevel.NON_URGENT), ("P003", PriorityLevel.URGENT)]:
17
+ action = TriageAction(action_type=ActionType.ASSIGN_PRIORITY, patient_id=pid, priority_level=p)
18
+ obs = env.step(action)
19
+ print(f" Assigned {pid}={p.value} -> reward={obs.reward}, done={obs.done}")
20
+
21
+ state = env.state
22
+ print(f"State: assignments={state.assignments}, cleared={state.queue_cleared}")
23
+ score = grade_task("basic-triage", state.model_dump())
24
+ print(f"Grader score: {score}")
25
+ assert score == 1.0, f"Expected 1.0, got {score}"
26
+ print("BASIC TRIAGE: PASSED\n")
27
+
28
+ def test_wrong_assignments():
29
+ env = TriageEnvironment()
30
+ env.reset(task_name="basic-triage")
31
+ for pid, p in [("P001", PriorityLevel.NON_URGENT), ("P002", PriorityLevel.IMMEDIATE), ("P003", PriorityLevel.NON_URGENT)]:
32
+ env.step(TriageAction(action_type=ActionType.ASSIGN_PRIORITY, patient_id=pid, priority_level=p))
33
+ score = grade_task("basic-triage", env.state.model_dump())
34
+ print(f"All wrong score: {score}")
35
+ assert score == 0.0, f"Expected 0.0, got {score}"
36
+ print("WRONG ASSIGNMENTS: PASSED\n")
37
+
38
+ def test_incomplete_records():
39
+ env = TriageEnvironment()
40
+ obs = env.reset(task_name="incomplete-records-triage")
41
+ pid = obs.current_patient["patient_id"] if obs.current_patient else "none"
42
+ print(f"Incomplete records - patients: {obs.queue_length}, current: {pid}")
43
+ print(f" Missing fields: {obs.missing_fields}")
44
+
45
+ # Request info for P101 (vitals missing)
46
+ action = TriageAction(action_type=ActionType.REQUEST_INFO, patient_id="P101", info_field=InfoField.VITALS)
47
+ obs = env.step(action)
48
+ print(f" Info request -> reward={obs.reward}")
49
+
50
+ # Assign correct priorities
51
+ for pid, p in [("P101", PriorityLevel.IMMEDIATE), ("P102", PriorityLevel.LESS_URGENT),
52
+ ("P103", PriorityLevel.URGENT), ("P104", PriorityLevel.NON_URGENT)]:
53
+ obs = env.step(TriageAction(action_type=ActionType.ASSIGN_PRIORITY, patient_id=pid, priority_level=p))
54
+
55
+ score = grade_task("incomplete-records-triage", env.state.model_dump())
56
+ print(f"Incomplete records score: {score}")
57
+ assert 0.5 <= score <= 1.0, f"Expected good score, got {score}"
58
+ print("INCOMPLETE RECORDS: PASSED\n")
59
+
60
+ def test_mass_casualty():
61
+ env = TriageEnvironment()
62
+ obs = env.reset(task_name="mass-casualty-triage")
63
+ print(f"Mass casualty - patients: {obs.queue_length}")
64
+
65
+ # Assign all 8 patients with correct priorities
66
+ correct = [
67
+ ("P201", PriorityLevel.IMMEDIATE), ("P202", PriorityLevel.IMMEDIATE),
68
+ ("P203", PriorityLevel.LESS_URGENT), ("P204", PriorityLevel.LESS_URGENT),
69
+ ("P205", PriorityLevel.URGENT), ("P206", PriorityLevel.NON_URGENT),
70
+ ("P207", PriorityLevel.URGENT), ("P208", PriorityLevel.NON_URGENT),
71
+ ]
72
+ for pid, p in correct:
73
+ obs = env.step(TriageAction(action_type=ActionType.ASSIGN_PRIORITY, patient_id=pid, priority_level=p))
74
+
75
+ score = grade_task("mass-casualty-triage", env.state.model_dump())
76
+ print(f"Mass casualty perfect score: {score}")
77
+ assert score >= 0.8, f"Expected high score, got {score}"
78
+ print("MASS CASUALTY: PASSED\n")
79
+
80
+ def test_grader_variance():
81
+ """Different behaviors must produce different scores."""
82
+ scores = set()
83
+
84
+ # Perfect
85
+ env = TriageEnvironment()
86
+ env.reset(task_name="basic-triage")
87
+ for pid, p in [("P001", PriorityLevel.IMMEDIATE), ("P002", PriorityLevel.NON_URGENT), ("P003", PriorityLevel.URGENT)]:
88
+ env.step(TriageAction(action_type=ActionType.ASSIGN_PRIORITY, patient_id=pid, priority_level=p))
89
+ scores.add(grade_task("basic-triage", env.state.model_dump()))
90
+
91
+ # All wrong
92
+ env = TriageEnvironment()
93
+ env.reset(task_name="basic-triage")
94
+ for pid, p in [("P001", PriorityLevel.NON_URGENT), ("P002", PriorityLevel.IMMEDIATE), ("P003", PriorityLevel.NON_URGENT)]:
95
+ env.step(TriageAction(action_type=ActionType.ASSIGN_PRIORITY, patient_id=pid, priority_level=p))
96
+ scores.add(grade_task("basic-triage", env.state.model_dump()))
97
+
98
+ # Partial
99
+ env = TriageEnvironment()
100
+ env.reset(task_name="basic-triage")
101
+ for pid, p in [("P001", PriorityLevel.IMMEDIATE), ("P002", PriorityLevel.IMMEDIATE), ("P003", PriorityLevel.IMMEDIATE)]:
102
+ env.step(TriageAction(action_type=ActionType.ASSIGN_PRIORITY, patient_id=pid, priority_level=p))
103
+ scores.add(grade_task("basic-triage", env.state.model_dump()))
104
+
105
+ # No actions
106
+ env = TriageEnvironment()
107
+ env.reset(task_name="basic-triage")
108
+ scores.add(grade_task("basic-triage", env.state.model_dump()))
109
+
110
+ print(f"Grader variance - unique scores: {scores}")
111
+ assert len(scores) >= 3, f"Expected 3+ different scores, got {scores}"
112
+ print("GRADER VARIANCE: PASSED\n")
113
+
114
+ def test_server_app_import():
115
+ """Test that the server app can be imported."""
116
+ from server.app import app
117
+ print(f"Server app imported: {app.title}")
118
+ print("SERVER IMPORT: PASSED\n")
119
+
120
+ if __name__ == "__main__":
121
+ test_basic_triage()
122
+ test_wrong_assignments()
123
+ test_incomplete_records()
124
+ test_mass_casualty()
125
+ test_grader_variance()
126
+ test_server_app_import()
127
+ print("=" * 60)
128
+ print("ALL INTEGRATION TESTS PASSED!")
129
+ print("=" * 60)
tests/test_endpoints.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test HTTP endpoints against running server."""
2
+ import httpx
3
+
4
+ BASE = "http://127.0.0.1:8000"
5
+
6
+ # Health
7
+ r = httpx.get(f"{BASE}/health")
8
+ print(f"Health: {r.status_code} {r.text}")
9
+
10
+ # Reset with empty body (required for pre-submission validator)
11
+ r = httpx.post(f"{BASE}/reset", json={})
12
+ print(f"Reset (empty): {r.status_code}")
13
+
14
+ # Reset with task_name
15
+ r = httpx.post(f"{BASE}/reset", json={"task_name": "basic-triage"})
16
+ data = r.json()
17
+ obs = data.get("observation", data)
18
+ pid = obs.get("current_patient", {}).get("patient_id", "?")
19
+ print(f"Reset (basic-triage): {r.status_code}, patient={pid}")
20
+
21
+ # Step
22
+ r = httpx.post(f"{BASE}/step", json={
23
+ "action_type": "assign_priority",
24
+ "patient_id": "P001",
25
+ "priority_level": "immediate"
26
+ })
27
+ data = r.json()
28
+ print(f"Step: {r.status_code}, reward={data.get('reward', '?')}, done={data.get('done', '?')}")
29
+
30
+ # State
31
+ r = httpx.get(f"{BASE}/state")
32
+ state = r.json()
33
+ print(f"State: {r.status_code}, assignments={state.get('assignments', {})}")
34
+
35
+ print("\nALL ENDPOINT TESTS PASSED!")
tests/test_environment.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: test_environment.py
3
+ Purpose: Test core environment behavior — reset, step, state.
4
+ Part of: Medical Triage Assistant — OpenEnv Round 1
5
+ Author: Team Squirrel
6
+ """
7
+
8
+ import sys
9
+ import os
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+
12
+ import pytest
13
+ from triage_flow.environment import TriageEnvironment
14
+ from models import TriageAction, ActionType, PriorityLevel, InfoField
15
+
16
+
17
+ class TestReset:
18
+ """Test reset() behavior."""
19
+
20
+ def test_reset_returns_observation(self):
21
+ """reset() should return a TriageObservation."""
22
+ env = TriageEnvironment()
23
+ obs = env.reset(task_name="basic-triage")
24
+ assert obs.done is False
25
+ assert obs.current_patient is not None
26
+ assert obs.queue_length > 0
27
+
28
+ def test_reset_basic_triage_has_3_patients(self):
29
+ """Basic triage should have 3 patients."""
30
+ env = TriageEnvironment()
31
+ obs = env.reset(task_name="basic-triage")
32
+ assert obs.queue_length == 3
33
+
34
+ def test_reset_clears_state(self):
35
+ """reset() should clear all previous state."""
36
+ env = TriageEnvironment()
37
+ obs = env.reset(task_name="basic-triage")
38
+
39
+ # Take an action
40
+ action = TriageAction(
41
+ action_type=ActionType.ASSIGN_PRIORITY,
42
+ patient_id="P001",
43
+ priority_level=PriorityLevel.IMMEDIATE
44
+ )
45
+ env.step(action)
46
+
47
+ # Reset should clear
48
+ obs = env.reset(task_name="basic-triage")
49
+ assert env.state.step_count == 0
50
+ assert env.state.assignments == {}
51
+
52
+ def test_reset_all_tasks(self):
53
+ """All 3 tasks should reset successfully."""
54
+ env = TriageEnvironment()
55
+ for task in ["basic-triage", "incomplete-records-triage", "mass-casualty-triage"]:
56
+ obs = env.reset(task_name=task)
57
+ assert obs.done is False
58
+ assert obs.current_patient is not None
59
+
60
+
61
+ class TestStep:
62
+ """Test step() behavior."""
63
+
64
+ def test_step_valid_action(self):
65
+ """step() with valid action should return updated observation."""
66
+ env = TriageEnvironment()
67
+ env.reset(task_name="basic-triage")
68
+
69
+ action = TriageAction(
70
+ action_type=ActionType.ASSIGN_PRIORITY,
71
+ patient_id="P001",
72
+ priority_level=PriorityLevel.IMMEDIATE,
73
+ )
74
+ obs = env.step(action)
75
+ assert obs.reward is not None
76
+ assert obs.step_number == 1
77
+
78
+ def test_step_correct_assignment_positive_reward(self):
79
+ """Correct priority assignment should give positive reward."""
80
+ env = TriageEnvironment()
81
+ env.reset(task_name="basic-triage")
82
+
83
+ # P001 ground truth is IMMEDIATE
84
+ action = TriageAction(
85
+ action_type=ActionType.ASSIGN_PRIORITY,
86
+ patient_id="P001",
87
+ priority_level=PriorityLevel.IMMEDIATE,
88
+ )
89
+ obs = env.step(action)
90
+ assert obs.reward > 0
91
+
92
+ def test_step_incorrect_assignment_negative_reward(self):
93
+ """Incorrect priority assignment should give negative or zero reward."""
94
+ env = TriageEnvironment()
95
+ env.reset(task_name="basic-triage")
96
+
97
+ # P001 ground truth is IMMEDIATE, assigning NON_URGENT is wrong
98
+ action = TriageAction(
99
+ action_type=ActionType.ASSIGN_PRIORITY,
100
+ patient_id="P001",
101
+ priority_level=PriorityLevel.NON_URGENT,
102
+ )
103
+ obs = env.step(action)
104
+ assert obs.reward < 0
105
+
106
+ def test_step_counts_increment(self):
107
+ """Step count should increment with each step."""
108
+ env = TriageEnvironment()
109
+ env.reset(task_name="basic-triage")
110
+
111
+ action = TriageAction(action_type=ActionType.ADVANCE_QUEUE)
112
+ obs = env.step(action)
113
+ assert obs.step_number == 1
114
+
115
+ obs = env.step(action)
116
+ assert obs.step_number == 2
117
+
118
+ def test_done_when_all_assigned(self):
119
+ """Episode should end when all patients are assigned."""
120
+ env = TriageEnvironment()
121
+ env.reset(task_name="basic-triage")
122
+
123
+ # Assign all 3 patients
124
+ for pid, priority in [
125
+ ("P001", PriorityLevel.IMMEDIATE),
126
+ ("P002", PriorityLevel.NON_URGENT),
127
+ ("P003", PriorityLevel.URGENT),
128
+ ]:
129
+ action = TriageAction(
130
+ action_type=ActionType.ASSIGN_PRIORITY,
131
+ patient_id=pid,
132
+ priority_level=priority,
133
+ )
134
+ obs = env.step(action)
135
+
136
+ assert obs.done is True
137
+
138
+
139
+ class TestState:
140
+ """Test state() behavior."""
141
+
142
+ def test_state_serializable(self):
143
+ """state() should return serializable data."""
144
+ env = TriageEnvironment()
145
+ env.reset(task_name="basic-triage")
146
+ state = env.state
147
+ d = state.model_dump()
148
+ assert isinstance(d, dict)
149
+ assert "patients" in d
150
+ assert "assignments" in d
151
+
152
+ def test_state_tracks_assignments(self):
153
+ """state() should reflect assignments made."""
154
+ env = TriageEnvironment()
155
+ env.reset(task_name="basic-triage")
156
+
157
+ action = TriageAction(
158
+ action_type=ActionType.ASSIGN_PRIORITY,
159
+ patient_id="P001",
160
+ priority_level=PriorityLevel.IMMEDIATE,
161
+ )
162
+ env.step(action)
163
+
164
+ state = env.state
165
+ assert "P001" in state.assignments
166
+ assert state.assignments["P001"] == "immediate"
tests/test_graders.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: test_graders.py
3
+ Purpose: Test deterministic graders produce varying scores for different inputs.
4
+ Part of: Medical Triage Assistant — OpenEnv Round 1
5
+ Author: Team Squirrel
6
+ """
7
+
8
+ import sys
9
+ import os
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+
12
+ import pytest
13
+ from triage_flow.graders import grade_task
14
+ from triage_flow.environment import TriageEnvironment
15
+ from models import TriageAction, ActionType, PriorityLevel
16
+
17
+
18
+ class TestGraderBasicTriage:
19
+ """Test grader for basic-triage task."""
20
+
21
+ def test_perfect_score(self):
22
+ """All correct assignments should score 1.0."""
23
+ env = TriageEnvironment()
24
+ env.reset(task_name="basic-triage")
25
+
26
+ for pid, priority in [
27
+ ("P001", PriorityLevel.IMMEDIATE),
28
+ ("P002", PriorityLevel.NON_URGENT),
29
+ ("P003", PriorityLevel.URGENT),
30
+ ]:
31
+ env.step(TriageAction(
32
+ action_type=ActionType.ASSIGN_PRIORITY,
33
+ patient_id=pid,
34
+ priority_level=priority,
35
+ ))
36
+
37
+ state = env.state.model_dump()
38
+ score = grade_task("basic-triage", state)
39
+ assert score == 1.0
40
+
41
+ def test_zero_score(self):
42
+ """All wrong assignments should score 0.0."""
43
+ env = TriageEnvironment()
44
+ env.reset(task_name="basic-triage")
45
+
46
+ # Deliberately wrong assignments
47
+ for pid, priority in [
48
+ ("P001", PriorityLevel.NON_URGENT), # Should be IMMEDIATE
49
+ ("P002", PriorityLevel.IMMEDIATE), # Should be NON_URGENT
50
+ ("P003", PriorityLevel.NON_URGENT), # Should be URGENT
51
+ ]:
52
+ env.step(TriageAction(
53
+ action_type=ActionType.ASSIGN_PRIORITY,
54
+ patient_id=pid,
55
+ priority_level=priority,
56
+ ))
57
+
58
+ state = env.state.model_dump()
59
+ score = grade_task("basic-triage", state)
60
+ assert score == 0.0
61
+
62
+ def test_partial_score(self):
63
+ """One correct out of three should score ~0.33."""
64
+ env = TriageEnvironment()
65
+ env.reset(task_name="basic-triage")
66
+
67
+ for pid, priority in [
68
+ ("P001", PriorityLevel.IMMEDIATE), # Correct
69
+ ("P002", PriorityLevel.IMMEDIATE), # Wrong
70
+ ("P003", PriorityLevel.NON_URGENT), # Wrong
71
+ ]:
72
+ env.step(TriageAction(
73
+ action_type=ActionType.ASSIGN_PRIORITY,
74
+ patient_id=pid,
75
+ priority_level=priority,
76
+ ))
77
+
78
+ state = env.state.model_dump()
79
+ score = grade_task("basic-triage", state)
80
+ assert 0.3 <= score <= 0.35 # ~0.33
81
+
82
+ def test_no_assignments_scores_zero(self):
83
+ """No assignments should score 0.0."""
84
+ env = TriageEnvironment()
85
+ env.reset(task_name="basic-triage")
86
+ state = env.state.model_dump()
87
+ score = grade_task("basic-triage", state)
88
+ assert score == 0.0
89
+
90
+
91
+ class TestGraderDeterminism:
92
+ """Test that graders are deterministic."""
93
+
94
+ def test_same_state_same_score(self):
95
+ """Same state should always produce same score."""
96
+ env = TriageEnvironment()
97
+ env.reset(task_name="basic-triage")
98
+
99
+ env.step(TriageAction(
100
+ action_type=ActionType.ASSIGN_PRIORITY,
101
+ patient_id="P001",
102
+ priority_level=PriorityLevel.IMMEDIATE,
103
+ ))
104
+
105
+ state = env.state.model_dump()
106
+ score1 = grade_task("basic-triage", state)
107
+ score2 = grade_task("basic-triage", state)
108
+ assert score1 == score2
109
+
110
+
111
+ class TestGraderVariance:
112
+ """Test that graders produce different scores for different behaviors."""
113
+
114
+ def test_different_behaviors_different_scores(self):
115
+ """Different action sequences should produce different scores."""
116
+ scores = []
117
+
118
+ # Perfect run
119
+ env = TriageEnvironment()
120
+ env.reset(task_name="basic-triage")
121
+ for pid, p in [("P001", PriorityLevel.IMMEDIATE), ("P002", PriorityLevel.NON_URGENT), ("P003", PriorityLevel.URGENT)]:
122
+ env.step(TriageAction(action_type=ActionType.ASSIGN_PRIORITY, patient_id=pid, priority_level=p))
123
+ scores.append(grade_task("basic-triage", env.state.model_dump()))
124
+
125
+ # All wrong
126
+ env = TriageEnvironment()
127
+ env.reset(task_name="basic-triage")
128
+ for pid, p in [("P001", PriorityLevel.NON_URGENT), ("P002", PriorityLevel.IMMEDIATE), ("P003", PriorityLevel.NON_URGENT)]:
129
+ env.step(TriageAction(action_type=ActionType.ASSIGN_PRIORITY, patient_id=pid, priority_level=p))
130
+ scores.append(grade_task("basic-triage", env.state.model_dump()))
131
+
132
+ # Partial
133
+ env = TriageEnvironment()
134
+ env.reset(task_name="basic-triage")
135
+ for pid, p in [("P001", PriorityLevel.IMMEDIATE), ("P002", PriorityLevel.IMMEDIATE), ("P003", PriorityLevel.IMMEDIATE)]:
136
+ env.step(TriageAction(action_type=ActionType.ASSIGN_PRIORITY, patient_id=pid, priority_level=p))
137
+ scores.append(grade_task("basic-triage", env.state.model_dump()))
138
+
139
+ # No actions
140
+ env = TriageEnvironment()
141
+ env.reset(task_name="basic-triage")
142
+ scores.append(grade_task("basic-triage", env.state.model_dump()))
143
+
144
+ # Verify we get at least 3 different scores
145
+ unique_scores = set(scores)
146
+ assert len(unique_scores) >= 3, f"Expected varying scores, got: {scores}"
tests/test_http_flow.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Full HTTP flow test: reset -> step -> step -> step -> state -> grader score."""
2
+ import httpx
3
+
4
+ BASE = "http://127.0.0.1:8000"
5
+
6
+ # Reset
7
+ r = httpx.post(f"{BASE}/reset", json={"task_name": "basic-triage"})
8
+ assert r.status_code == 200, f"Reset failed: {r.status_code}"
9
+ data = r.json()
10
+ obs = data.get("observation", data)
11
+ pid = obs.get("current_patient", {}).get("patient_id", "?")
12
+ print(f"1. Reset OK: status={r.status_code}, patient={pid}")
13
+
14
+ # Step 1: assign P001 as IMMEDIATE (correct)
15
+ r = httpx.post(f"{BASE}/step", json={"action": {
16
+ "action_type": "assign_priority",
17
+ "patient_id": "P001",
18
+ "priority_level": "immediate"
19
+ }})
20
+ assert r.status_code == 200, f"Step 1 failed: {r.status_code} {r.text}"
21
+ data = r.json()
22
+ print(f"2. Step 1: reward={data.get('reward')}, done={data.get('done')}")
23
+
24
+ # Step 2: assign P002 as NON_URGENT (correct)
25
+ r = httpx.post(f"{BASE}/step", json={"action": {
26
+ "action_type": "assign_priority",
27
+ "patient_id": "P002",
28
+ "priority_level": "non_urgent"
29
+ }})
30
+ assert r.status_code == 200, f"Step 2 failed: {r.status_code}"
31
+ data = r.json()
32
+ print(f"3. Step 2: reward={data.get('reward')}, done={data.get('done')}")
33
+
34
+ # Step 3: assign P003 as URGENT (correct)
35
+ r = httpx.post(f"{BASE}/step", json={"action": {
36
+ "action_type": "assign_priority",
37
+ "patient_id": "P003",
38
+ "priority_level": "urgent"
39
+ }})
40
+ assert r.status_code == 200, f"Step 3 failed: {r.status_code}"
41
+ data = r.json()
42
+ print(f"4. Step 3: reward={data.get('reward')}, done={data.get('done')}")
43
+ assert data.get("done") == True, "Should be done after all patients assigned"
44
+
45
+ # State
46
+ r = httpx.get(f"{BASE}/state")
47
+ assert r.status_code == 200, f"State failed: {r.status_code}"
48
+ state = r.json()
49
+ print(f"5. State: assignments={state.get('assignments')}")
50
+
51
+ # Verify via grader
52
+ import sys; sys.path.insert(0, ".")
53
+ from triage_flow.graders import grade_task
54
+ score = grade_task("basic-triage", state)
55
+ print(f"6. Grader score: {score}")
56
+ assert score == 1.0, f"Expected 1.0, got {score}"
57
+
58
+ print("\n=== FULL HTTP FLOW TEST PASSED ===")
tests/test_inference_logging.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test that inference.py produces the exact stdout format required.
3
+ Runs the environment directly (no LLM needed) with mock actions to verify logging format.
4
+ """
5
+ import sys
6
+ import os
7
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8
+
9
+ from triage_flow.environment import TriageEnvironment
10
+ from triage_flow.graders import grade_task
11
+ from models import TriageAction, ActionType, PriorityLevel
12
+
13
+ # Test the logging functions from inference.py
14
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
15
+ from inference import log_start, log_step, log_end
16
+
17
+ import io
18
+ from contextlib import redirect_stdout
19
+
20
+ # Capture stdout
21
+ f = io.StringIO()
22
+ with redirect_stdout(f):
23
+ log_start(task="basic-triage", env="triage-flow", model="test-model")
24
+
25
+ # Simulate a 3-step episode
26
+ log_step(step=1, action="assign_priority(P001,immediate)", reward=0.50, done=False, error=None)
27
+ log_step(step=2, action="assign_priority(P002,non_urgent)", reward=0.10, done=False, error=None)
28
+ log_step(step=3, action="assign_priority(P003,urgent)", reward=0.50, done=True, error=None)
29
+
30
+ log_end(success=True, steps=3, score=1.00, rewards=[0.50, 0.10, 0.50])
31
+
32
+ output = f.getvalue()
33
+ lines = output.strip().split("\n")
34
+
35
+ print("=== CAPTURED STDOUT ===")
36
+ for line in lines:
37
+ print(repr(line))
38
+ print()
39
+
40
+ # Validate format
41
+ assert lines[0].startswith("[START]"), f"Line 0 should start with [START]: {lines[0]}"
42
+ assert "task=basic-triage" in lines[0], f"Missing task: {lines[0]}"
43
+ assert "env=triage-flow" in lines[0], f"Missing env: {lines[0]}"
44
+ assert "model=test-model" in lines[0], f"Missing model: {lines[0]}"
45
+
46
+ for i in range(1, 4):
47
+ assert lines[i].startswith("[STEP]"), f"Line {i} should start with [STEP]: {lines[i]}"
48
+ assert f"step={i}" in lines[i], f"Missing step={i}: {lines[i]}"
49
+ assert "reward=" in lines[i], f"Missing reward: {lines[i]}"
50
+ assert "done=" in lines[i], f"Missing done: {lines[i]}"
51
+ assert "error=" in lines[i], f"Missing error: {lines[i]}"
52
+
53
+ # Check [STEP] format details
54
+ assert "done=false" in lines[1], f"done should be lowercase: {lines[1]}"
55
+ assert "done=true" in lines[3], f"done should be lowercase: {lines[3]}"
56
+ assert "error=null" in lines[1], f"error should be 'null': {lines[1]}"
57
+ assert "reward=0.50" in lines[1], f"reward should be 2 decimal: {lines[1]}"
58
+
59
+ # Check [END]
60
+ assert lines[4].startswith("[END]"), f"Line 4 should start with [END]: {lines[4]}"
61
+ assert "success=true" in lines[4], f"success should be lowercase: {lines[4]}"
62
+ assert "steps=3" in lines[4], f"Missing steps: {lines[4]}"
63
+ assert "score=1.00" in lines[4], f"score should be 2 decimal: {lines[4]}"
64
+ assert "rewards=0.50,0.10,0.50" in lines[4], f"rewards format wrong: {lines[4]}"
65
+
66
+ print("=== ALL FORMAT CHECKS PASSED ===")
tests/test_models.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: test_models.py
3
+ Purpose: Test all Pydantic models and enums validate correctly.
4
+ Part of: Medical Triage Assistant — OpenEnv Round 1
5
+ Author: Team Squirrel
6
+ """
7
+
8
+ import sys
9
+ import os
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+
12
+ import pytest
13
+ from models import (
14
+ PriorityLevel,
15
+ ActionType,
16
+ InfoField,
17
+ PatientRecord,
18
+ TriageAction,
19
+ TriageObservation,
20
+ TriageState,
21
+ )
22
+
23
+
24
+ class TestEnums:
25
+ """Test all enum definitions."""
26
+
27
+ def test_priority_levels(self):
28
+ """Verify all 4 priority levels exist and have correct values."""
29
+ assert PriorityLevel.IMMEDIATE.value == "immediate"
30
+ assert PriorityLevel.URGENT.value == "urgent"
31
+ assert PriorityLevel.LESS_URGENT.value == "less_urgent"
32
+ assert PriorityLevel.NON_URGENT.value == "non_urgent"
33
+
34
+ def test_action_types(self):
35
+ """Verify all 5 action types exist."""
36
+ assert len(ActionType) == 5
37
+ assert ActionType.ASSIGN_PRIORITY.value == "assign_priority"
38
+ assert ActionType.REQUEST_INFO.value == "request_info"
39
+ assert ActionType.ESCALATE.value == "escalate"
40
+ assert ActionType.DEFER.value == "defer"
41
+ assert ActionType.ADVANCE_QUEUE.value == "advance_queue"
42
+
43
+ def test_info_fields(self):
44
+ """Verify all 5 info fields exist."""
45
+ assert len(InfoField) == 5
46
+ assert InfoField.VITALS.value == "vitals"
47
+
48
+
49
+ class TestTriageAction:
50
+ """Test action model validation."""
51
+
52
+ def test_valid_assign_priority(self):
53
+ """Test creating a valid assign_priority action."""
54
+ action = TriageAction(
55
+ action_type=ActionType.ASSIGN_PRIORITY,
56
+ patient_id="P001",
57
+ priority_level=PriorityLevel.IMMEDIATE,
58
+ )
59
+ assert action.action_type == ActionType.ASSIGN_PRIORITY
60
+ assert action.patient_id == "P001"
61
+ assert action.priority_level == PriorityLevel.IMMEDIATE
62
+
63
+ def test_valid_request_info(self):
64
+ """Test creating a valid request_info action."""
65
+ action = TriageAction(
66
+ action_type=ActionType.REQUEST_INFO,
67
+ patient_id="P001",
68
+ info_field=InfoField.VITALS,
69
+ )
70
+ assert action.action_type == ActionType.REQUEST_INFO
71
+ assert action.info_field == InfoField.VITALS
72
+
73
+ def test_valid_advance_queue(self):
74
+ """Test creating a valid advance_queue action."""
75
+ action = TriageAction(
76
+ action_type=ActionType.ADVANCE_QUEUE,
77
+ )
78
+ assert action.action_type == ActionType.ADVANCE_QUEUE
79
+
80
+
81
+ class TestTriageObservation:
82
+ """Test observation model."""
83
+
84
+ def test_default_observation(self):
85
+ """Test observation with default values."""
86
+ obs = TriageObservation(done=False, reward=None)
87
+ assert obs.done is False
88
+ assert obs.reward is None
89
+ assert obs.queue_length == 0
90
+
91
+ def test_full_observation(self):
92
+ """Test observation with all fields."""
93
+ obs = TriageObservation(
94
+ done=False,
95
+ reward=0.50,
96
+ current_patient={"patient_id": "P001", "age": 62},
97
+ queue_length=3,
98
+ queue_position=1,
99
+ missing_fields=["vitals"],
100
+ previous_action_feedback="Episode started",
101
+ step_number=1,
102
+ task_name="basic-triage",
103
+ )
104
+ assert obs.current_patient["patient_id"] == "P001"
105
+ assert obs.queue_length == 3
106
+ assert obs.missing_fields == ["vitals"]
107
+
108
+
109
+ class TestTriageState:
110
+ """Test state model."""
111
+
112
+ def test_default_state(self):
113
+ """Test state with default values."""
114
+ state = TriageState()
115
+ assert state.step_count == 0
116
+ assert state.task_name == ""
117
+ assert state.patients == []
118
+ assert state.assignments == {}
119
+
120
+ def test_serializable(self):
121
+ """Test state can be serialized to dict."""
122
+ state = TriageState(
123
+ task_name="basic-triage",
124
+ patients=[{"patient_id": "P001"}],
125
+ assignments={"P001": "immediate"},
126
+ )
127
+ d = state.model_dump()
128
+ assert d["task_name"] == "basic-triage"
129
+ assert d["assignments"]["P001"] == "immediate"
triage_flow/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: __init__.py
3
+ Purpose: Package initialization for the triage_flow environment module.
4
+ Part of: Medical Triage Assistant — OpenEnv Round 1
5
+ Author: Team Squirrel
6
+
7
+ Overview:
8
+ Exports the core environment class and task utilities for use by
9
+ the server and inference scripts.
10
+
11
+ Usage:
12
+ from triage_flow.environment import TriageEnvironment
13
+ from triage_flow.tasks import TASK_NAMES
14
+ from triage_flow.graders import grade_task
15
+ """
16
+
17
+ from triage_flow.environment import TriageEnvironment
18
+ from triage_flow.tasks import TASK_NAMES, get_task_config
19
+ from triage_flow.graders import grade_task
20
+
21
+ __all__ = [
22
+ "TriageEnvironment",
23
+ "TASK_NAMES",
24
+ "get_task_config",
25
+ "grade_task",
26
+ ]
triage_flow/environment.py ADDED
@@ -0,0 +1,544 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: environment.py
3
+ Purpose: Core environment logic implementing reset(), step(), and state for TriageFlow.
4
+ Part of: Medical Triage Assistant — OpenEnv Round 1
5
+ Author: Team Squirrel
6
+
7
+ Overview:
8
+ Implements the TriageEnvironment class which extends the OpenEnv Environment
9
+ base class. This is the central simulation engine: it manages the patient queue,
10
+ validates and applies agent actions, computes rewards, and tracks state for
11
+ deterministic grading. The environment follows a standard episode lifecycle:
12
+ reset() → (observe, act, step) loop → done.
13
+
14
+ Dependencies:
15
+ - openenv.core.env_server: Environment base class
16
+ - models: TriageAction, TriageObservation, TriageState
17
+ - tasks: get_task_config for loading patient data
18
+ - reward: compute_step_reward for per-step reward calculation
19
+
20
+ Usage:
21
+ env = TriageEnvironment()
22
+ obs = env.reset(task_name="basic-triage")
23
+ obs = env.step(TriageAction(action_type="assign_priority", patient_id="P001", priority_level="immediate"))
24
+ """
25
+
26
+ import uuid
27
+ from typing import Any, Dict, List, Optional
28
+
29
+ from openenv.core.env_server import Environment
30
+
31
+ import sys
32
+ import os
33
+
34
+ # Add parent directory to path so we can import models
35
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
36
+
37
+ from models import (
38
+ TriageAction,
39
+ TriageObservation,
40
+ TriageState,
41
+ PriorityLevel,
42
+ ActionType,
43
+ InfoField,
44
+ )
45
+ from triage_flow.tasks import get_task_config, TASK_NAMES
46
+ from triage_flow.reward import compute_step_reward, compute_terminal_reward
47
+
48
+
49
+ class TriageEnvironment(Environment):
50
+ """
51
+ Core triage simulation environment implementing the OpenEnv spec.
52
+
53
+ Manages a queue of patients that an agent must triage by assigning
54
+ urgency priorities, requesting missing information, escalating critical
55
+ cases, or deferring patients. The environment tracks all state for
56
+ deterministic grading.
57
+
58
+ Attributes:
59
+ SUPPORTS_CONCURRENT_SESSIONS (bool): Enables multiple simultaneous clients.
60
+
61
+ Notes:
62
+ Ground truth priorities are never exposed in the observation.
63
+ The grader reads from state() for scoring.
64
+ """
65
+
66
+ SUPPORTS_CONCURRENT_SESSIONS = True
67
+
68
+ def __init__(self):
69
+ """Initialize environment with empty state."""
70
+ self._state = TriageState()
71
+ self._patients: List[Dict[str, Any]] = []
72
+ self._task_config: Dict[str, Any] = {}
73
+ self._cumulative_reward: float = 0.0
74
+
75
+ def reset(self, seed=None, episode_id=None, task_name=None, **kwargs) -> TriageObservation:
76
+ """
77
+ Reset the environment for a new episode.
78
+
79
+ Loads the specified task's patient queue, clears all state,
80
+ and returns the initial observation showing the first patient.
81
+
82
+ Args:
83
+ seed: Optional random seed (not used — tasks are deterministic).
84
+ episode_id: Optional episode identifier.
85
+ task_name: Which task to load. Defaults to "basic-triage".
86
+ **kwargs: Additional arguments (may include task_name).
87
+
88
+ Returns:
89
+ TriageObservation: Initial observation with first patient visible.
90
+
91
+ Notes:
92
+ If task_name is not provided, it defaults to "basic-triage".
93
+ The task_name can also be passed via kwargs.
94
+ """
95
+ # Extract task_name from kwargs if not provided directly
96
+ if task_name is None:
97
+ task_name = kwargs.get("task_name", "basic-triage")
98
+
99
+ # Load task configuration
100
+ self._task_config = get_task_config(task_name)
101
+ self._patients = self._task_config["patients"]
102
+
103
+ # Reset cumulative reward
104
+ self._cumulative_reward = 0.0
105
+
106
+ # Initialize state
107
+ self._state = TriageState(
108
+ episode_id=episode_id or str(uuid.uuid4()),
109
+ step_count=0,
110
+ task_name=task_name,
111
+ patients=[p.copy() for p in self._patients],
112
+ assignments={},
113
+ escalations={},
114
+ info_requests=[],
115
+ action_history=[],
116
+ current_index=0,
117
+ max_steps=self._task_config["max_steps"],
118
+ queue_cleared=False,
119
+ )
120
+
121
+ # Build initial observation
122
+ return self._build_observation(
123
+ reward=None,
124
+ done=False,
125
+ feedback=f"Episode started. Task: {task_name}. "
126
+ f"{len(self._patients)} patients in queue. "
127
+ f"Assess each patient and assign appropriate priority.",
128
+ )
129
+
130
+ def step(self, action: TriageAction, timeout_s=None, **kwargs) -> TriageObservation:
131
+ """
132
+ Execute one agent action and return the resulting observation.
133
+
134
+ Validates the action, applies it to the internal state, computes
135
+ the per-step reward, and checks for done conditions.
136
+
137
+ Args:
138
+ action (TriageAction): The action to execute.
139
+ timeout_s: Optional timeout (not used).
140
+ **kwargs: Additional arguments.
141
+
142
+ Returns:
143
+ TriageObservation: Updated observation after action.
144
+
145
+ Notes:
146
+ Invalid actions are handled gracefully with a penalty reward
147
+ and descriptive feedback. The episode always emits an observation.
148
+ """
149
+ self._state.step_count += 1
150
+
151
+ # Validate action and compute reward
152
+ is_valid, validation_msg = self._validate_action(action)
153
+
154
+ # Get current patient info for reward computation
155
+ current_patient = self._get_current_patient()
156
+ ground_truth = current_patient.get("ground_truth_priority") if current_patient else None
157
+ missing_fields = current_patient.get("missing_fields", []) if current_patient else []
158
+ info_changes = current_patient.get("info_changes_decision", False) if current_patient else False
159
+ patient_id = action.patient_id if action.patient_id else (
160
+ current_patient.get("patient_id", "") if current_patient else ""
161
+ )
162
+
163
+ # Compute reward
164
+ reward_value, reward_reason = compute_step_reward(
165
+ action_type=action.action_type.value if isinstance(action.action_type, ActionType) else str(action.action_type),
166
+ patient_id=patient_id,
167
+ assigned_priority=action.priority_level.value if action.priority_level else None,
168
+ ground_truth_priority=ground_truth,
169
+ info_field=action.info_field.value if action.info_field else None,
170
+ missing_fields=missing_fields,
171
+ info_changes_decision=info_changes,
172
+ already_assigned=patient_id in self._state.assignments,
173
+ already_escalated=patient_id in self._state.escalations,
174
+ escalation_reason=action.escalation_reason,
175
+ action_history=self._state.action_history,
176
+ is_valid_action=is_valid,
177
+ )
178
+
179
+ # Apply action to state
180
+ feedback = self._apply_action(action, is_valid, validation_msg)
181
+
182
+ # Record action in history
183
+ self._state.action_history.append({
184
+ "step": self._state.step_count,
185
+ "action_type": action.action_type.value if isinstance(action.action_type, ActionType) else str(action.action_type),
186
+ "patient_id": patient_id,
187
+ "priority_level": action.priority_level.value if action.priority_level else None,
188
+ "info_field": action.info_field.value if action.info_field else None,
189
+ "escalation_reason": action.escalation_reason,
190
+ "reward": reward_value,
191
+ "reward_reason": reward_reason,
192
+ "valid": is_valid,
193
+ })
194
+
195
+ # Accumulate reward
196
+ self._cumulative_reward += reward_value
197
+
198
+ # Check done conditions
199
+ done = self._check_done()
200
+
201
+ # Add terminal bonus if done and queue cleared
202
+ if done and self._state.queue_cleared:
203
+ terminal_reward, terminal_reason = compute_terminal_reward(True)
204
+ reward_value += terminal_reward
205
+ self._cumulative_reward += terminal_reward
206
+ feedback += f" {terminal_reason}."
207
+
208
+ return self._build_observation(
209
+ reward=reward_value,
210
+ done=done,
211
+ feedback=f"{feedback} | {reward_reason}",
212
+ )
213
+
214
+ @property
215
+ def state(self) -> TriageState:
216
+ """
217
+ Return the full internal state for grading and validation.
218
+
219
+ Returns:
220
+ TriageState: Complete state snapshot including ground truth,
221
+ action history, and all assignments.
222
+
223
+ Notes:
224
+ This is read by graders to compute the final score.
225
+ It must be serializable to JSON.
226
+ """
227
+ return self._state
228
+
229
+ # ========================================================================
230
+ # Internal Helpers
231
+ # ========================================================================
232
+
233
+ def _get_current_patient(self) -> Optional[Dict[str, Any]]:
234
+ """
235
+ Get the patient at the current queue position.
236
+
237
+ Returns:
238
+ Optional[dict]: Current patient data, or None if queue is empty/exhausted.
239
+ """
240
+ if 0 <= self._state.current_index < len(self._patients):
241
+ return self._patients[self._state.current_index]
242
+ return None
243
+
244
+ def _build_observation(
245
+ self, reward: Optional[float], done: bool, feedback: str
246
+ ) -> TriageObservation:
247
+ """
248
+ Build an observation object from current state.
249
+
250
+ Strips ground truth fields from the patient record before
251
+ including it in the observation.
252
+
253
+ Args:
254
+ reward (Optional[float]): Reward for this step.
255
+ done (bool): Whether episode is complete.
256
+ feedback (str): Human-readable feedback message.
257
+
258
+ Returns:
259
+ TriageObservation: Agent-visible observation.
260
+ """
261
+ current_patient = self._get_current_patient()
262
+
263
+ # Build agent-visible patient dict (strip ground truth)
264
+ visible_patient = None
265
+ missing = []
266
+ if current_patient:
267
+ visible_patient = {
268
+ k: v
269
+ for k, v in current_patient.items()
270
+ if k not in ("ground_truth_priority", "missing_fields",
271
+ "info_changes_decision", "revealed_history",
272
+ "revealed_medications")
273
+ }
274
+ # Calculate missing fields
275
+ for field in ["vitals", "history", "medications", "allergies"]:
276
+ if current_patient.get(field) is None:
277
+ missing.append(field)
278
+
279
+ # Count remaining unassigned patients
280
+ unassigned_count = sum(
281
+ 1 for p in self._patients
282
+ if p["patient_id"] not in self._state.assignments
283
+ )
284
+
285
+ return TriageObservation(
286
+ done=done,
287
+ reward=reward,
288
+ current_patient=visible_patient,
289
+ queue_length=unassigned_count,
290
+ queue_position=self._state.current_index + 1,
291
+ missing_fields=missing,
292
+ previous_action_feedback=feedback,
293
+ step_number=self._state.step_count,
294
+ task_name=self._state.task_name,
295
+ )
296
+
297
+ def _validate_action(self, action: TriageAction) -> tuple:
298
+ """
299
+ Validate that an action is legal in the current state.
300
+
301
+ Args:
302
+ action (TriageAction): Action to validate.
303
+
304
+ Returns:
305
+ tuple: (is_valid: bool, message: str)
306
+ """
307
+ action_type = action.action_type.value if isinstance(action.action_type, ActionType) else str(action.action_type)
308
+ current_patient = self._get_current_patient()
309
+
310
+ if not current_patient:
311
+ return False, "No patient at current queue position"
312
+
313
+ patient_id = action.patient_id or current_patient.get("patient_id", "")
314
+
315
+ # Check if patient_id exists in the queue
316
+ valid_ids = [p["patient_id"] for p in self._patients]
317
+ if patient_id and patient_id not in valid_ids:
318
+ return False, f"Patient {patient_id} not found in queue"
319
+
320
+ if action_type == "assign_priority":
321
+ if not action.priority_level:
322
+ return False, "ASSIGN_PRIORITY requires a priority_level"
323
+ if patient_id in self._state.assignments:
324
+ return False, f"Patient {patient_id} already has priority assigned"
325
+
326
+ elif action_type == "request_info":
327
+ if not action.info_field:
328
+ return False, "REQUEST_INFO requires an info_field"
329
+
330
+ elif action_type == "escalate":
331
+ if patient_id in self._state.escalations:
332
+ return False, f"Patient {patient_id} already escalated"
333
+
334
+ elif action_type == "defer":
335
+ if len(self._patients) <= 1:
336
+ return False, "Cannot defer when only one patient in queue"
337
+
338
+ elif action_type == "advance_queue":
339
+ pass # Always valid
340
+
341
+ else:
342
+ return False, f"Unknown action type: {action_type}"
343
+
344
+ return True, "Action valid"
345
+
346
+ def _apply_action(self, action: TriageAction, is_valid: bool, validation_msg: str) -> str:
347
+ """
348
+ Apply a validated action to the internal state.
349
+
350
+ Args:
351
+ action (TriageAction): Action to apply.
352
+ is_valid (bool): Whether the action passed validation.
353
+ validation_msg (str): Validation feedback message.
354
+
355
+ Returns:
356
+ str: Feedback message describing what happened.
357
+ """
358
+ if not is_valid:
359
+ return f"Action rejected: {validation_msg}"
360
+
361
+ action_type = action.action_type.value if isinstance(action.action_type, ActionType) else str(action.action_type)
362
+ current_patient = self._get_current_patient()
363
+ patient_id = action.patient_id or (current_patient.get("patient_id", "") if current_patient else "")
364
+
365
+ if action_type == "assign_priority":
366
+ priority = action.priority_level.value if action.priority_level else "unknown"
367
+ self._state.assignments[patient_id] = priority
368
+ # Auto-advance to next unassigned patient
369
+ self._advance_to_next_unassigned()
370
+ # Check if queue is cleared
371
+ self._check_queue_cleared()
372
+ return f"Priority {priority} assigned to patient {patient_id}"
373
+
374
+ elif action_type == "request_info":
375
+ field = action.info_field.value if action.info_field else "unknown"
376
+ # Record the request
377
+ self._state.info_requests.append({
378
+ "patient_id": patient_id,
379
+ "field": field,
380
+ "step": self._state.step_count,
381
+ })
382
+ # Reveal the information if it was genuinely missing
383
+ target_patient = self._find_patient(patient_id)
384
+ if target_patient:
385
+ revealed_value = None
386
+ if field == "history" and target_patient.get("revealed_history"):
387
+ target_patient["history"] = target_patient["revealed_history"]
388
+ revealed_value = target_patient["history"]
389
+ elif field == "medications" and target_patient.get("revealed_medications"):
390
+ target_patient["medications"] = target_patient["revealed_medications"]
391
+ revealed_value = str(target_patient["medications"])
392
+ elif field == "vitals" and target_patient.get("vitals") is None:
393
+ # Generate plausible vitals based on ground truth priority
394
+ target_patient["vitals"] = self._generate_revealed_vitals(target_patient)
395
+ revealed_value = str(target_patient["vitals"])
396
+ elif field == "allergies" and target_patient.get("allergies") is None:
397
+ target_patient["allergies"] = []
398
+ revealed_value = "No known allergies"
399
+
400
+ # Update info_complete if all fields now present
401
+ still_missing = any(
402
+ target_patient.get(f) is None
403
+ for f in ["vitals", "history", "medications", "allergies"]
404
+ )
405
+ target_patient["info_complete"] = not still_missing
406
+
407
+ if revealed_value:
408
+ return f"Info requested: {field} revealed for patient {patient_id}: {revealed_value}"
409
+ return f"Info requested: {field} for patient {patient_id} (already present)"
410
+
411
+ return f"Info requested: {field} for patient {patient_id}"
412
+
413
+ elif action_type == "escalate":
414
+ reason = action.escalation_reason or "No reason provided"
415
+ self._state.escalations[patient_id] = reason
416
+ return f"Patient {patient_id} escalated to senior staff. Reason: {reason}"
417
+
418
+ elif action_type == "defer":
419
+ # Move patient to end of queue
420
+ target_idx = next(
421
+ (i for i, p in enumerate(self._patients) if p["patient_id"] == patient_id),
422
+ None
423
+ )
424
+ if target_idx is not None:
425
+ patient = self._patients.pop(target_idx)
426
+ self._patients.append(patient)
427
+ # Adjust current index if needed
428
+ if target_idx <= self._state.current_index:
429
+ self._state.current_index = max(0, self._state.current_index - 1)
430
+ return f"Patient {patient_id} deferred to end of queue"
431
+
432
+ elif action_type == "advance_queue":
433
+ self._state.current_index = min(
434
+ self._state.current_index + 1, len(self._patients) - 1
435
+ )
436
+ next_patient = self._get_current_patient()
437
+ next_id = next_patient["patient_id"] if next_patient else "none"
438
+ return f"Advanced queue. Now viewing patient {next_id}"
439
+
440
+ return "Action applied"
441
+
442
+ def _advance_to_next_unassigned(self):
443
+ """
444
+ Move current_index to the next patient that hasn't been assigned yet.
445
+ Wraps around the queue if necessary.
446
+ """
447
+ start = self._state.current_index
448
+ for i in range(len(self._patients)):
449
+ idx = (start + i) % len(self._patients)
450
+ patient = self._patients[idx]
451
+ if patient["patient_id"] not in self._state.assignments:
452
+ self._state.current_index = idx
453
+ return
454
+ # All assigned — stay at current
455
+ self._state.current_index = min(start, len(self._patients) - 1)
456
+
457
+ def _check_queue_cleared(self):
458
+ """Check if all patients have been assigned a priority."""
459
+ all_assigned = all(
460
+ p["patient_id"] in self._state.assignments for p in self._patients
461
+ )
462
+ self._state.queue_cleared = all_assigned
463
+
464
+ def _check_done(self) -> bool:
465
+ """
466
+ Check if the episode should end.
467
+
468
+ Done conditions:
469
+ 1. All patients assigned a priority (queue cleared)
470
+ 2. Maximum steps reached
471
+
472
+ Returns:
473
+ bool: True if episode should end.
474
+ """
475
+ # All patients assigned
476
+ if self._state.queue_cleared:
477
+ return True
478
+
479
+ # Max steps reached
480
+ if self._state.step_count >= self._state.max_steps:
481
+ return True
482
+
483
+ return False
484
+
485
+ def _find_patient(self, patient_id: str) -> Optional[Dict[str, Any]]:
486
+ """
487
+ Find a patient in the queue by ID.
488
+
489
+ Args:
490
+ patient_id (str): Patient ID to search for.
491
+
492
+ Returns:
493
+ Optional[dict]: Patient data, or None if not found.
494
+ """
495
+ for p in self._patients:
496
+ if p["patient_id"] == patient_id:
497
+ return p
498
+ return None
499
+
500
+ def _generate_revealed_vitals(self, patient: Dict[str, Any]) -> Dict[str, Any]:
501
+ """
502
+ Generate plausible vitals for a patient whose vitals were missing.
503
+
504
+ Based on ground truth priority to maintain clinical consistency.
505
+
506
+ Args:
507
+ patient (dict): Patient data including ground_truth_priority.
508
+
509
+ Returns:
510
+ dict: Vitals dictionary with HR, BP, SpO2, temperature.
511
+
512
+ Notes:
513
+ # ASSUMPTION: Revealed vitals are consistent with the ground truth
514
+ # priority to maintain deterministic grading.
515
+ """
516
+ priority = patient.get("ground_truth_priority", "non_urgent")
517
+ if priority == "immediate":
518
+ return {
519
+ "heart_rate": 125,
520
+ "blood_pressure": "85/55",
521
+ "spo2": 90,
522
+ "temperature": 38.5,
523
+ }
524
+ elif priority == "urgent":
525
+ return {
526
+ "heart_rate": 105,
527
+ "blood_pressure": "145/95",
528
+ "spo2": 93,
529
+ "temperature": 38.8,
530
+ }
531
+ elif priority == "less_urgent":
532
+ return {
533
+ "heart_rate": 85,
534
+ "blood_pressure": "130/82",
535
+ "spo2": 97,
536
+ "temperature": 37.5,
537
+ }
538
+ else: # non_urgent
539
+ return {
540
+ "heart_rate": 75,
541
+ "blood_pressure": "120/78",
542
+ "spo2": 99,
543
+ "temperature": 36.9,
544
+ }
triage_flow/graders.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: graders.py
3
+ Purpose: Deterministic grading functions for each TriageFlow task.
4
+ Part of: Medical Triage Assistant — OpenEnv Round 1
5
+ Author: Team Squirrel
6
+
7
+ Overview:
8
+ Implements one grader per task. Each grader reads from the environment's
9
+ state() output and returns a float in [0.0, 1.0]. Graders are deterministic:
10
+ the same state always produces the same score. Different agent behaviors
11
+ produce meaningfully different scores — graders that always return the same
12
+ score cause disqualification.
13
+
14
+ Dependencies:
15
+ - models: TriageState for state access
16
+
17
+ Usage:
18
+ from triage_flow.graders import grade_task
19
+ score = grade_task("basic-triage", state_dict)
20
+ """
21
+
22
+ from typing import Any, Dict, List, Optional
23
+
24
+
25
+ def grade_task(task_name: str, state: Dict[str, Any]) -> float:
26
+ """
27
+ Grade a completed episode for the given task.
28
+
29
+ Dispatches to the appropriate task-specific grader.
30
+
31
+ Args:
32
+ task_name (str): Which task to grade.
33
+ state (dict): Full state from environment.state().
34
+
35
+ Returns:
36
+ float: Score in [0.0, 1.0].
37
+
38
+ Raises:
39
+ ValueError: If task_name is not recognized.
40
+ """
41
+ graders = {
42
+ "basic-triage": _grade_basic_triage,
43
+ "incomplete-records-triage": _grade_incomplete_records,
44
+ "mass-casualty-triage": _grade_mass_casualty,
45
+ }
46
+
47
+ if task_name not in graders:
48
+ raise ValueError(f"Unknown task for grading: {task_name}")
49
+
50
+ score = graders[task_name](state)
51
+ # Clamp to [0.0, 1.0]
52
+ return max(0.0, min(1.0, score))
53
+
54
+
55
+ # ============================================================================
56
+ # Task 1: Basic Triage (Easy)
57
+ # ============================================================================
58
+ # Grader Logic: 1.0 if all 3 correct, 0.67 if 2 correct, 0.33 if 1 correct, 0.0 if none.
59
+
60
+ def _grade_basic_triage(state: Dict[str, Any]) -> float:
61
+ """
62
+ Grade the basic triage task.
63
+
64
+ Simple accuracy-based grading: count how many patients were
65
+ assigned the correct priority out of 3 total.
66
+
67
+ Args:
68
+ state (dict): Full episode state.
69
+
70
+ Returns:
71
+ float: Score = correct_count / total_patients.
72
+
73
+ Notes:
74
+ Deterministic: same assignments → same score.
75
+ Varies meaningfully: 0.0, 0.33, 0.67, or 1.0.
76
+ """
77
+ patients = state.get("patients", [])
78
+ assignments = state.get("assignments", {})
79
+
80
+ if not patients:
81
+ return 0.0
82
+
83
+ correct = 0
84
+ total = len(patients)
85
+
86
+ for patient in patients:
87
+ pid = patient["patient_id"]
88
+ gt = patient.get("ground_truth_priority")
89
+ assigned = assignments.get(pid)
90
+ if assigned == gt:
91
+ correct += 1
92
+
93
+ # Score = proportion correct (rounds to 0.0, 0.33, 0.67, 1.0 for 3 patients)
94
+ return round(correct / total, 2)
95
+
96
+
97
+ # ============================================================================
98
+ # Task 2: Incomplete Records Triage (Medium)
99
+ # ============================================================================
100
+ # Grader Logic: Base score from priority accuracy (60%).
101
+ # Bonus for appropriate info requests (20%).
102
+ # Step efficiency (10%).
103
+ # Penalize unnecessary info requests (10%).
104
+
105
+ def _grade_incomplete_records(state: Dict[str, Any]) -> float:
106
+ """
107
+ Grade the incomplete records triage task.
108
+
109
+ Score decomposition:
110
+ - Priority accuracy: 60% weight
111
+ - Info request quality: 20% weight
112
+ - Escalation quality: 10% weight
113
+ - Step efficiency: 10% weight
114
+
115
+ Args:
116
+ state (dict): Full episode state.
117
+
118
+ Returns:
119
+ float: Weighted composite score in [0.0, 1.0].
120
+
121
+ Notes:
122
+ Info requests are scored based on whether they targeted
123
+ genuinely missing fields that would change the triage decision.
124
+ """
125
+ patients = state.get("patients", [])
126
+ assignments = state.get("assignments", {})
127
+ info_requests = state.get("info_requests", [])
128
+ max_steps = state.get("max_steps", 15)
129
+ step_count = state.get("step_count", 0)
130
+
131
+ if not patients:
132
+ return 0.0
133
+
134
+ # --- Priority Accuracy (60%) ---
135
+ correct = 0
136
+ total = len(patients)
137
+ for patient in patients:
138
+ pid = patient["patient_id"]
139
+ gt = patient.get("ground_truth_priority")
140
+ assigned = assignments.get(pid)
141
+ if assigned == gt:
142
+ correct += 1
143
+ priority_score = correct / total
144
+
145
+ # --- Info Request Quality (20%) ---
146
+ # Count appropriate vs inappropriate info requests
147
+ appropriate_requests = 0
148
+ unnecessary_requests = 0
149
+ for req in info_requests:
150
+ pid = req.get("patient_id")
151
+ field = req.get("field")
152
+ # Find the patient
153
+ patient = next((p for p in patients if p["patient_id"] == pid), None)
154
+ if patient:
155
+ missing = patient.get("missing_fields", [])
156
+ if field in missing:
157
+ appropriate_requests += 1
158
+ else:
159
+ unnecessary_requests += 1
160
+ else:
161
+ unnecessary_requests += 1
162
+
163
+ # Patients with genuinely useful missing info
164
+ patients_needing_info = sum(
165
+ 1 for p in patients
166
+ if p.get("info_changes_decision", False) and p.get("missing_fields")
167
+ )
168
+ total_missing_fields = sum(
169
+ len(p.get("missing_fields", []))
170
+ for p in patients
171
+ if p.get("missing_fields")
172
+ )
173
+
174
+ if total_missing_fields > 0:
175
+ info_recall = appropriate_requests / total_missing_fields
176
+ else:
177
+ info_recall = 1.0 # No missing fields = perfect
178
+
179
+ info_precision = 1.0
180
+ total_requests = appropriate_requests + unnecessary_requests
181
+ if total_requests > 0:
182
+ info_precision = appropriate_requests / total_requests
183
+
184
+ info_score = (info_recall * 0.6 + info_precision * 0.4)
185
+
186
+ # --- Escalation Quality (10%) ---
187
+ escalations = state.get("escalations", {})
188
+ immediate_patients = [p for p in patients if p.get("ground_truth_priority") == "immediate"]
189
+ non_immediate_patients = [p for p in patients if p.get("ground_truth_priority") != "immediate"]
190
+
191
+ escalation_score = 1.0
192
+ if immediate_patients:
193
+ # Reward escalating immediate patients
194
+ correct_escalations = sum(
195
+ 1 for p in immediate_patients if p["patient_id"] in escalations
196
+ )
197
+ missed_escalations = len(immediate_patients) - correct_escalations
198
+ wrong_escalations = sum(
199
+ 1 for p in non_immediate_patients if p["patient_id"] in escalations
200
+ )
201
+ escalation_score = max(0.0, 1.0 - missed_escalations * 0.3 - wrong_escalations * 0.2)
202
+ else:
203
+ # No immediate patients — penalize any escalations
204
+ if escalations:
205
+ escalation_score = max(0.0, 1.0 - len(escalations) * 0.2)
206
+
207
+ # --- Step Efficiency (10%) ---
208
+ # Reward finishing efficiently (fewer steps = better)
209
+ if max_steps > 0:
210
+ efficiency = max(0.0, 1.0 - (step_count / max_steps) * 0.5)
211
+ else:
212
+ efficiency = 1.0
213
+
214
+ # Weighted composite
215
+ final_score = (
216
+ priority_score * 0.60
217
+ + info_score * 0.20
218
+ + escalation_score * 0.10
219
+ + efficiency * 0.10
220
+ )
221
+
222
+ return round(final_score, 2)
223
+
224
+
225
+ # ============================================================================
226
+ # Task 3: Mass Casualty Triage (Hard)
227
+ # ============================================================================
228
+ # Grader Logic:
229
+ # - Correct ordering of top 2 critical patients: 30%
230
+ # - IMMEDIATE misclassified as NON_URGENT: heavy penalty
231
+ # - General accuracy on remaining: 30%
232
+ # - Step efficiency under pressure: 10%
233
+ # - Weighted accuracy overall: 30%
234
+
235
+ def _grade_mass_casualty(state: Dict[str, Any]) -> float:
236
+ """
237
+ Grade the mass casualty triage task.
238
+
239
+ Score decomposition:
240
+ - Critical patient identification: 30% weight
241
+ (Did the agent correctly identify and prioritize the 2 IMMEDIATE patients?)
242
+ - Overall priority accuracy (weighted by severity): 40% weight
243
+ - Step efficiency under time pressure: 15% weight
244
+ - Safety penalty for dangerous misclassifications: 15% weight
245
+
246
+ Args:
247
+ state (dict): Full episode state.
248
+
249
+ Returns:
250
+ float: Weighted composite score in [0.0, 1.0].
251
+
252
+ Notes:
253
+ IMMEDIATE patients misclassified as NON_URGENT receive heavy penalties.
254
+ The hard task is designed so frontier models will struggle with
255
+ interaction reasoning (e.g., anticoagulant + head injury).
256
+ """
257
+ patients = state.get("patients", [])
258
+ assignments = state.get("assignments", {})
259
+ action_history = state.get("action_history", [])
260
+ max_steps = state.get("max_steps", 20)
261
+ step_count = state.get("step_count", 0)
262
+
263
+ if not patients:
264
+ return 0.0
265
+
266
+ # --- Critical Patient Identification (30%) ---
267
+ # The two IMMEDIATE patients should be identified correctly
268
+ immediate_patients = [p for p in patients if p.get("ground_truth_priority") == "immediate"]
269
+ critical_score = 0.0
270
+
271
+ if immediate_patients:
272
+ correctly_identified = 0
273
+ for p in immediate_patients:
274
+ pid = p["patient_id"]
275
+ if assignments.get(pid) == "immediate":
276
+ correctly_identified += 1
277
+ critical_score = correctly_identified / len(immediate_patients)
278
+
279
+ # Bonus: Were they triaged FIRST? (within first N actions)
280
+ assign_actions = [
281
+ a for a in action_history
282
+ if a.get("action_type") == "assign_priority" and a.get("valid", True)
283
+ ]
284
+ if len(assign_actions) >= 2:
285
+ first_two_pids = [a.get("patient_id") for a in assign_actions[:2]]
286
+ immediate_pids = {p["patient_id"] for p in immediate_patients}
287
+ # Bonus for triaging IMMEDIATE patients in first two actions
288
+ first_correct = sum(1 for pid in first_two_pids if pid in immediate_pids)
289
+ critical_score = min(1.0, critical_score + first_correct * 0.15)
290
+
291
+ # --- Overall Priority Accuracy (weighted by severity) (40%) ---
292
+ # Priority weights: IMMEDIATE errors are more costly
293
+ SEVERITY_WEIGHTS = {
294
+ "immediate": 3.0,
295
+ "urgent": 2.0,
296
+ "less_urgent": 1.0,
297
+ "non_urgent": 0.5,
298
+ }
299
+
300
+ weighted_correct = 0.0
301
+ total_weight = 0.0
302
+
303
+ for patient in patients:
304
+ pid = patient["patient_id"]
305
+ gt = patient.get("ground_truth_priority", "non_urgent")
306
+ assigned = assignments.get(pid)
307
+ weight = SEVERITY_WEIGHTS.get(gt, 1.0)
308
+ total_weight += weight
309
+
310
+ if assigned == gt:
311
+ weighted_correct += weight
312
+ elif assigned is None:
313
+ pass # Not assigned — no credit, no extra penalty here
314
+
315
+ accuracy_score = weighted_correct / total_weight if total_weight > 0 else 0.0
316
+
317
+ # --- Step Efficiency (15%) ---
318
+ # With 20 steps for 8 patients, efficiency matters
319
+ assigned_count = len(assignments)
320
+ total_patients = len(patients)
321
+
322
+ if total_patients > 0:
323
+ completion_rate = assigned_count / total_patients
324
+ else:
325
+ completion_rate = 0.0
326
+
327
+ # Efficiency = completion rate * time efficiency
328
+ if max_steps > 0 and assigned_count > 0:
329
+ steps_per_patient = step_count / assigned_count
330
+ optimal_steps_per_patient = 1.5 # Some info requests are okay
331
+ time_efficiency = max(0.0, 1.0 - max(0, steps_per_patient - optimal_steps_per_patient) * 0.2)
332
+ efficiency_score = completion_rate * time_efficiency
333
+ else:
334
+ efficiency_score = 0.0
335
+
336
+ # --- Safety Penalty (15%) ---
337
+ # Penalize dangerous misclassifications
338
+ safety_score = 1.0
339
+
340
+ for patient in patients:
341
+ pid = patient["patient_id"]
342
+ gt = patient.get("ground_truth_priority")
343
+ assigned = assignments.get(pid)
344
+
345
+ if gt == "immediate" and assigned == "non_urgent":
346
+ safety_score -= 0.40 # Catastrophic error
347
+ elif gt == "immediate" and assigned == "less_urgent":
348
+ safety_score -= 0.30 # Very dangerous
349
+ elif gt == "urgent" and assigned == "non_urgent":
350
+ safety_score -= 0.20 # Dangerous
351
+ elif gt == "immediate" and assigned is None:
352
+ safety_score -= 0.25 # Failed to triage critical patient
353
+
354
+ safety_score = max(0.0, safety_score)
355
+
356
+ # Weighted composite
357
+ final_score = (
358
+ critical_score * 0.30
359
+ + accuracy_score * 0.40
360
+ + efficiency_score * 0.15
361
+ + safety_score * 0.15
362
+ )
363
+
364
+ return round(final_score, 2)
triage_flow/reward.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: reward.py
3
+ Purpose: Compute per-step rewards for agent actions in the TriageFlow environment.
4
+ Part of: Medical Triage Assistant — OpenEnv Round 1
5
+ Author: Team Squirrel
6
+
7
+ Overview:
8
+ Implements the reward shaping function for the TriageFlow environment.
9
+ Rewards are computed per-step based on the action taken and the ground
10
+ truth state. Correct urgency assignments earn positive rewards scaled
11
+ by severity. Incorrect assignments, unnecessary actions, and invalid
12
+ actions incur penalties. A terminal bonus is awarded for clearing the queue.
13
+
14
+ Dependencies:
15
+ - models: PriorityLevel, ActionType, InfoField enums
16
+
17
+ Usage:
18
+ from triage_flow.reward import compute_step_reward
19
+ """
20
+
21
+ from typing import Any, Dict, List, Optional, Tuple
22
+
23
+
24
+ # ============================================================================
25
+ # Reward Constants (from PART 8 of build reference)
26
+ # ============================================================================
27
+
28
+ # Correct priority assignment rewards
29
+ REWARD_CORRECT_IMMEDIATE = 0.50
30
+ REWARD_CORRECT_URGENT = 0.30
31
+ REWARD_CORRECT_LESS_URGENT = 0.20
32
+ REWARD_CORRECT_NON_URGENT = 0.10
33
+
34
+ # Misclassification penalties
35
+ PENALTY_IMMEDIATE_AS_NON_URGENT = -0.50
36
+ PENALTY_URGENT_AS_NON_URGENT = -0.30
37
+ PENALTY_MISCLASSIFY_DEFAULT = -0.20
38
+
39
+ # Info request rewards/penalties
40
+ REWARD_APPROPRIATE_INFO_REQUEST = 0.10
41
+ PENALTY_UNNECESSARY_INFO_REQUEST = -0.10
42
+
43
+ # Escalation rewards/penalties
44
+ REWARD_APPROPRIATE_ESCALATION = 0.20
45
+ PENALTY_UNNECESSARY_ESCALATION = -0.10
46
+
47
+ # Invalid action penalty
48
+ PENALTY_INVALID_ACTION = -0.05
49
+
50
+ # Loop/no-op penalty
51
+ PENALTY_REPEATED_NOOP = -0.10
52
+
53
+ # Terminal bonus
54
+ REWARD_QUEUE_CLEARED = 0.20
55
+
56
+ # Correct assignment reward lookup by ground truth priority
57
+ CORRECT_ASSIGNMENT_REWARDS = {
58
+ "immediate": REWARD_CORRECT_IMMEDIATE,
59
+ "urgent": REWARD_CORRECT_URGENT,
60
+ "less_urgent": REWARD_CORRECT_LESS_URGENT,
61
+ "non_urgent": REWARD_CORRECT_NON_URGENT,
62
+ }
63
+
64
+
65
+ def compute_step_reward(
66
+ action_type: str,
67
+ patient_id: str,
68
+ assigned_priority: Optional[str],
69
+ ground_truth_priority: Optional[str],
70
+ info_field: Optional[str],
71
+ missing_fields: List[str],
72
+ info_changes_decision: bool,
73
+ already_assigned: bool,
74
+ already_escalated: bool,
75
+ escalation_reason: Optional[str],
76
+ action_history: List[Dict[str, Any]],
77
+ is_valid_action: bool,
78
+ ) -> Tuple[float, str]:
79
+ """
80
+ Compute the reward for a single step based on the action taken.
81
+
82
+ Args:
83
+ action_type (str): The type of action taken.
84
+ patient_id (str): The patient the action targets.
85
+ assigned_priority (Optional[str]): The priority level assigned (for assign_priority).
86
+ ground_truth_priority (Optional[str]): The correct priority for the patient.
87
+ info_field (Optional[str]): The field requested (for request_info).
88
+ missing_fields (List[str]): Fields currently missing for the patient.
89
+ info_changes_decision (bool): Whether requesting info would change the triage decision.
90
+ already_assigned (bool): Whether this patient already has a priority assigned.
91
+ already_escalated (bool): Whether this patient has already been escalated.
92
+ escalation_reason (Optional[str]): Reason given for escalation.
93
+ action_history (List[dict]): History of all previous actions.
94
+ is_valid_action (bool): Whether the action passed validation.
95
+
96
+ Returns:
97
+ Tuple[float, str]: (reward_value, reason_string)
98
+
99
+ Notes:
100
+ Rewards are raw values that may accumulate outside [0, 1] during an episode.
101
+ The grader normalizes the final episode score to [0.0, 1.0].
102
+ """
103
+ # Invalid action
104
+ if not is_valid_action:
105
+ return PENALTY_INVALID_ACTION, "Invalid or malformed action"
106
+
107
+ # Check for repeated no-op / loop behavior
108
+ # (same action on same patient repeated 3+ times)
109
+ if _is_repeated_action(action_type, patient_id, action_history):
110
+ return PENALTY_REPEATED_NOOP, "Repeated action detected (loop behavior)"
111
+
112
+ # ---- ASSIGN PRIORITY ----
113
+ if action_type == "assign_priority":
114
+ if already_assigned:
115
+ return PENALTY_INVALID_ACTION, f"Patient {patient_id} already has priority assigned"
116
+
117
+ if assigned_priority == ground_truth_priority:
118
+ reward = CORRECT_ASSIGNMENT_REWARDS.get(ground_truth_priority, 0.10)
119
+ return reward, f"Correct priority assignment: {assigned_priority}"
120
+ else:
121
+ # Calculate misclassification penalty based on severity
122
+ penalty = _misclassification_penalty(ground_truth_priority, assigned_priority)
123
+ return penalty, (
124
+ f"Incorrect priority: assigned {assigned_priority}, "
125
+ f"correct is {ground_truth_priority}"
126
+ )
127
+
128
+ # ---- REQUEST INFO ----
129
+ elif action_type == "request_info":
130
+ if info_field and info_field in missing_fields:
131
+ # Field is genuinely missing — appropriate request
132
+ if info_changes_decision:
133
+ return REWARD_APPROPRIATE_INFO_REQUEST, (
134
+ f"Appropriate info request: {info_field} (would change decision)"
135
+ )
136
+ else:
137
+ # Field is missing but wouldn't change the decision
138
+ # Small positive for being thorough, but less than if it mattered
139
+ return REWARD_APPROPRIATE_INFO_REQUEST * 0.5, (
140
+ f"Info request: {info_field} (field missing but wouldn't change triage)"
141
+ )
142
+ else:
143
+ return PENALTY_UNNECESSARY_INFO_REQUEST, (
144
+ f"Unnecessary info request: {info_field} is already present"
145
+ )
146
+
147
+ # ---- ESCALATE ----
148
+ elif action_type == "escalate":
149
+ if already_escalated:
150
+ return PENALTY_INVALID_ACTION, f"Patient {patient_id} already escalated"
151
+
152
+ # Escalation is appropriate for IMMEDIATE priority patients
153
+ if ground_truth_priority == "immediate":
154
+ return REWARD_APPROPRIATE_ESCALATION, (
155
+ f"Appropriate escalation for critical patient {patient_id}"
156
+ )
157
+ else:
158
+ return PENALTY_UNNECESSARY_ESCALATION, (
159
+ f"Unnecessary escalation for {ground_truth_priority} patient {patient_id}"
160
+ )
161
+
162
+ # ---- DEFER ----
163
+ elif action_type == "defer":
164
+ # Deferring is a neutral to slightly negative action
165
+ # It's okay for non-urgent patients but bad for immediate ones
166
+ if ground_truth_priority == "immediate":
167
+ return -0.20, f"Dangerous: deferred IMMEDIATE patient {patient_id}"
168
+ elif ground_truth_priority == "urgent":
169
+ return -0.10, f"Risky: deferred URGENT patient {patient_id}"
170
+ else:
171
+ return 0.0, f"Deferred patient {patient_id}"
172
+
173
+ # ---- ADVANCE QUEUE ----
174
+ elif action_type == "advance_queue":
175
+ # Neutral action — just moves to next patient
176
+ return 0.0, "Advanced to next patient in queue"
177
+
178
+ # Fallback
179
+ return 0.0, "Unknown action type"
180
+
181
+
182
+ def compute_terminal_reward(queue_cleared: bool) -> Tuple[float, str]:
183
+ """
184
+ Compute the terminal bonus reward at end of episode.
185
+
186
+ Args:
187
+ queue_cleared (bool): Whether all patients have been assigned priorities.
188
+
189
+ Returns:
190
+ Tuple[float, str]: (reward_value, reason_string)
191
+ """
192
+ if queue_cleared:
193
+ return REWARD_QUEUE_CLEARED, "Terminal bonus: all patients triaged"
194
+ return 0.0, "Episode ended without clearing queue"
195
+
196
+
197
+ def _misclassification_penalty(
198
+ ground_truth: Optional[str], assigned: Optional[str]
199
+ ) -> float:
200
+ """
201
+ Calculate penalty for incorrect priority assignment.
202
+
203
+ More severe misclassifications (e.g. IMMEDIATE → NON_URGENT) receive
204
+ larger penalties than minor ones (e.g. URGENT → LESS_URGENT).
205
+
206
+ Args:
207
+ ground_truth (Optional[str]): Correct priority level.
208
+ assigned (Optional[str]): Priority level assigned by agent.
209
+
210
+ Returns:
211
+ float: Penalty value (negative).
212
+ """
213
+ if ground_truth == "immediate" and assigned == "non_urgent":
214
+ return PENALTY_IMMEDIATE_AS_NON_URGENT
215
+ elif ground_truth == "immediate" and assigned == "less_urgent":
216
+ return -0.40
217
+ elif ground_truth == "urgent" and assigned == "non_urgent":
218
+ return PENALTY_URGENT_AS_NON_URGENT
219
+ elif ground_truth == "immediate" and assigned == "urgent":
220
+ return -0.15 # Close but still wrong for critical patient
221
+ elif ground_truth == "urgent" and assigned == "less_urgent":
222
+ return -0.15
223
+ else:
224
+ return PENALTY_MISCLASSIFY_DEFAULT
225
+
226
+
227
+ def _is_repeated_action(
228
+ action_type: str, patient_id: str, action_history: List[Dict[str, Any]]
229
+ ) -> bool:
230
+ """
231
+ Detect if the agent is in a loop by checking for 3+ identical actions.
232
+
233
+ Args:
234
+ action_type (str): Current action type.
235
+ patient_id (str): Current target patient.
236
+ action_history (List[dict]): Previous actions taken.
237
+
238
+ Returns:
239
+ bool: True if this exact action has been taken 2+ times already.
240
+ """
241
+ count = sum(
242
+ 1
243
+ for a in action_history
244
+ if a.get("action_type") == action_type and a.get("patient_id") == patient_id
245
+ )
246
+ return count >= 2 # Current would be the 3rd
triage_flow/tasks.py ADDED
@@ -0,0 +1,478 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: tasks.py
3
+ Purpose: Define all task configurations and patient case data for the TriageFlow environment.
4
+ Part of: Medical Triage Assistant — OpenEnv Round 1
5
+ Author: Team Squirrel
6
+
7
+ Overview:
8
+ This module contains the hardcoded patient case data for all three tasks:
9
+ basic-triage (easy), incomplete-records-triage (medium), and mass-casualty-triage (hard).
10
+ Each task defines a set of patients with ground truth priority labels, as well as
11
+ task-specific configuration like max steps and which fields may be missing.
12
+
13
+ Dependencies:
14
+ - models: PriorityLevel, InfoField for type-safe enums
15
+
16
+ Usage:
17
+ from triage_flow.tasks import get_task_config, TASK_NAMES
18
+ """
19
+
20
+ from typing import Any, Dict, List
21
+
22
+ # ASSUMPTION: Ground truth priorities are stored here alongside patient data.
23
+ # They are stripped from the observation before being shown to the agent.
24
+
25
+
26
+ TASK_NAMES = ["basic-triage", "incomplete-records-triage", "mass-casualty-triage"]
27
+
28
+
29
+ def get_task_config(task_name: str) -> Dict[str, Any]:
30
+ """
31
+ Load the configuration for a given task.
32
+
33
+ Args:
34
+ task_name (str): One of the three defined task names.
35
+
36
+ Returns:
37
+ dict: Task configuration including patients, max_steps, and description.
38
+
39
+ Raises:
40
+ ValueError: If task_name is not recognized.
41
+ """
42
+ if task_name not in TASK_CONFIGS:
43
+ raise ValueError(
44
+ f"Unknown task: {task_name}. Available tasks: {list(TASK_CONFIGS.keys())}"
45
+ )
46
+ # Return a deep copy to prevent state mutation between episodes
47
+ import copy
48
+ return copy.deepcopy(TASK_CONFIGS[task_name])
49
+
50
+
51
+ # ============================================================================
52
+ # Task 1: Basic Triage (Easy)
53
+ # ============================================================================
54
+ # Scenario: 3 patients with complete records and clearly distinct urgency levels.
55
+ # One has chest pain and abnormal vitals, one has a sprained ankle, one has a mild fever.
56
+ # Agent Objective: Assign correct priority to all 3 patients.
57
+ # Grader Logic: 1.0 if all correct, 0.67 if 2 correct, 0.33 if 1 correct, 0.0 if none.
58
+ # Difficulty: Symptoms and vitals make urgency unambiguous. No missing data.
59
+
60
+ TASK1_PATIENTS = [
61
+ {
62
+ "patient_id": "P001",
63
+ "age": 62,
64
+ "chief_complaint": "Severe chest pain radiating to left arm",
65
+ "symptoms": [
66
+ "crushing chest pain",
67
+ "shortness of breath",
68
+ "diaphoresis",
69
+ "nausea",
70
+ "left arm numbness",
71
+ ],
72
+ "vitals": {
73
+ "heart_rate": 110,
74
+ "blood_pressure": "180/100",
75
+ "spo2": 91,
76
+ "temperature": 37.2,
77
+ },
78
+ "history": "History of hypertension and type 2 diabetes. Previous MI 3 years ago.",
79
+ "medications": ["metformin", "lisinopril", "aspirin"],
80
+ "allergies": ["penicillin"],
81
+ "info_complete": True,
82
+ "ground_truth_priority": "immediate",
83
+ },
84
+ {
85
+ "patient_id": "P002",
86
+ "age": 28,
87
+ "chief_complaint": "Twisted ankle while jogging",
88
+ "symptoms": [
89
+ "ankle swelling",
90
+ "mild pain on weight bearing",
91
+ "slight bruising",
92
+ ],
93
+ "vitals": {
94
+ "heart_rate": 72,
95
+ "blood_pressure": "120/78",
96
+ "spo2": 99,
97
+ "temperature": 36.8,
98
+ },
99
+ "history": "No significant medical history. Active lifestyle.",
100
+ "medications": [],
101
+ "allergies": [],
102
+ "info_complete": True,
103
+ "ground_truth_priority": "non_urgent",
104
+ },
105
+ {
106
+ "patient_id": "P003",
107
+ "age": 45,
108
+ "chief_complaint": "Persistent high fever for 3 days with productive cough",
109
+ "symptoms": [
110
+ "fever",
111
+ "productive cough",
112
+ "body aches",
113
+ "fatigue",
114
+ "mild shortness of breath",
115
+ ],
116
+ "vitals": {
117
+ "heart_rate": 95,
118
+ "blood_pressure": "130/85",
119
+ "spo2": 95,
120
+ "temperature": 39.2,
121
+ },
122
+ "history": "Smoker for 20 years. No other chronic conditions.",
123
+ "medications": [],
124
+ "allergies": ["sulfa drugs"],
125
+ "info_complete": True,
126
+ "ground_truth_priority": "urgent",
127
+ },
128
+ ]
129
+
130
+
131
+ # ============================================================================
132
+ # Task 2: Incomplete Records Triage (Medium)
133
+ # ============================================================================
134
+ # Scenario: 4 patients, 2 of whom have missing vitals or incomplete history.
135
+ # Agent must decide when to request info vs. assign based on available data.
136
+ # Grader Logic: Base score from priority accuracy. Bonus for appropriate info requests.
137
+ # Difficulty: Agent must reason about information value.
138
+
139
+ TASK2_PATIENTS = [
140
+ {
141
+ "patient_id": "P101",
142
+ "age": 55,
143
+ "chief_complaint": "Sudden severe headache, worst of life",
144
+ "symptoms": [
145
+ "thunderclap headache",
146
+ "neck stiffness",
147
+ "photophobia",
148
+ "nausea",
149
+ ],
150
+ "vitals": None, # MISSING — requesting vitals would be appropriate
151
+ "history": "Hypertension, family history of aneurysm.",
152
+ "medications": ["amlodipine"],
153
+ "allergies": [],
154
+ "info_complete": False,
155
+ "ground_truth_priority": "immediate",
156
+ # HIDDEN: Even without vitals, symptoms clearly indicate immediate priority.
157
+ # Requesting vitals is acceptable but not necessary for correct triage.
158
+ "missing_fields": ["vitals"],
159
+ "info_changes_decision": False, # Priority is clear even without vitals
160
+ },
161
+ {
162
+ "patient_id": "P102",
163
+ "age": 34,
164
+ "chief_complaint": "Abdominal pain and fatigue",
165
+ "symptoms": [
166
+ "generalized abdominal discomfort",
167
+ "fatigue",
168
+ "mild nausea",
169
+ ],
170
+ "vitals": {
171
+ "heart_rate": 82,
172
+ "blood_pressure": "125/80",
173
+ "spo2": 98,
174
+ "temperature": 37.0,
175
+ },
176
+ "history": None, # MISSING — requesting history is important here
177
+ "medications": None, # MISSING
178
+ "allergies": [],
179
+ "info_complete": False,
180
+ "ground_truth_priority": "less_urgent",
181
+ # HIDDEN: Without history, we might not know this person has Crohn's disease
182
+ # which could change urgency. Info request is beneficial.
183
+ "missing_fields": ["history", "medications"],
184
+ "info_changes_decision": True, # History would reveal chronic condition context
185
+ "revealed_history": "Diagnosed with Crohn's disease 5 years ago. Recent flare-ups.",
186
+ "revealed_medications": ["mesalamine", "prednisone"],
187
+ },
188
+ {
189
+ "patient_id": "P103",
190
+ "age": 72,
191
+ "chief_complaint": "Difficulty breathing and chest tightness",
192
+ "symptoms": [
193
+ "dyspnea on exertion",
194
+ "wheezing",
195
+ "chest tightness",
196
+ "cough",
197
+ ],
198
+ "vitals": {
199
+ "heart_rate": 100,
200
+ "blood_pressure": "145/90",
201
+ "spo2": 92,
202
+ "temperature": 37.1,
203
+ },
204
+ "history": "COPD, 40-year smoking history. Uses home oxygen intermittently.",
205
+ "medications": ["albuterol inhaler", "tiotropium", "home oxygen PRN"],
206
+ "allergies": ["aspirin"],
207
+ "info_complete": True,
208
+ "ground_truth_priority": "urgent",
209
+ "missing_fields": [],
210
+ "info_changes_decision": False,
211
+ },
212
+ {
213
+ "patient_id": "P104",
214
+ "age": 19,
215
+ "chief_complaint": "Sore throat and runny nose",
216
+ "symptoms": [
217
+ "sore throat",
218
+ "nasal congestion",
219
+ "mild cough",
220
+ "low-grade fever",
221
+ ],
222
+ "vitals": None, # MISSING
223
+ "history": "No significant medical history.",
224
+ "medications": [],
225
+ "allergies": None, # MISSING
226
+ "info_complete": False,
227
+ "ground_truth_priority": "non_urgent",
228
+ "missing_fields": ["vitals", "allergies"],
229
+ "info_changes_decision": False, # Clearly non-urgent regardless
230
+ },
231
+ ]
232
+
233
+
234
+ # ============================================================================
235
+ # Task 3: Mass Casualty Triage (Hard)
236
+ # ============================================================================
237
+ # Scenario: 8 patients arrive simultaneously following a building collapse.
238
+ # Some have conflicting symptom/vitals signals. Some have interacting conditions.
239
+ # Step budget is tight (20 steps for 8 patients).
240
+ # Agent Objective: Prioritize the queue correctly under time pressure.
241
+ # Difficulty: Conflicting signals, interacting conditions, step pressure.
242
+
243
+ TASK3_PATIENTS = [
244
+ {
245
+ "patient_id": "P201",
246
+ "age": 45,
247
+ "chief_complaint": "Trapped under rubble, severe leg crush injury",
248
+ "symptoms": [
249
+ "severe leg pain",
250
+ "visible deformity",
251
+ "bleeding from leg wound",
252
+ "confusion",
253
+ "tachycardia",
254
+ ],
255
+ "vitals": {
256
+ "heart_rate": 130,
257
+ "blood_pressure": "90/60",
258
+ "spo2": 94,
259
+ "temperature": 36.5,
260
+ },
261
+ "history": "No known medical history.",
262
+ "medications": [],
263
+ "allergies": [],
264
+ "info_complete": True,
265
+ "ground_truth_priority": "immediate",
266
+ # Crush injury with signs of hemorrhagic shock — highest priority
267
+ },
268
+ {
269
+ "patient_id": "P202",
270
+ "age": 67,
271
+ "chief_complaint": "Hit head on falling debris, on anticoagulants",
272
+ "symptoms": [
273
+ "scalp laceration",
274
+ "headache",
275
+ "mild confusion",
276
+ "dizziness",
277
+ ],
278
+ "vitals": {
279
+ "heart_rate": 88,
280
+ "blood_pressure": "150/85",
281
+ "spo2": 97,
282
+ "temperature": 36.9,
283
+ },
284
+ "history": "Atrial fibrillation, on warfarin. History of TIA.",
285
+ "medications": ["warfarin", "metoprolol", "lisinopril"],
286
+ "allergies": ["codeine"],
287
+ "info_complete": True,
288
+ "ground_truth_priority": "immediate",
289
+ # CRITICAL INTERACTION: Head injury + anticoagulant = high bleed risk
290
+ # Vitals look stable but the combination is life-threatening
291
+ },
292
+ {
293
+ "patient_id": "P203",
294
+ "age": 30,
295
+ "chief_complaint": "Arm laceration from broken glass",
296
+ "symptoms": [
297
+ "deep laceration on forearm",
298
+ "moderate bleeding",
299
+ "pain",
300
+ ],
301
+ "vitals": {
302
+ "heart_rate": 90,
303
+ "blood_pressure": "130/80",
304
+ "spo2": 98,
305
+ "temperature": 36.8,
306
+ },
307
+ "history": "Healthy, no chronic conditions.",
308
+ "medications": [],
309
+ "allergies": [],
310
+ "info_complete": True,
311
+ "ground_truth_priority": "less_urgent",
312
+ # Active bleeding but hemodynamically stable — can be managed with pressure
313
+ },
314
+ {
315
+ "patient_id": "P204",
316
+ "age": 8,
317
+ "chief_complaint": "Crying, complaining of arm pain after fall",
318
+ "symptoms": [
319
+ "arm pain",
320
+ "swelling at wrist",
321
+ "refuses to move arm",
322
+ "crying",
323
+ ],
324
+ "vitals": {
325
+ "heart_rate": 110,
326
+ "blood_pressure": "100/65",
327
+ "spo2": 99,
328
+ "temperature": 37.0,
329
+ },
330
+ "history": "No significant medical history. Up to date on vaccinations.",
331
+ "medications": [],
332
+ "allergies": ["amoxicillin"],
333
+ "info_complete": True,
334
+ "ground_truth_priority": "less_urgent",
335
+ # Likely fracture — painful but stable, not life-threatening
336
+ # NOTE: Elevated HR is normal for distressed child
337
+ },
338
+ {
339
+ "patient_id": "P205",
340
+ "age": 52,
341
+ "chief_complaint": "Dust inhalation, progressive breathing difficulty",
342
+ "symptoms": [
343
+ "coughing",
344
+ "wheezing",
345
+ "progressive dyspnea",
346
+ "chest tightness",
347
+ "hoarse voice",
348
+ ],
349
+ "vitals": {
350
+ "heart_rate": 115,
351
+ "blood_pressure": "140/90",
352
+ "spo2": 89,
353
+ "temperature": 37.1,
354
+ },
355
+ "history": "Asthma diagnosed in childhood.",
356
+ "medications": ["fluticasone inhaler"],
357
+ "allergies": [],
358
+ "info_complete": True,
359
+ "ground_truth_priority": "urgent",
360
+ # Inhalation injury with low SpO2 and asthma history — urgent
361
+ # Could deteriorate to immediate if airway compromise develops
362
+ },
363
+ {
364
+ "patient_id": "P206",
365
+ "age": 38,
366
+ "chief_complaint": "Minor scrapes and feeling shaken",
367
+ "symptoms": [
368
+ "superficial abrasions",
369
+ "anxiety",
370
+ "mild tremor",
371
+ ],
372
+ "vitals": {
373
+ "heart_rate": 100,
374
+ "blood_pressure": "135/85",
375
+ "spo2": 99,
376
+ "temperature": 36.9,
377
+ },
378
+ "history": "Anxiety disorder. Takes medication daily.",
379
+ "medications": ["sertraline"],
380
+ "allergies": [],
381
+ "info_complete": True,
382
+ "ground_truth_priority": "non_urgent",
383
+ # Minor injuries + anxiety response — non-urgent
384
+ # Elevated vitals are from anxiety, not injury severity
385
+ },
386
+ {
387
+ "patient_id": "P207",
388
+ "age": 75,
389
+ "chief_complaint": "Back pain and inability to walk after being knocked down",
390
+ "symptoms": [
391
+ "severe lower back pain",
392
+ "inability to bear weight",
393
+ "numbness in legs",
394
+ "urinary incontinence",
395
+ ],
396
+ "vitals": {
397
+ "heart_rate": 95,
398
+ "blood_pressure": "160/95",
399
+ "spo2": 96,
400
+ "temperature": 36.7,
401
+ },
402
+ "history": "Osteoporosis. Previous vertebral compression fracture.",
403
+ "medications": ["alendronate", "calcium supplement", "vitamin D"],
404
+ "allergies": [],
405
+ "info_complete": True,
406
+ "ground_truth_priority": "urgent",
407
+ # Numbness + incontinence = possible cauda equina syndrome — urgent
408
+ # Spinal injury with neurological signs needs urgent assessment
409
+ },
410
+ {
411
+ "patient_id": "P208",
412
+ "age": 22,
413
+ "chief_complaint": "Ringing in ears and small cut on forehead",
414
+ "symptoms": [
415
+ "tinnitus",
416
+ "minor forehead laceration",
417
+ "slight headache",
418
+ ],
419
+ "vitals": {
420
+ "heart_rate": 78,
421
+ "blood_pressure": "118/72",
422
+ "spo2": 99,
423
+ "temperature": 36.8,
424
+ },
425
+ "history": "No significant medical history.",
426
+ "medications": [],
427
+ "allergies": [],
428
+ "info_complete": True,
429
+ "ground_truth_priority": "non_urgent",
430
+ # Blast concussion effects — minor, monitor only
431
+ },
432
+ ]
433
+
434
+
435
+ # ============================================================================
436
+ # Task Configuration Registry
437
+ # ============================================================================
438
+
439
+ TASK_CONFIGS = {
440
+ "basic-triage": {
441
+ "task_name": "basic-triage",
442
+ "difficulty": "easy",
443
+ "description": (
444
+ "Three patients with complete records and clearly distinct urgency levels. "
445
+ "One has chest pain and abnormal vitals (IMMEDIATE), one has a sprained "
446
+ "ankle (NON_URGENT), and one has a persistent fever with cough (URGENT). "
447
+ "Assign the correct priority to all three."
448
+ ),
449
+ "patients": TASK1_PATIENTS,
450
+ "max_steps": 10,
451
+ "expected_actions": 3, # One assignment per patient
452
+ },
453
+ "incomplete-records-triage": {
454
+ "task_name": "incomplete-records-triage",
455
+ "difficulty": "medium",
456
+ "description": (
457
+ "Four patients, two with missing vitals or history. The agent must decide "
458
+ "when to request missing information versus triaging with available data. "
459
+ "Bonus credit for requesting info when it would change the decision."
460
+ ),
461
+ "patients": TASK2_PATIENTS,
462
+ "max_steps": 15,
463
+ "expected_actions": 4, # One assignment per patient (plus possible info requests)
464
+ },
465
+ "mass-casualty-triage": {
466
+ "task_name": "mass-casualty-triage",
467
+ "difficulty": "hard",
468
+ "description": (
469
+ "Eight patients from a building collapse. Some have conflicting signals "
470
+ "(e.g., stable vitals but on anticoagulants with head injury). Some have "
471
+ "interacting conditions. Step budget is tight at 20 steps for 8 patients. "
472
+ "Identify the two most critical patients first."
473
+ ),
474
+ "patients": TASK3_PATIENTS,
475
+ "max_steps": 20,
476
+ "expected_actions": 8, # One assignment per patient
477
+ },
478
+ }
uv.lock ADDED
The diff for this file is too large to render. See raw diff