Spaces:
Sleeping
Sleeping
Commit ·
0cb452d
0
Parent(s):
feat: complete TriageFlow environment for Hackathon Submission
Browse files- .github/workflows/sync_to_hub.yml +25 -0
- .gitignore +6 -0
- Dockerfile +12 -0
- PROGRESS.md +42 -0
- README.md +278 -0
- __init__.py +11 -0
- client.py +114 -0
- inference.py +381 -0
- models.py +234 -0
- openenv.yaml +10 -0
- pyproject.toml +32 -0
- requirements.txt +7 -0
- scripts/validate-submission.sh +75 -0
- server/Dockerfile +65 -0
- server/__init__.py +6 -0
- server/app.py +59 -0
- server/triage_flow_environment.py +544 -0
- temp_test_env/test_env/README.md +255 -0
- temp_test_env/test_env/__init__.py +16 -0
- temp_test_env/test_env/client.py +99 -0
- temp_test_env/test_env/models.py +27 -0
- temp_test_env/test_env/openenv.yaml +7 -0
- temp_test_env/test_env/pyproject.toml +45 -0
- temp_test_env/test_env/server/Dockerfile +80 -0
- temp_test_env/test_env/server/__init__.py +11 -0
- temp_test_env/test_env/server/app.py +84 -0
- temp_test_env/test_env/server/requirements.txt +6 -0
- temp_test_env/test_env/server/test_env_environment.py +104 -0
- temp_test_env/test_env/uv.lock +0 -0
- tests/run_integration_test.py +129 -0
- tests/test_endpoints.py +35 -0
- tests/test_environment.py +166 -0
- tests/test_graders.py +146 -0
- tests/test_http_flow.py +58 -0
- tests/test_inference_logging.py +66 -0
- tests/test_models.py +129 -0
- triage_flow/__init__.py +26 -0
- triage_flow/environment.py +544 -0
- triage_flow/graders.py +364 -0
- triage_flow/reward.py +246 -0
- triage_flow/tasks.py +478 -0
- uv.lock +0 -0
.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 |
+
[](https://github.com/meta-pytorch/OpenEnv)
|
| 16 |
+
[](https://opensource.org/licenses/MIT)
|
| 17 |
+
[](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
|
|
|