pratinavseth commited on
Commit
5344146
·
verified ·
1 Parent(s): a949d75

Deploy: claude-code-for-health

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/architecture.png filter=lfs diff=lfs merge=lfs -text
37
+ data/MedCalcBench/train_data.csv filter=lfs diff=lfs merge=lfs -text
CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ ## [Unreleased]
6
+
7
+ ### Changed
8
+ - **inference.py**: Set `TEMPERATURE = 0.0` for reproducible baseline (was 0.3)
9
+ - **inference.py**: Added explicit `ValueError` if `HF_TOKEN` environment variable is missing
10
+ - **inference.py**: Clarified score clamping to `(0.01, 0.99)` for normalization epsilon
11
+ - **README.md**: Updated Tasks table with "Est. Frontier Score" column for each difficulty tier
12
+ - Clinical Note Review (Easy): 0.70–0.85
13
+ - Medical Calculation (Medium): 0.45–0.65
14
+ - Diagnostic Workup (Hard): 0.30–0.50
15
+ - **README.md**: Corrected dataset sizes to reflect actual file counts
16
+ - MEDEC: 55,394 total (training 36,766 / validation 9,129 / test 9,499)
17
+ - MedCalc-Bench: ~138,000 total (numeric-answer rows only; train 136,969 / test 16,521)
18
+ - MedCaseReasoning: 766 structured clinical cases
19
+ - **README.md**: Updated headline from "15,000+ real cases" to "194,000+ real cases"
20
+ - **README.md**: Added note: "Each inference run picks one random case per difficulty level"
21
+ - **README.md**: Added "Prior Work & Novelty" section with SDBench (Nori et al., 2025) citation
22
+ - **README.md**: Expanded novelty claims with three key additions:
23
+ 1. Multi-skill integration (note review + calculation + diagnosis)
24
+ 2. Developer-native CLI interaction (same as Claude Code / aider / Codex)
25
+ 3. Scale (194,000+ real cases, not curated vignettes)
26
+ - **README.md**: Added Pydantic class definitions to Action/Observation space section
27
+ - **README.md**: Added JSON action format examples to Action space
28
+ - **README.md**: Added compliance note to Baseline Scores section on runtime and stdout format
29
+ - **GitHub**: Created public repository at https://github.com/pratinavseth/claude-code-for-health
30
+
31
+ ### Fixed
32
+ - **inference.py**: Reverted score clamping from `(0.0, 1.0)` back to `(0.01, 0.99)` (epsilon required for normalization)
33
+ - **README.md**: Corrected all dataset row counts (external analysis had incorrect numbers)
34
+ - **README.md**: Clarified that 194,000+ is the full data pool, not per-run consumption
35
+
36
+ ### Notes
37
+ - All changes maintain backward compatibility with OpenEnv spec
38
+ - Baseline inference completes in <5 minutes on standard hardware
39
+ - Docker build and HuggingFace Space deployment remain compatible
Dockerfile ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=claude_code_for_health
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
+ ENV ENABLE_WEB_INTERFACE=true
74
+
75
+ # Health check
76
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
77
+ CMD curl -f http://localhost:8000/health || exit 1
78
+
79
+ # Run the FastAPI server
80
+ # The module path is constructed to work with the /app/env structure
81
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
README.md CHANGED
@@ -1,10 +1,287 @@
1
  ---
2
- title: Claude Code For Health
3
- emoji: 🌖
4
- colorFrom: pink
5
  colorTo: yellow
6
  sdk: docker
7
  pinned: false
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Claude Code For Health Environment Server
3
+ emoji: ⏲️
4
+ colorFrom: yellow
5
  colorTo: yellow
6
  sdk: docker
7
  pinned: false
8
+ app_port: 8000
9
+ base_path: /web
10
+ tags:
11
+ - openenv
12
  ---
13
 
14
+ # Claude Code for Health
15
+
16
+ Medical AI is mostly tested on multiple-choice questions. This environment tests something harder: can an agent actually *do* clinical work, step by step, the way a real clinician does?
17
+
18
+ Three task types, 194,000+ real cases, fully programmatic grading.
19
+
20
+ ## Motivation
21
+
22
+ Medical errors are the third leading cause of death in the US, yet most AI benchmarks for medicine (MedQA, USMLE, PubMedQA) hand the model a complete question and ask for a single answer. That measures pattern recognition, not judgment.
23
+
24
+ Real clinical work looks nothing like that. A clinician doesn't receive a pre-packaged summary — they read a history, order the right tests, interpret results in context, update their hypothesis, and commit to a plan. The reasoning happens over time, with incomplete information at every step.
25
+
26
+ This environment forces that workflow. The agent interacts with a CLI — the same metaphor used by Claude Code, aider, and Codex for software engineering — but applied to patient care. It can only see what it explicitly requests, and it's graded on the full trajectory: what it looked up, how efficiently it got there, and whether its final answer was right.
27
+
28
+ ## Architecture
29
+
30
+ ![Architecture](assets/architecture.png)
31
+
32
+ ## Tasks
33
+
34
+ | Task | Difficulty | Description | Dataset | Cases | Est. Frontier Score |
35
+ |---|---|---|---|---|---|
36
+ | **Clinical Note Review** | Easy | Read a clinical note, identify errors, correct them or approve | MEDEC | 55,394 | 0.70–0.85 |
37
+ | **Medical Calculation** | Medium | Read a patient scenario, identify the formula, compute the answer | MedCalc-Bench | ~138,000 | 0.45–0.65 |
38
+ | **Diagnostic Workup** | Hard | Explore a patient chart via CLI tools, build a differential, confirm diagnosis | MedCaseReasoning | 766 | 0.30–0.50 |
39
+
40
+ ## Datasets
41
+
42
+ - **MEDEC** — 55,394 clinical notes with annotated errors and corrections across 3 splits (training: 36,766 / validation: 9,129 / test: 9,499)
43
+ - **MedCalc-Bench** — ~138,000 medical calculation problems with ground truth answers and tolerance bounds (train: 136,969 / test: 16,521; numeric-answer rows only)
44
+ - **MedCaseReasoning** — 766 structured clinical cases with demographics, vitals, labs, imaging, physical exam, and ground truth diagnoses (JSONL)
45
+
46
+ Each inference run picks one random case per difficulty level — the full pools are available for training and evaluation at scale.
47
+
48
+ ## Prior Work & Novelty
49
+
50
+ Most medical AI benchmarks test static knowledge: MedQA, USMLE, and PubMedQA all hand the model a complete question and ask for a single answer. That measures pattern recognition on full context, not the sequential decision-making that clinical work actually requires.
51
+
52
+ The closest sequential benchmark is **SDBench** (Nori et al., 2025 — [arXiv:2506.22405](https://arxiv.org/abs/2506.22405)), which converts 304 NEJM case records into interactive diagnosis. Physicians scored only ~36% on hard cases, confirming that sequential reasoning under information constraints is a genuine challenge.
53
+
54
+ This environment extends that line of work with three key additions:
55
+ 1. **Multi-skill integration** — note error correction, quantitative reasoning, and sequential diagnosis in one unified CLI interface, rather than diagnosis alone
56
+ 2. **Developer-native interaction** — pure CLI commands (exactly like Claude Code / aider / Codex), making this the first medical environment optimized for tool-use agents that already excel at software tasks
57
+ 3. **194,000+ real cases** from MEDEC, MedCalc-Bench, and MedCaseReasoning — not curated vignettes; dense per-step rewards mirror real clinical cost-efficiency trade-offs
58
+
59
+ ## Action / Observation Space
60
+
61
+ **Action** — one CLI command string per step:
62
+ ```python
63
+ class MedAction(Action):
64
+ command: str = Field(..., description="CLI command string, e.g. 'chart.labs CBC'")
65
+ ```
66
+
67
+ The agent sends a single JSON object to the `/step` endpoint:
68
+ ```json
69
+ {"command": "chart.labs CBC"}
70
+ {"command": "ddx.confirm Adult-onset Still disease"}
71
+ {"command": "submit 4.5"}
72
+ {"command": "note.correct 3 Patient presented with altered mental status."}
73
+ ```
74
+
75
+ Example command strings:
76
+ ```
77
+ chart.history # view past medical history
78
+ chart.labs CBC # view CBC panel results
79
+ ddx.add Pulmonary Embolism # add to differential
80
+ ddx.confirm Pulmonary Embolism # submit final diagnosis (ends episode)
81
+ calculate Wells_PE # declare which calculator to use
82
+ submit 4.5 # submit numeric answer (ends episode)
83
+ note.correct 3 Corrected text here # fix error in sentence 3
84
+ note.approve # approve note as-is (ends episode)
85
+ ```
86
+
87
+ **Observation** — command output + episode context after each step:
88
+ ```python
89
+ class MedObservation(Observation):
90
+ output: str # Command output text (chart data, lab results, etc.)
91
+ error: str # Error message if command was invalid (empty string if none)
92
+ available_commands: list[str] # Tools valid for the current task type
93
+ task_type: str # "diagnosis" | "calculation" | "note_review"
94
+ step_number: int # Current step count
95
+ max_steps: int # Hard cap (50 steps per episode)
96
+ done: bool # Inherited — episode complete?
97
+ reward: float # Inherited — step reward
98
+ metadata: dict # Inherited — additional info
99
+ ```
100
+
101
+ **State** — episode tracking via GET /state:
102
+ ```python
103
+ class MedState(State):
104
+ episode_id: str # Inherited — unique episode identifier
105
+ step_count: int # Inherited — total steps taken
106
+ task_type: str # "diagnosis" | "calculation" | "note_review"
107
+ difficulty: str # "easy" | "medium" | "hard"
108
+ total_score: float # Cumulative reward accumulated so far
109
+ commands_issued: int # Number of commands sent
110
+ is_submitted: bool # Whether the agent has submitted a final answer
111
+ ```
112
+
113
+ ## Available Tools
114
+
115
+ The environment simulates a real CLI tool interface - the same interaction pattern used by Claude Code, OpenCode, and Codex CLI for software engineering, but applied to clinical medicine. The agent issues text commands one at a time, receives structured output, and decides what to do next. No menus, no dropdowns - just a terminal and clinical judgment.
116
+
117
+ ### Diagnosis Tools
118
+ ```
119
+ chart.history View past medical history, medications, allergies
120
+ chart.vitals View vital signs
121
+ chart.labs [panel] View lab results (list panels or view specific)
122
+ chart.imaging [type] View imaging findings
123
+ chart.exam [system] View physical exam findings
124
+ chart.medications View current medications
125
+ chart.allergies View known allergies
126
+ ddx.add <diagnosis> Add to differential
127
+ ddx.remove <diagnosis> Remove from differential
128
+ ddx.list Show current differential
129
+ ddx.confirm <diagnosis> Submit final diagnosis (ends episode)
130
+ ```
131
+
132
+ ### Calculation Tools
133
+ ```
134
+ case.read Read the full patient note + question
135
+ calculate <name> Declare which calculator you're using
136
+ submit <number> Submit numeric answer (ends episode)
137
+ ```
138
+
139
+ ### Note Review Tools
140
+ ```
141
+ note.read Read the clinical note with numbered sentences
142
+ note.correct <id> <text> Correct a sentence by ID
143
+ note.approve Approve note / submit corrections (ends episode)
144
+ ```
145
+
146
+ ### Reference Tools (all tasks)
147
+ ```
148
+ reference.ranges <test> Normal range lookup (e.g. sodium, troponin)
149
+ reference.criteria <condition> Diagnostic criteria (e.g. DKA, sepsis, PE)
150
+ reference.drug_info <drug> Drug mechanism, indications, contraindications
151
+ interpret <test> <value> Interpret a lab value against normal range
152
+ ```
153
+
154
+ ## Reward Design
155
+
156
+ Dense rewards over the full trajectory. Every step can yield signal, not just the terminal action.
157
+
158
+ | Task | Intermediate Budget | Terminal Budget | Total |
159
+ |---|---|---|---|
160
+ | Note Review | 0.10 (read note) | 0.90 (detection + correction quality) | 1.0 |
161
+ | Calculation | 0.15 (read case + declare calculator) | 0.85 (numeric accuracy + correct calculator + efficiency) | 1.0 |
162
+ | Diagnosis | 0.30 (chart exploration credit per relevant section) | 0.70 (diagnostic accuracy + workup completeness + efficiency + reasoning) | 1.0 |
163
+
164
+ **Penalties:**
165
+ - Protocol violations: -0.05 (imaging without vitals, confirming with <2 differentials, specialized labs without basic panels)
166
+ - Duplicate tool calls: -0.05
167
+
168
+ ## Baseline Scores
169
+
170
+ Model: `meta-llama/Llama-3.1-8B-Instruct` via HuggingFace Router (20 runs, random case per difficulty):
171
+
172
+ | Task | Avg Score | Min | Max |
173
+ |---|---|---|---|
174
+ | Easy (note review) | 0.49 | 0.19 | 0.73 |
175
+ | Medium (calculation) | 0.27 | 0.01 | 0.84 |
176
+ | Hard (diagnosis) | 0.22 | 0.12 | 0.41 |
177
+
178
+ `inference.py` produces the exact `[START]` / `[STEP]` / `[END]` stdout format required by OpenEnv and completes all 3 tasks in under 5 minutes on standard hardware.
179
+
180
+ ## Example Episode (Diagnosis - Hard)
181
+
182
+ ```
183
+ > reset(options={"task": "hard"})
184
+ Patient: 45M, presenting with fever, rash, and joint pain
185
+ Type 'help' for available tools.
186
+
187
+ > chart.history reward: +0.02
188
+ PMH: None significant
189
+ Medications: None
190
+ Social: Non-smoker, occasional alcohol
191
+
192
+ > chart.vitals reward: +0.02
193
+ BP: 130/85 | HR: 102 | Temp: 39.2C | RR: 18 | SpO2: 98%
194
+
195
+ > chart.labs reward: 0.00
196
+ Available lab panels: CBC, BMP, inflammatory_markers, LFTs
197
+
198
+ > chart.labs inflammatory_markers reward: +0.02
199
+ inflammatory_markers:
200
+ ESR: 85 mm/hr
201
+ CRP: 12.4 mg/dL
202
+ Ferritin: 26,250 ng/mL
203
+
204
+ > reference.ranges ferritin reward: 0.00
205
+ FERRITIN: Normal range 12-300 ng/mL
206
+ Female 12-150, Male 12-300. Very high in HLH, Still disease
207
+
208
+ > interpret ferritin 26250 reward: 0.00
209
+ FERRITIN 26250.0 ng/mL: HIGH - critically elevated (normal 12-300)
210
+ Female 12-150, Male 12-300. Very high in HLH, Still disease
211
+
212
+ > reference.criteria hlh reward: 0.00
213
+ HLH (HScore): Fever, organomegaly, cytopenias (2-3 lineages),
214
+ hypertriglyceridemia (>=265) or hypofibrinogenemia (<=150),
215
+ ferritin >=500 (often >10,000), elevated soluble CD25...
216
+
217
+ > ddx.add HLH reward: 0.00
218
+ Added 'HLH'. Differential has 1 entry(ies).
219
+
220
+ > ddx.add Adult-onset Still disease reward: 0.00
221
+ Added 'Adult-onset Still disease'. Differential has 2 entry(ies).
222
+
223
+ > ddx.confirm Adult-onset Still disease reward: +0.34
224
+ Diagnosis submitted: 'Adult-onset Still disease'. Score: 0.34
225
+
226
+ [STATUS] DDX: [HLH, Adult-onset Still disease] | Step: 10/50
227
+ Total episode score: 0.40
228
+ ```
229
+
230
+ The agent earned intermediate rewards for each relevant chart section explored (+0.02 each), used reference tools to interpret the critically elevated ferritin (no reward, but informed its reasoning), built a 2-item differential (avoiding the -0.05 penalty), and got partial terminal credit for a close but not exact diagnosis match.
231
+
232
+ ## Setup
233
+
234
+ ```bash
235
+ # Install
236
+ uv sync
237
+
238
+ # Run server
239
+ uv run uvicorn server.app:app --port 8000
240
+
241
+ # Run inference (set HF_TOKEN first)
242
+ export HF_TOKEN="your_token"
243
+ uv run python inference.py
244
+ ```
245
+
246
+ ## Docker
247
+
248
+ ```bash
249
+ docker build -t claude_code_for_health .
250
+ docker run -p 8000:8000 claude_code_for_health
251
+ ```
252
+
253
+ ## Environment Variables
254
+
255
+ | Variable | Description | Default |
256
+ |---|---|---|
257
+ | `API_BASE_URL` | LLM endpoint | `https://router.huggingface.co/v1` |
258
+ | `MODEL_NAME` | Model identifier | `meta-llama/Llama-3.1-8B-Instruct` |
259
+ | `HF_TOKEN` | HuggingFace API key | (required) |
260
+ | `IMAGE_NAME` | Docker image for `from_docker_image()` | (optional) |
261
+
262
+ ## Project Structure
263
+
264
+ ```
265
+ claude_code_for_health/
266
+ ├── Dockerfile # Container image definition
267
+ ├── openenv.yaml # OpenEnv manifest
268
+ ├── pyproject.toml # Dependencies
269
+ ├── inference.py # Baseline inference script
270
+ ├── models.py # MedAction, MedObservation, MedState
271
+ ├── client.py # EnvClient wrapper
272
+ ├── __init__.py # Module exports
273
+ ├── data/
274
+ │ ├── MedCaseReasoning/ # Diagnosis cases (JSONL)
275
+ │ ├── MedCalcBench/ # Calculation cases (CSV)
276
+ │ ├── MEDEC/ # Note review cases (CSV)
277
+ │ └── reference/ # Lab ranges, criteria, drug info (JSON)
278
+ └── server/
279
+ ├── app.py # FastAPI application
280
+ ├── claude_code_for_health_environment.py # Core environment
281
+ ├── command_parser.py # CLI command parsing
282
+ ├── data_loader.py # Dataset loading
283
+ ├── task_configs.py # Difficulty tiers + case selection
284
+ ├── graders.py # Dense reward functions
285
+ ├── constants.py # Reference data loader
286
+ └── ui.py # Custom Gradio dashboard
287
+ ```
__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from .client import ClaudeCodeForHealthEnv
2
+ from .models import MedAction, MedObservation, MedState
3
+
4
+ __all__ = [
5
+ "MedAction",
6
+ "MedObservation",
7
+ "MedState",
8
+ "ClaudeCodeForHealthEnv",
9
+ ]
assets/architecture.png ADDED

Git LFS Details

  • SHA256: 7f2bb915fbbc35eaf7db6f9efa07020e46b4b0ceb79cff580c6a76bf786bd289
  • Pointer size: 131 Bytes
  • Size of remote file: 238 kB
client.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Client for the Claude Code for Health environment."""
2
+
3
+ from typing import Dict
4
+
5
+ from openenv.core import EnvClient
6
+ from openenv.core.client_types import StepResult
7
+ from .models import MedAction, MedObservation, MedState
8
+
9
+
10
+ class ClaudeCodeForHealthEnv(
11
+ EnvClient[MedAction, MedObservation, MedState]
12
+ ):
13
+ def _step_payload(self, action: MedAction) -> Dict:
14
+ return {"command": action.command}
15
+
16
+ def _parse_result(self, payload: Dict) -> StepResult[MedObservation]:
17
+ obs_data = payload.get("observation", {})
18
+ observation = MedObservation(
19
+ output=obs_data.get("output", ""),
20
+ error=obs_data.get("error", ""),
21
+ available_commands=obs_data.get("available_commands", []),
22
+ task_type=obs_data.get("task_type", ""),
23
+ step_number=obs_data.get("step_number", 0),
24
+ max_steps=obs_data.get("max_steps", 50),
25
+ done=payload.get("done", False),
26
+ reward=payload.get("reward"),
27
+ metadata=obs_data.get("metadata", {}),
28
+ )
29
+ return StepResult(
30
+ observation=observation,
31
+ reward=payload.get("reward"),
32
+ done=payload.get("done", False),
33
+ )
34
+
35
+ def _parse_state(self, payload: Dict) -> MedState:
36
+ return MedState(
37
+ episode_id=payload.get("episode_id"),
38
+ step_count=payload.get("step_count", 0),
39
+ task_type=payload.get("task_type", ""),
40
+ difficulty=payload.get("difficulty", "easy"),
41
+ total_score=payload.get("total_score", 0.0),
42
+ commands_issued=payload.get("commands_issued", 0),
43
+ is_submitted=payload.get("is_submitted", False),
44
+ )
data/MEDEC/MEDEC-Full-TrainingSet-with-ErrorType.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/MEDEC/MEDEC-MS-TestSet-with-GroundTruth-and-ErrorType.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/MEDEC/MEDEC-MS-ValidationSet-with-GroundTruth-and-ErrorType.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/MedCalcBench/one_shot_data.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/MedCalcBench/test_data.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/MedCalcBench/train_data.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:503db8197c55438640e66bb8a20a114ef2d5e8c6a1b12b79ccd1aa9c3e33b2c1
3
+ size 51145140
data/MedCaseReasoning/extracted_cases.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
data/MedCaseReasoning/extraction_errors.jsonl ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ {"pmcid": "PMC10399059", "error": "list index out of range", "final_diagnosis": "Streptococcus equi subspecies equi"}
2
+ {"pmcid": "PMC6881760", "error": "Unterminated string starting at: line 145 column 7 (char 5668)", "final_diagnosis": "Intravascular large B-cell lymphoma"}
data/reference/diagnostic_criteria.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "dka": "DKA (Diabetic Ketoacidosis): pH <7.3, Serum bicarbonate <18 mEq/L, Blood glucose >250 mg/dL (or euglycemic if on SGLT2i), Positive serum/urine ketones, Anion gap >12. Severity: Mild (pH 7.25-7.30), Moderate (7.0-7.24), Severe (<7.0)",
3
+ "diabetic ketoacidosis": "DKA: pH <7.3, HCO3 <18, glucose >250, ketones positive, AG >12. Mild/Moderate/Severe by pH cutoffs 7.25/7.0",
4
+ "sepsis": "Sepsis (Sepsis-3): Suspected infection + SOFA score increase >=2. qSOFA (screening): >=2 of: RR >=22, altered mentation (GCS <15), SBP <=100 mmHg",
5
+ "sirs": "SIRS: >=2 of: Temp >38C or <36C, HR >90, RR >20 or PaCO2 <32, WBC >12k or <4k or >10% bands",
6
+ "pe": "Wells Criteria for PE: Clinical signs of DVT (+3), PE most likely (+3), HR >100 (+1.5), Immobilization/surgery (+1.5), Previous PE/DVT (+1.5), Hemoptysis (+1), Malignancy (+1). Low <2, Moderate 2-6, High >6",
7
+ "pulmonary embolism": "Wells Criteria: DVT signs (+3), PE most likely (+3), HR>100 (+1.5), immobilization (+1.5), prior PE/DVT (+1.5), hemoptysis (+1), cancer (+1). Score >4: consider CTPA",
8
+ "dvt": "Wells Criteria for DVT: Active cancer (+1), Paralysis/cast (+1), Bedridden >3d or surgery <12wk (+1), Tenderness along deep veins (+1), Entire leg swollen (+1), Calf >3cm (+1), Pitting edema (+1), Collateral veins (+1), Prior DVT (+1), Alternative dx as likely (-2). Low 0, Moderate 1-2, High >=3",
9
+ "heart failure": "Framingham Criteria for CHF: Major: PND, JVD, rales, cardiomegaly, S3, hepatojugular reflux, weight loss on diuretics. Minor: ankle edema, night cough, dyspnea on exertion, hepatomegaly, pleural effusion, HR>120. Diagnosis: 2 major OR 1 major + 2 minor",
10
+ "chf": "Framingham Criteria: 2 major criteria OR 1 major + 2 minor. BNP >400 supports dx. LVEF <40% = HFrEF, >=50% = HFpEF",
11
+ "mi": "STEMI: ST elevation >=1mm in >=2 contiguous leads (>=2mm in V1-V3). NSTEMI: Elevated troponin + ischemic symptoms without ST elevation. Type 1: plaque rupture. Type 2: demand ischemia",
12
+ "stroke": "NIH Stroke Scale for severity. CT head to rule out hemorrhage. tPA within 4.5h (NINDS criteria). Large vessel occlusion: consider thrombectomy within 24h",
13
+ "aki": "AKI (KDIGO): Stage 1: Cr increase >=0.3 mg/dL in 48h or 1.5-1.9x baseline. Stage 2: Cr 2.0-2.9x baseline. Stage 3: Cr >=3x baseline or Cr >=4.0 or initiation of RRT. Also UOP <0.5 mL/kg/h for 6h",
14
+ "ckd": "CKD: GFR <60 for >3 months. Stage 1: GFR>=90 (with kidney damage), Stage 2: 60-89, Stage 3a: 45-59, Stage 3b: 30-44, Stage 4: 15-29, Stage 5: <15",
15
+ "meningitis": "Bacterial meningitis: fever, nuchal rigidity, altered mental status (classic triad in ~44%). CSF: WBC >1000 (PMN predominant), protein >250, glucose <40 (or CSF/serum ratio <0.4), positive gram stain/culture. Kernig/Brudzinski signs. Empiric: ceftriaxone + vancomycin \u00b1 ampicillin (>50y or immunocompromised)",
16
+ "pancreatitis": "Acute pancreatitis: >=2 of 3: (1) Abdominal pain consistent with pancreatitis, (2) Serum lipase >=3x ULN, (3) Characteristic findings on imaging. Ranson criteria for severity. BISAP score for mortality",
17
+ "cirrhosis": "Child-Pugh Score: Bilirubin, Albumin, INR, Ascites, Encephalopathy. Class A: 5-6 (compensated), Class B: 7-9 (significant), Class C: 10-15 (decompensated). MELD for transplant prioritization",
18
+ "pneumonia": "CAP: CURB-65 for severity: Confusion, Urea >7, RR >=30, BP <90/60, Age >=65. Score 0-1: outpatient, 2: short stay, 3-5: ICU consideration. PSI/PORT score alternative",
19
+ "gout": "Gout: Monosodium urate crystals (needle-shaped, negatively birefringent). ACR/EULAR criteria: joint involvement, serum urate >6, acute episode features, tophi, imaging evidence",
20
+ "hlh": "HLH (HScore): Fever, organomegaly, cytopenias (2-3 lineages), hypertriglyceridemia (>=265) or hypofibrinogenemia (<=150), hemophagocytosis on biopsy, low/absent NK activity, ferritin >=500 (often >10,000), elevated soluble CD25",
21
+ "ards": "ARDS (Berlin Definition): Acute onset within 1 week, bilateral opacities on imaging, not fully explained by cardiac failure/fluid overload, PaO2/FiO2: Mild 200-300, Moderate 100-200, Severe <100 (with PEEP >=5)",
22
+ "dic": "DIC: Prolonged PT/PTT, low platelets, low fibrinogen, elevated D-dimer, schistocytes on smear. ISTH DIC score >=5 = overt DIC. Treat underlying cause. Acute (bleeding): replace factors. Chronic (clotting): anticoagulation"
23
+ }
data/reference/drug_info.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "metformin": "Biguanide. Mechanism: Decreases hepatic glucose production, increases insulin sensitivity. Indications: Type 2 DM (first-line). Contraindications: eGFR <30, acute/chronic metabolic acidosis. Key: Hold before contrast, risk of lactic acidosis. Max 2550 mg/day",
3
+ "warfarin": "Vitamin K antagonist. Mechanism: Inhibits factors II, VII, IX, X and proteins C/S. Indications: AF, DVT/PE, mechanical valves. Monitoring: INR (target 2-3, or 2.5-3.5 for mechanical valves). Reversal: Vitamin K, FFP, PCC. Many drug/food interactions",
4
+ "heparin": "Unfractionated heparin. Mechanism: Potentiates antithrombin III, inhibits thrombin and factor Xa. Monitoring: aPTT (target 1.5-2.5x control). Reversal: protamine sulfate. Risk: HIT (check platelets). Weight-based dosing: 80 U/kg bolus, 18 U/kg/hr",
5
+ "enoxaparin": "LMWH. Mechanism: Anti-Xa > anti-IIa activity. Indications: DVT/PE treatment and prophylaxis. Dosing: Treatment 1 mg/kg BID or 1.5 mg/kg daily. Renal adjustment: CrCl <30 \u2192 1 mg/kg daily. Monitoring: Anti-Xa levels (trough 0.5-1.0). Partial reversal with protamine",
6
+ "aspirin": "NSAID/Antiplatelet. Mechanism: Irreversibly inhibits COX-1 \u2192 blocks TXA2. Indications: ACS, secondary prevention CVD, Kawasaki disease. Dose: 81mg (prevention), 325mg (acute ACS). Contraindications: Active bleeding, aspirin-exacerbated respiratory disease. Reye syndrome risk in children",
7
+ "clopidogrel": "P2Y12 inhibitor. Mechanism: Irreversibly blocks ADP receptor on platelets. Indications: ACS, PCI stenting (with aspirin), stroke prevention. Loading dose: 300-600mg. Maintenance: 75mg daily. CYP2C19 poor metabolizers: consider prasugrel or ticagrelor",
8
+ "lisinopril": "ACE inhibitor. Mechanism: Blocks ACE \u2192 decreases angiotensin II and aldosterone. Indications: HTN, HFrEF, post-MI, diabetic nephropathy. Contraindications: Bilateral renal artery stenosis, pregnancy, angioedema history. Monitor: K+, creatinine. Dry cough \u2192 switch to ARB",
9
+ "losartan": "ARB. Mechanism: Blocks AT1 receptor. Indications: HTN, diabetic nephropathy, HF (if ACE-intolerant). Contraindications: Pregnancy, bilateral renal artery stenosis. Advantage: No cough (unlike ACEi). Monitor K+, creatinine",
10
+ "amlodipine": "Calcium channel blocker (dihydropyridine). Mechanism: Blocks L-type Ca channels in vascular smooth muscle. Indications: HTN, angina. Side effects: Peripheral edema, flushing, headache. Does not affect HR significantly. Safe in HFrEF",
11
+ "metoprolol": "Beta-1 selective blocker. Mechanism: Blocks cardiac beta-1 receptors \u2192 decreased HR, contractility, BP. Indications: HTN, HFrEF (succinate), rate control AF, post-MI. Contraindications: Decompensated HF, severe bradycardia, 2nd/3rd degree AVB. Tartrate (BID) vs Succinate (daily, for HF)",
12
+ "carvedilol": "Non-selective beta + alpha-1 blocker. Mechanism: Beta blockade (negative chronotropy/inotropy) + alpha blockade (vasodilation). Indications: HFrEF (mortality benefit), HTN. Dose: Start 3.125mg BID, titrate to 25mg BID. Contraindications: Decompensated HF, reactive airway disease, severe bradycardia",
13
+ "furosemide": "Loop diuretic. Mechanism: Inhibits Na-K-2Cl cotransporter in thick ascending limb. Indications: Edema (HF, cirrhosis, nephrotic), HTN, acute pulmonary edema. IV:PO ratio 1:2. Monitor: K+, Mg2+, creatinine, uric acid. Ototoxicity at high doses",
14
+ "amoxicillin": "Aminopenicillin. Mechanism: Inhibits cell wall synthesis (PBP binding). Indications: Otitis media, sinusitis, UTI, H. pylori (triple therapy), dental infections. Spectrum: Strep, E. coli, H. influenzae. Resistance: beta-lactamase producers \u2192 add clavulanate",
15
+ "ceftriaxone": "3rd-gen cephalosporin. Mechanism: Inhibits cell wall synthesis. Indications: Meningitis, pneumonia, UTI, gonorrhea, Lyme disease. Spectrum: Broad gram-negative + some gram-positive. Crosses BBB. Do not mix with calcium-containing solutions (neonates). IM or IV",
16
+ "vancomycin": "Glycopeptide. Mechanism: Inhibits cell wall synthesis by binding D-Ala-D-Ala. Indications: MRSA, C. difficile (PO), endocarditis. Monitoring: Trough 15-20 mcg/mL (serious infections) or AUC/MIC. Toxicity: Red man syndrome (histamine, slow infusion), nephrotoxicity, ototoxicity",
17
+ "prednisone": "Glucocorticoid. Mechanism: Anti-inflammatory, immunosuppressive (NF-kB inhibition, decreased cytokines). Indications: Asthma exacerbation, autoimmune diseases, allergic reactions, adrenal insufficiency. Taper if >2 weeks use. Side effects: Hyperglycemia, osteoporosis, adrenal suppression, immunosuppression",
18
+ "insulin": "Hormone. Mechanism: Binds insulin receptor \u2192 glucose uptake, glycogen synthesis, lipogenesis. Types: Rapid (lispro, aspart), Short (regular), Intermediate (NPH), Long (glargine, detemir). DKA: IV regular insulin drip. Hypoglycemia is main risk. Sliding scale for inpatients",
19
+ "acetaminophen": "Analgesic/antipyretic. Mechanism: Central COX inhibition (not peripheral). Indications: Pain, fever. Max: 4g/day (2g/day in liver disease). Toxicity: Hepatotoxicity (NAPQI accumulation). Antidote: N-acetylcysteine (NAC). Rumack-Matthew nomogram for overdose",
20
+ "ibuprofen": "NSAID. Mechanism: Non-selective COX-1/COX-2 inhibitor. Indications: Pain, inflammation, fever. Contraindications: Active GI bleed, CKD stage 4-5, post-CABG, third trimester pregnancy. Risks: GI bleed, renal impairment, CV events. Max: 3200 mg/day"
21
+ }
data/reference/lab_ranges.json ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "sodium": {
3
+ "low": 136,
4
+ "high": 145,
5
+ "unit": "mEq/L",
6
+ "context": "Hyponatremia <136, Hypernatremia >145"
7
+ },
8
+ "na": {
9
+ "low": 136,
10
+ "high": 145,
11
+ "unit": "mEq/L",
12
+ "context": "Hyponatremia <136, Hypernatremia >145"
13
+ },
14
+ "potassium": {
15
+ "low": 3.5,
16
+ "high": 5.0,
17
+ "unit": "mEq/L",
18
+ "context": "Hypokalemia <3.5, Hyperkalemia >5.0"
19
+ },
20
+ "k": {
21
+ "low": 3.5,
22
+ "high": 5.0,
23
+ "unit": "mEq/L",
24
+ "context": "Hypokalemia <3.5, Hyperkalemia >5.0"
25
+ },
26
+ "chloride": {
27
+ "low": 98,
28
+ "high": 106,
29
+ "unit": "mEq/L",
30
+ "context": ""
31
+ },
32
+ "cl": {
33
+ "low": 98,
34
+ "high": 106,
35
+ "unit": "mEq/L",
36
+ "context": ""
37
+ },
38
+ "bicarbonate": {
39
+ "low": 22,
40
+ "high": 29,
41
+ "unit": "mEq/L",
42
+ "context": "Metabolic acidosis <22, Metabolic alkalosis >29"
43
+ },
44
+ "hco3": {
45
+ "low": 22,
46
+ "high": 29,
47
+ "unit": "mEq/L",
48
+ "context": "Metabolic acidosis <22, Metabolic alkalosis >29"
49
+ },
50
+ "bun": {
51
+ "low": 7,
52
+ "high": 20,
53
+ "unit": "mg/dL",
54
+ "context": "Elevated in renal failure, dehydration, GI bleed"
55
+ },
56
+ "creatinine": {
57
+ "low": 0.7,
58
+ "high": 1.3,
59
+ "unit": "mg/dL",
60
+ "context": "Elevated in renal insufficiency. Use CKD-EPI or Cockcroft-Gault for GFR"
61
+ },
62
+ "glucose": {
63
+ "low": 70,
64
+ "high": 100,
65
+ "unit": "mg/dL",
66
+ "context": "Fasting. Diabetes: fasting >=126 or random >=200"
67
+ },
68
+ "calcium": {
69
+ "low": 8.5,
70
+ "high": 10.5,
71
+ "unit": "mg/dL",
72
+ "context": "Correct for albumin: add 0.8 per 1.0 below albumin 4.0"
73
+ },
74
+ "magnesium": {
75
+ "low": 1.7,
76
+ "high": 2.2,
77
+ "unit": "mg/dL",
78
+ "context": "Low Mg can cause refractory hypokalemia"
79
+ },
80
+ "phosphate": {
81
+ "low": 2.5,
82
+ "high": 4.5,
83
+ "unit": "mg/dL",
84
+ "context": ""
85
+ },
86
+ "albumin": {
87
+ "low": 3.5,
88
+ "high": 5.5,
89
+ "unit": "g/dL",
90
+ "context": "Low in liver disease, nephrotic syndrome, malnutrition"
91
+ },
92
+ "hemoglobin": {
93
+ "low": 12.0,
94
+ "high": 17.5,
95
+ "unit": "g/dL",
96
+ "context": "Female 12-16, Male 14-17.5. Anemia if below range"
97
+ },
98
+ "hgb": {
99
+ "low": 12.0,
100
+ "high": 17.5,
101
+ "unit": "g/dL",
102
+ "context": "Female 12-16, Male 14-17.5"
103
+ },
104
+ "hematocrit": {
105
+ "low": 36,
106
+ "high": 51,
107
+ "unit": "%",
108
+ "context": "Female 36-44, Male 41-51"
109
+ },
110
+ "hct": {
111
+ "low": 36,
112
+ "high": 51,
113
+ "unit": "%",
114
+ "context": "Female 36-44, Male 41-51"
115
+ },
116
+ "wbc": {
117
+ "low": 4.5,
118
+ "high": 11.0,
119
+ "unit": "x10^3/uL",
120
+ "context": "Leukocytosis >11, Leukopenia <4.5. Left shift if bands >10%"
121
+ },
122
+ "platelets": {
123
+ "low": 150,
124
+ "high": 400,
125
+ "unit": "x10^3/uL",
126
+ "context": "Thrombocytopenia <150, Thrombocytosis >400"
127
+ },
128
+ "plt": {
129
+ "low": 150,
130
+ "high": 400,
131
+ "unit": "x10^3/uL",
132
+ "context": "Thrombocytopenia <150, Thrombocytosis >400"
133
+ },
134
+ "inr": {
135
+ "low": 0.8,
136
+ "high": 1.2,
137
+ "unit": "",
138
+ "context": "Therapeutic on warfarin: 2.0-3.0. Mechanical valve: 2.5-3.5"
139
+ },
140
+ "pt": {
141
+ "low": 11,
142
+ "high": 13.5,
143
+ "unit": "seconds",
144
+ "context": "Prolonged in warfarin use, liver disease, DIC"
145
+ },
146
+ "ptt": {
147
+ "low": 25,
148
+ "high": 35,
149
+ "unit": "seconds",
150
+ "context": "Prolonged in heparin use, hemophilia, lupus anticoagulant"
151
+ },
152
+ "aptt": {
153
+ "low": 25,
154
+ "high": 35,
155
+ "unit": "seconds",
156
+ "context": "Same as PTT"
157
+ },
158
+ "fibrinogen": {
159
+ "low": 200,
160
+ "high": 400,
161
+ "unit": "mg/dL",
162
+ "context": "Low in DIC, liver failure. Acute phase reactant (rises in inflammation)"
163
+ },
164
+ "d-dimer": {
165
+ "low": 0,
166
+ "high": 0.5,
167
+ "unit": "mcg/mL FEU",
168
+ "context": "Elevated in PE, DVT, DIC, sepsis. High sensitivity, low specificity"
169
+ },
170
+ "troponin": {
171
+ "low": 0,
172
+ "high": 0.04,
173
+ "unit": "ng/mL",
174
+ "context": "Elevated in MI, myocarditis, PE, renal failure. High-sensitivity <14 ng/L"
175
+ },
176
+ "bnp": {
177
+ "low": 0,
178
+ "high": 100,
179
+ "unit": "pg/mL",
180
+ "context": "Heart failure: >400 likely, 100-400 gray zone. Age-adjusted: >age*50 if >75"
181
+ },
182
+ "nt-probnp": {
183
+ "low": 0,
184
+ "high": 300,
185
+ "unit": "pg/mL",
186
+ "context": "Age-dependent. HF likely: >900 (<50y), >1800 (50-75y), >1800 (>75y)"
187
+ },
188
+ "ast": {
189
+ "low": 10,
190
+ "high": 40,
191
+ "unit": "U/L",
192
+ "context": "Elevated in liver damage, MI, hemolysis. AST>ALT suggests alcoholic liver"
193
+ },
194
+ "alt": {
195
+ "low": 7,
196
+ "high": 56,
197
+ "unit": "U/L",
198
+ "context": "More specific for liver than AST. ALT>AST suggests viral/NASH"
199
+ },
200
+ "alp": {
201
+ "low": 44,
202
+ "high": 147,
203
+ "unit": "U/L",
204
+ "context": "Elevated in cholestasis, bone disease, pregnancy"
205
+ },
206
+ "bilirubin": {
207
+ "low": 0.1,
208
+ "high": 1.2,
209
+ "unit": "mg/dL",
210
+ "context": "Total. Direct >0.3 suggests conjugated/obstructive. Indirect: hemolysis, Gilbert"
211
+ },
212
+ "ggt": {
213
+ "low": 0,
214
+ "high": 51,
215
+ "unit": "U/L",
216
+ "context": "Elevated in cholestasis, alcohol use. Helps distinguish bone vs liver ALP"
217
+ },
218
+ "ldh": {
219
+ "low": 140,
220
+ "high": 280,
221
+ "unit": "U/L",
222
+ "context": "Elevated in hemolysis, tissue damage, lymphoma, PCP pneumonia"
223
+ },
224
+ "ferritin": {
225
+ "low": 12,
226
+ "high": 300,
227
+ "unit": "ng/mL",
228
+ "context": "Female 12-150, Male 12-300. Iron deficiency <12. Very high in HLH, Still disease"
229
+ },
230
+ "iron": {
231
+ "low": 60,
232
+ "high": 170,
233
+ "unit": "mcg/dL",
234
+ "context": "Low in iron deficiency, chronic disease. High in hemochromatosis"
235
+ },
236
+ "tibc": {
237
+ "low": 250,
238
+ "high": 370,
239
+ "unit": "mcg/dL",
240
+ "context": "High TIBC + low iron = iron deficiency. Low TIBC = chronic disease"
241
+ },
242
+ "transferrin saturation": {
243
+ "low": 20,
244
+ "high": 50,
245
+ "unit": "%",
246
+ "context": "Iron deficiency <20%. Hemochromatosis >45%"
247
+ },
248
+ "tsat": {
249
+ "low": 20,
250
+ "high": 50,
251
+ "unit": "%",
252
+ "context": "Iron deficiency <20%. Hemochromatosis >45%"
253
+ },
254
+ "crp": {
255
+ "low": 0,
256
+ "high": 1.0,
257
+ "unit": "mg/dL",
258
+ "context": "Acute phase reactant. >10 suggests bacterial infection"
259
+ },
260
+ "esr": {
261
+ "low": 0,
262
+ "high": 20,
263
+ "unit": "mm/hr",
264
+ "context": "Female 0-20, Male 0-15. Elevated in inflammation, infection, malignancy"
265
+ },
266
+ "procalcitonin": {
267
+ "low": 0,
268
+ "high": 0.1,
269
+ "unit": "ng/mL",
270
+ "context": ">0.5 suggests bacterial infection. >2.0 high risk sepsis"
271
+ },
272
+ "tsh": {
273
+ "low": 0.4,
274
+ "high": 4.0,
275
+ "unit": "mIU/L",
276
+ "context": "Hypothyroid >4.0, Hyperthyroid <0.4"
277
+ },
278
+ "free t4": {
279
+ "low": 0.8,
280
+ "high": 1.8,
281
+ "unit": "ng/dL",
282
+ "context": "Low in hypothyroid, High in hyperthyroid"
283
+ },
284
+ "hba1c": {
285
+ "low": 4.0,
286
+ "high": 5.6,
287
+ "unit": "%",
288
+ "context": "Pre-diabetes 5.7-6.4, Diabetes >=6.5"
289
+ },
290
+ "lactate": {
291
+ "low": 0.5,
292
+ "high": 2.0,
293
+ "unit": "mmol/L",
294
+ "context": "Elevated in sepsis, shock, ischemia. >4 = severe"
295
+ },
296
+ "ammonia": {
297
+ "low": 15,
298
+ "high": 45,
299
+ "unit": "mcg/dL",
300
+ "context": "Elevated in hepatic encephalopathy, urea cycle defects"
301
+ },
302
+ "lipase": {
303
+ "low": 0,
304
+ "high": 160,
305
+ "unit": "U/L",
306
+ "context": "Elevated in pancreatitis (>3x upper limit significant)"
307
+ },
308
+ "amylase": {
309
+ "low": 28,
310
+ "high": 100,
311
+ "unit": "U/L",
312
+ "context": "Less specific than lipase for pancreatitis"
313
+ },
314
+ "uric acid": {
315
+ "low": 3.0,
316
+ "high": 7.0,
317
+ "unit": "mg/dL",
318
+ "context": "Gout risk >7.0. Tumor lysis syndrome causes acute elevation"
319
+ },
320
+ "ph": {
321
+ "low": 7.35,
322
+ "high": 7.45,
323
+ "unit": "",
324
+ "context": "Acidemia <7.35, Alkalemia >7.45"
325
+ },
326
+ "pco2": {
327
+ "low": 35,
328
+ "high": 45,
329
+ "unit": "mmHg",
330
+ "context": "Respiratory acidosis >45, Respiratory alkalosis <35"
331
+ },
332
+ "po2": {
333
+ "low": 80,
334
+ "high": 100,
335
+ "unit": "mmHg",
336
+ "context": "Hypoxemia <80. Critical <60"
337
+ },
338
+ "spo2": {
339
+ "low": 95,
340
+ "high": 100,
341
+ "unit": "%",
342
+ "context": "Hypoxemia <95%. Critical <90%"
343
+ },
344
+ "anion gap": {
345
+ "low": 8,
346
+ "high": 12,
347
+ "unit": "mEq/L",
348
+ "context": "Elevated: MUDPILES (Methanol, Uremia, DKA, Propylene glycol, INH/Iron, Lactic acidosis, Ethylene glycol, Salicylates)"
349
+ }
350
+ }
inference.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Baseline inference script for Claude Code for Health.
3
+
4
+ Runs an LLM agent against all 3 task difficulties (easy, medium, hard).
5
+ Emits [START], [STEP], [END] stdout lines per the OpenEnv spec.
6
+
7
+ Required env vars:
8
+ API_BASE_URL — LLM endpoint (default: HF router)
9
+ MODEL_NAME — model identifier
10
+ HF_TOKEN — API key
11
+ """
12
+
13
+ import asyncio
14
+ import os
15
+ import re
16
+ import sys
17
+ import textwrap
18
+ from typing import Optional
19
+
20
+ from openai import OpenAI
21
+
22
+ from claude_code_for_health import ClaudeCodeForHealthEnv, MedAction
23
+
24
+
25
+ IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME") or os.getenv("IMAGE_NAME")
26
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
27
+ if not API_KEY:
28
+ raise ValueError("HF_TOKEN environment variable is required")
29
+ API_BASE_URL = os.getenv("API_BASE_URL") or "https://router.huggingface.co/v1"
30
+ MODEL_NAME = os.getenv("MODEL_NAME") or "meta-llama/Llama-3.1-8B-Instruct"
31
+ BENCHMARK = "claude_code_for_health"
32
+ MAX_STEPS = 30
33
+ TEMPERATURE = 0.0
34
+ MAX_TOKENS = 200
35
+
36
+ SYSTEM_PROMPT = textwrap.dedent("""\
37
+ You are a clinical AI assistant interacting with a medical environment via CLI commands.
38
+ Each turn, respond with EXACTLY ONE command — no explanation, no markdown, just the command.
39
+
40
+ DIAGNOSIS TASKS — commands:
41
+ chart.history View past medical history, meds, allergies, social, family
42
+ chart.vitals View vital signs
43
+ chart.labs List available lab panels
44
+ chart.labs <panel> View specific lab panel results
45
+ chart.imaging List available imaging studies
46
+ chart.imaging <type> View specific imaging findings
47
+ chart.exam List available physical exam systems
48
+ chart.exam <system> View specific exam findings
49
+ chart.medications View current medications
50
+ chart.allergies View known allergies
51
+ ddx.add <diagnosis> Add diagnosis to differential
52
+ ddx.remove <diagnosis> Remove from differential
53
+ ddx.list Show current differential
54
+ ddx.confirm <diagnosis> Submit final diagnosis (ends episode)
55
+ help List commands
56
+
57
+ CALCULATION TASKS — commands:
58
+ case.read Read the full patient note
59
+ calculate <name> Declare which calculator you're using
60
+ submit <number> Submit numeric answer (ends episode)
61
+ help List commands
62
+
63
+ NOTE REVIEW TASKS — commands:
64
+ note.read Read the clinical note
65
+ note.correct <sentence_id> <text> Correct an error in a sentence
66
+ note.approve Approve note / submit corrections (ends episode)
67
+ help List commands
68
+
69
+ REFERENCE TOOLS (available in all tasks):
70
+ reference.ranges <test> Look up normal range for a lab test
71
+ reference.criteria <condition> Look up diagnostic criteria for a condition
72
+ reference.drug_info <drug> Look up drug mechanism, indications, contraindications
73
+ interpret <test> <value> Interpret a lab value (e.g. interpret sodium 128)
74
+
75
+ Strategy:
76
+ - Always read available data before making decisions
77
+ - Use reference tools when unsure about normal ranges or diagnostic criteria
78
+ - For diagnosis: review history, vitals, labs, then form differential before confirming
79
+ - For calculations: read the case, identify the calculator, compute, submit
80
+ - For note review: read the note carefully, correct errors if any, then approve
81
+ """)
82
+
83
+
84
+ def log_start(task: str, env: str, model: str) -> None:
85
+ print(f"[START] task={task} env={env} model={model}", flush=True)
86
+
87
+
88
+ def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
89
+ error_val = error if error else "null"
90
+ done_val = str(done).lower()
91
+ action_clean = action.replace("\n", " ").strip()
92
+ print(
93
+ f"[STEP] step={step} action={action_clean} reward={reward:.2f} done={done_val} error={error_val}",
94
+ flush=True,
95
+ )
96
+
97
+
98
+ def log_end(success: bool, steps: int, score: float, rewards: list[float]) -> None:
99
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
100
+ print(
101
+ f"[END] success={str(success).lower()} steps={steps} score={score:.2f} rewards={rewards_str}",
102
+ flush=True,
103
+ )
104
+
105
+
106
+ def clean_llm_output(text: str) -> str:
107
+ text = text.strip()
108
+ text = re.sub(r"^```\w*\n?", "", text)
109
+ text = re.sub(r"\n?```$", "", text)
110
+ text = text.strip("`").strip()
111
+ if text.startswith("$ "):
112
+ text = text[2:]
113
+ lines = text.strip().split("\n")
114
+ return lines[0].strip()
115
+
116
+
117
+ def get_agent_command(client: OpenAI, messages: list[dict]) -> str:
118
+ try:
119
+ completion = client.chat.completions.create(
120
+ model=MODEL_NAME,
121
+ messages=messages,
122
+ temperature=TEMPERATURE,
123
+ max_tokens=MAX_TOKENS,
124
+ stream=False,
125
+ )
126
+ raw = (completion.choices[0].message.content or "").strip()
127
+ return clean_llm_output(raw) if raw else "help"
128
+ except Exception as exc:
129
+ print(f"[DEBUG] LLM request failed: {exc}", file=sys.stderr, flush=True)
130
+ return "help"
131
+
132
+
133
+ async def run_task(client: OpenAI, env, difficulty: str) -> float:
134
+ rewards: list[float] = []
135
+ steps_taken = 0
136
+ score = 0.0
137
+ success = False
138
+
139
+ log_start(task=difficulty, env=BENCHMARK, model=MODEL_NAME)
140
+
141
+ try:
142
+ result = await env.reset(options={"task": difficulty})
143
+ observation_text = result.observation.output
144
+ task_type = result.observation.task_type
145
+
146
+ messages = [
147
+ {"role": "system", "content": SYSTEM_PROMPT},
148
+ {"role": "user", "content": f"Task type: {task_type}\n\nEnvironment output:\n{observation_text}"},
149
+ ]
150
+
151
+ for step in range(1, MAX_STEPS + 1):
152
+ if result.done:
153
+ break
154
+
155
+ command = get_agent_command(client, messages)
156
+
157
+ messages.append({"role": "assistant", "content": command})
158
+
159
+ result = await env.step(MedAction(command=command))
160
+
161
+ reward = result.reward or 0.0
162
+ done = result.done
163
+ error = result.observation.error or None
164
+ observation_text = result.observation.output
165
+
166
+ rewards.append(reward)
167
+ steps_taken = step
168
+
169
+ messages.append({"role": "user", "content": f"Environment output:\n{observation_text}"})
170
+
171
+ log_step(step=step, action=command, reward=reward, done=done, error=error)
172
+
173
+ if done:
174
+ break
175
+
176
+ score = sum(rewards)
177
+ score = min(max(score, 0.01), 0.99)
178
+ success = score >= 0.5
179
+
180
+ finally:
181
+ try:
182
+ await env.close()
183
+ except Exception as e:
184
+ print(f"[DEBUG] env.close() error: {e}", file=sys.stderr, flush=True)
185
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
186
+
187
+ return score
188
+
189
+
190
+ async def main() -> None:
191
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
192
+
193
+ for difficulty in ["easy", "medium", "hard"]:
194
+ if IMAGE_NAME:
195
+ env = await ClaudeCodeForHealthEnv.from_docker_image(IMAGE_NAME)
196
+ else:
197
+ env = ClaudeCodeForHealthEnv(base_url=os.getenv("ENV_BASE_URL", "http://localhost:8000"))
198
+ await run_task(client, env, difficulty)
199
+
200
+
201
+ if __name__ == "__main__":
202
+ asyncio.run(main())
models.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data models for the Claude Code for Health Environment.
3
+
4
+ Three Pydantic models defining the action/observation/state contract:
5
+ - MedAction: single CLI command string (terminal metaphor)
6
+ - MedObservation: command output + episode metadata
7
+ - MedState: episode tracking for state() endpoint
8
+ """
9
+
10
+ from openenv.core.env_server.types import Action, Observation, State
11
+ from pydantic import Field
12
+
13
+
14
+ class MedAction(Action):
15
+ """Agent sends a single CLI command string per step."""
16
+
17
+ command: str = Field(..., description="CLI command string, e.g. 'chart.labs CBC'")
18
+
19
+
20
+ class MedObservation(Observation):
21
+ """Environment returns command output and episode context."""
22
+
23
+ output: str = Field(default="", description="Command output text")
24
+ error: str = Field(default="", description="Error message if command invalid")
25
+ available_commands: list[str] = Field(default_factory=list)
26
+ task_type: str = Field(default="", description="diagnosis | calculation | note_review")
27
+ step_number: int = Field(default=0)
28
+ max_steps: int = Field(default=50)
29
+
30
+
31
+ class MedState(State):
32
+ """Episode state exposed via the state() endpoint."""
33
+
34
+ task_type: str = Field(default="")
35
+ difficulty: str = Field(default="easy")
36
+ total_score: float = Field(default=0.0)
37
+ commands_issued: int = Field(default=0)
38
+ is_submitted: bool = Field(default=False)
openenv.yaml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: claude_code_for_health
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
7
+
8
+ description: >
9
+ Clinical terminal environment where an AI agent works through medical tasks
10
+ by typing CLI commands. Three task types: diagnostic workup, medical
11
+ calculations, and clinical note review. All programmatically graded.
12
+
13
+ tasks:
14
+ - name: easy
15
+ description: "Clinical note review — identify if a note is error-free or fix obvious errors"
16
+ difficulty: easy
17
+
18
+ - name: medium
19
+ description: "Medical calculation — read a clinical scenario, identify the formula, compute the answer"
20
+ difficulty: medium
21
+
22
+ - name: hard
23
+ description: "Diagnostic workup — explore patient chart via CLI, build differential, confirm diagnosis"
24
+ difficulty: hard
pyproject.toml ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-claude_code_for_health"
13
+ version = "0.1.0"
14
+ description = "Claude Code For Health 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
+ "rapidfuzz>=3.0.0",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "pytest>=8.0.0",
27
+ "pytest-cov>=4.0.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ # Server entry point - enables running via: uv run --project . server
32
+ # or: python -m claude_code_for_health.server.app
33
+ server = "claude_code_for_health.server.app:main"
34
+
35
+ [tool.setuptools]
36
+ include-package-data = true
37
+ packages = ["claude_code_for_health", "claude_code_for_health.server"]
38
+ package-dir = { "claude_code_for_health" = ".", "claude_code_for_health.server" = "server" }
server/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .claude_code_for_health_environment import ClaudeCodeForHealthEnvironment
2
+
3
+ __all__ = ["ClaudeCodeForHealthEnvironment"]
server/app.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ try:
2
+ from openenv.core.env_server.http_server import create_app
3
+ except Exception as e:
4
+ raise ImportError(
5
+ "openenv is required. Install with: pip install openenv-core[core]"
6
+ ) from e
7
+
8
+ try:
9
+ from ..models import MedAction, MedObservation
10
+ from .claude_code_for_health_environment import ClaudeCodeForHealthEnvironment
11
+ from .ui import build_custom_dashboard
12
+ except (ImportError, ModuleNotFoundError):
13
+ from models import MedAction, MedObservation
14
+ from server.claude_code_for_health_environment import ClaudeCodeForHealthEnvironment
15
+ from server.ui import build_custom_dashboard
16
+
17
+ app = create_app(
18
+ ClaudeCodeForHealthEnvironment,
19
+ MedAction,
20
+ MedObservation,
21
+ env_name="claude_code_for_health",
22
+ max_concurrent_envs=1,
23
+ gradio_builder=build_custom_dashboard,
24
+ )
25
+
26
+
27
+ def main(host: str = "0.0.0.0", port: int = 8000):
28
+ import uvicorn
29
+ uvicorn.run(app, host=host, port=port)
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
server/claude_code_for_health_environment.py ADDED
@@ -0,0 +1,588 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core environment: reset/step/state for all three clinical task types."""
2
+
3
+ from random import Random
4
+ from uuid import uuid4
5
+
6
+ from openenv.core.env_server.interfaces import Environment
7
+
8
+ try:
9
+ from ..models import MedAction, MedObservation, MedState
10
+ except ImportError:
11
+ from models import MedAction, MedObservation, MedState
12
+
13
+ from . import command_parser, constants, graders, task_configs
14
+ from .data_loader import DataLoader
15
+
16
+ PROTOCOL_PENALTY = -0.05
17
+ SPECIALIZED_LAB_PANELS = {"abg", "coags", "coagulation", "cultures", "cytology"}
18
+
19
+ REFERENCE_TOOLS = [
20
+ "reference.ranges <test>", "reference.criteria <condition>",
21
+ "reference.drug_info <drug>", "interpret <test> <value>",
22
+ ]
23
+ REFERENCE_TOOL_NAMES = {"reference.ranges", "reference.criteria", "reference.drug_info", "interpret"}
24
+
25
+ TASK_TOOLS = {
26
+ "diagnosis": [
27
+ "chart.history", "chart.vitals", "chart.labs [panel]",
28
+ "chart.imaging [type]", "chart.exam [system]",
29
+ "chart.medications", "chart.allergies",
30
+ "ddx.list", "ddx.add <diagnosis>", "ddx.remove <diagnosis>",
31
+ "ddx.confirm <diagnosis>", "help",
32
+ ] + REFERENCE_TOOLS,
33
+ "calculation": [
34
+ "case.read", "calculate <calculator_name>",
35
+ "submit <numeric_value>", "help",
36
+ ] + REFERENCE_TOOLS,
37
+ "note_review": [
38
+ "note.read", "note.correct <sentence_id> <corrected_text>",
39
+ "note.approve", "help",
40
+ ] + REFERENCE_TOOLS,
41
+ }
42
+
43
+ VALID_TOOL_NAMES = {
44
+ "diagnosis": {
45
+ "chart.history", "chart.vitals", "chart.labs", "chart.imaging",
46
+ "chart.exam", "chart.medications", "chart.allergies",
47
+ "ddx.list", "ddx.add", "ddx.remove", "ddx.confirm", "help",
48
+ } | REFERENCE_TOOL_NAMES,
49
+ "calculation": {"case.read", "calculate", "submit", "help"} | REFERENCE_TOOL_NAMES,
50
+ "note_review": {"note.read", "note.correct", "note.approve", "help"} | REFERENCE_TOOL_NAMES,
51
+ }
52
+
53
+
54
+ class ClaudeCodeForHealthEnvironment(Environment):
55
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
56
+
57
+ def __init__(self):
58
+ self._data_loader = DataLoader()
59
+ self._rng = Random()
60
+ self._state = MedState(episode_id=str(uuid4()), step_count=0)
61
+ self._max_steps = 50
62
+ self._reset_episode_vars()
63
+
64
+ def _reset_episode_vars(self):
65
+ self._task_type = ""
66
+ self._difficulty = "easy"
67
+ self._task_data: dict = {}
68
+ self._ground_truth: dict = {}
69
+ self._agent_actions: list[str] = []
70
+ self._ddx_list: list[str] = []
71
+ self._confirmed_diagnosis = ""
72
+ self._calculator_used = ""
73
+ self._submitted_value: float | None = None
74
+ self._corrections: dict[str, str] = {}
75
+ self._accessed_sections: set[str] = set()
76
+ self._relevant_sections: set[str] = set()
77
+ self._case_read = False
78
+ self._note_read = False
79
+ self._calculator_declared = False
80
+ self._is_done = False
81
+ self._cumulative_reward = 0.0
82
+ self._seen_commands: set[str] = set()
83
+
84
+ # ------------------------------------------------------------------
85
+ # reset / step / state
86
+ # ------------------------------------------------------------------
87
+
88
+ def reset(self, *, seed=None, options=None) -> MedObservation:
89
+ self._data_loader.load_all()
90
+ if seed is not None:
91
+ self._rng = Random(seed)
92
+
93
+ opts = options or {}
94
+ self._difficulty = opts.get("task", "easy")
95
+ self._task_type = opts.get("task_type") or task_configs.get_default_task_type(self._difficulty, self._rng)
96
+
97
+ cases_map = {
98
+ "diagnosis": self._data_loader.get_diagnosis_cases,
99
+ "calculation": self._data_loader.get_calculation_cases,
100
+ "note_review": self._data_loader.get_note_cases,
101
+ }
102
+ cases = cases_map.get(self._task_type, self._data_loader.get_diagnosis_cases)()
103
+ case = task_configs.select_case(self._task_type, self._difficulty, cases, self._rng)
104
+
105
+ self._state = MedState(
106
+ episode_id=str(uuid4()),
107
+ step_count=0,
108
+ task_type=self._task_type,
109
+ difficulty=self._difficulty,
110
+ )
111
+ self._reset_episode_vars()
112
+ self._task_type = self._state.task_type
113
+ self._difficulty = self._state.difficulty
114
+ self._task_data = case
115
+ self._setup_ground_truth(case)
116
+
117
+ if self._task_type == "diagnosis":
118
+ self._relevant_sections = graders.compute_relevant_sections(case.get("extracted", {}))
119
+
120
+ return MedObservation(
121
+ output=self._build_initial_observation(case),
122
+ available_commands=TASK_TOOLS.get(self._task_type, ["help"]),
123
+ task_type=self._task_type,
124
+ step_number=0,
125
+ max_steps=self._max_steps,
126
+ done=False,
127
+ reward=0.0,
128
+ )
129
+
130
+ def step(self, action: MedAction) -> MedObservation:
131
+ if self._is_done:
132
+ return self._obs("Episode is over. Call reset() to start a new one.", reward=0.0, done=True)
133
+
134
+ self._state.step_count += 1
135
+ self._state.commands_issued += 1
136
+ raw = action.command
137
+ self._agent_actions.append(raw)
138
+
139
+ cmd, args = command_parser.parse(raw)
140
+
141
+ if not cmd:
142
+ return self._obs("Empty command. Type 'help' for available tools.", reward=0.0)
143
+
144
+ valid = VALID_TOOL_NAMES.get(self._task_type, {"help"})
145
+ if cmd not in valid:
146
+ return self._obs(
147
+ f"Unknown tool: '{cmd}'. Type 'help' for available tools.",
148
+ error=f"Unknown command: {cmd}",
149
+ reward=0.0,
150
+ )
151
+
152
+ full_cmd = raw.strip().lower()
153
+ is_duplicate = full_cmd in self._seen_commands and cmd not in ("help", "ddx.list")
154
+ self._seen_commands.add(full_cmd)
155
+
156
+ output, reward, done = self._dispatch(cmd, args)
157
+
158
+ if is_duplicate and not done:
159
+ output += f"\n[NOTE] Duplicate tool call. Efficiency penalty: {PROTOCOL_PENALTY}"
160
+ reward += PROTOCOL_PENALTY
161
+
162
+ self._cumulative_reward += reward
163
+ self._state.total_score = round(self._cumulative_reward, 4)
164
+
165
+ if done:
166
+ self._is_done = True
167
+ self._state.is_submitted = True
168
+
169
+ if not done and self._state.step_count >= self._max_steps:
170
+ terminal_reward = self._force_terminal()
171
+ reward += terminal_reward
172
+ self._cumulative_reward += terminal_reward
173
+ self._state.total_score = round(self._cumulative_reward, 4)
174
+ done = True
175
+ self._is_done = True
176
+ output += "\n\nMax steps reached. Episode ended."
177
+
178
+ return self._obs(output, reward=round(reward, 4), done=done)
179
+
180
+ @property
181
+ def state(self) -> MedState:
182
+ return self._state
183
+
184
+ # ------------------------------------------------------------------
185
+ # Command dispatch
186
+ # ------------------------------------------------------------------
187
+
188
+ def _dispatch(self, cmd: str, args: list[str]) -> tuple[str, float, bool]:
189
+ if cmd == "help":
190
+ return self._handle_help(), 0.0, False
191
+
192
+ ref_result = self._dispatch_reference(cmd, args)
193
+ if ref_result is not None:
194
+ return ref_result
195
+
196
+ dispatch_map = {
197
+ "diagnosis": self._dispatch_diagnosis,
198
+ "calculation": self._dispatch_calculation,
199
+ "note_review": self._dispatch_note,
200
+ }
201
+ handler = dispatch_map.get(self._task_type)
202
+ if handler:
203
+ return handler(cmd, args)
204
+ return "Internal error: unknown task type.", 0.0, False
205
+
206
+ def _dispatch_reference(self, cmd: str, args: list[str]) -> tuple[str, float, bool] | None:
207
+ lookup_map = {
208
+ "reference.ranges": ("test_name", constants.lookup_range),
209
+ "reference.criteria": ("condition", constants.lookup_criteria),
210
+ "reference.drug_info": ("drug_name", constants.lookup_drug),
211
+ }
212
+ if cmd in lookup_map:
213
+ param_name, lookup_fn = lookup_map[cmd]
214
+ if not args:
215
+ return f"Usage: {cmd} <{param_name}>", 0.0, False
216
+ result = lookup_fn(args[0])
217
+ if result is None:
218
+ return f"No results found for '{args[0]}'.", 0.0, False
219
+ return result, 0.0, False
220
+
221
+ if cmd == "interpret":
222
+ if not args:
223
+ return "Usage: interpret <test_name> <value>", 0.0, False
224
+ parts = args[0].rsplit(None, 1) if len(args) == 1 else args
225
+ if len(parts) < 2:
226
+ return "Usage: interpret <test_name> <value>", 0.0, False
227
+ result = constants.interpret_value(parts[0], parts[-1])
228
+ if result is None:
229
+ return f"Unknown test '{parts[0]}'. Try: sodium, potassium, troponin, wbc, etc.", 0.0, False
230
+ return result, 0.0, False
231
+
232
+ return None
233
+
234
+ # ------------------------------------------------------------------
235
+ # Diagnosis tools
236
+ # ------------------------------------------------------------------
237
+
238
+ def _diag_step_reward(self, cmd: str, args: list[str]) -> float:
239
+ return graders.diagnosis_step_reward(cmd, args, self._accessed_sections, self._relevant_sections)
240
+
241
+ def _handle_chart_keyed(self, data: dict, key_arg: str | None, cmd: str,
242
+ label: str, list_label: str) -> tuple[str, float, bool]:
243
+ if not key_arg:
244
+ keys = list(data.keys()) if data else []
245
+ if keys:
246
+ return f"Available {list_label}: {', '.join(keys)}", 0.0, False
247
+ return f"No {list_label} available.", 0.0, False
248
+
249
+ matched = self._fuzzy_key_match(key_arg, data)
250
+ if matched is None:
251
+ return f"{label} '{key_arg}' not available. Use '{cmd}' to list.", 0.0, False
252
+
253
+ value = data[matched]
254
+ output = self._format_dict(value, title=matched) if isinstance(value, dict) else f"{matched}: {value}"
255
+ reward = self._diag_step_reward(cmd, [matched.lower()])
256
+ return output, reward, False
257
+
258
+ def _dispatch_diagnosis(self, cmd: str, args: list[str]) -> tuple[str, float, bool]:
259
+ extracted = self._task_data.get("extracted", {})
260
+ penalty, warning = self._check_prerequisites(cmd, args)
261
+
262
+ if cmd == "chart.history":
263
+ output = self._format_history(extracted.get("history", {}))
264
+ return (output + warning), self._diag_step_reward(cmd, args) + penalty, False
265
+
266
+ if cmd == "chart.vitals":
267
+ output = self._format_vitals(extracted.get("vitals", {}))
268
+ return (output + warning), self._diag_step_reward(cmd, args) + penalty, False
269
+
270
+ if cmd == "chart.labs":
271
+ output, reward, done = self._handle_chart_keyed(
272
+ extracted.get("labs", {}), args[0] if args else None,
273
+ "chart.labs", "Lab panel", "lab panels")
274
+ return (output + warning), reward + penalty, done
275
+
276
+ if cmd == "chart.imaging":
277
+ output, reward, done = self._handle_chart_keyed(
278
+ extracted.get("imaging", {}), args[0] if args else None,
279
+ "chart.imaging", "Imaging", "imaging")
280
+ return (output + warning), reward + penalty, done
281
+
282
+ if cmd == "chart.exam":
283
+ output, reward, done = self._handle_chart_keyed(
284
+ extracted.get("physical_exam", {}), args[0] if args else None,
285
+ "chart.exam", "Exam", "exam systems")
286
+ return output, reward, done
287
+
288
+ if cmd == "chart.medications":
289
+ meds = extracted.get("history", {}).get("medications", [])
290
+ return ("Medications: " + ", ".join(meds)) if meds else "No medications listed.", 0.0, False
291
+
292
+ if cmd == "chart.allergies":
293
+ allergies = extracted.get("history", {}).get("allergies", [])
294
+ return ("Allergies: " + ", ".join(allergies)) if allergies else "No known allergies.", 0.0, False
295
+
296
+ if cmd == "ddx.list":
297
+ if self._ddx_list:
298
+ items = "\n".join(f" {i+1}. {d}" for i, d in enumerate(self._ddx_list))
299
+ return f"Current differential:\n{items}", 0.0, False
300
+ return "Differential is empty.", 0.0, False
301
+
302
+ if cmd == "ddx.add":
303
+ if not args:
304
+ return "Usage: ddx.add <diagnosis>", 0.0, False
305
+ dx = args[0].strip()
306
+ self._ddx_list.append(dx)
307
+ return f"Added '{dx}'. Differential has {len(self._ddx_list)} entry(ies).", 0.0, False
308
+
309
+ if cmd == "ddx.remove":
310
+ if not args:
311
+ return "Usage: ddx.remove <diagnosis>", 0.0, False
312
+ dx = args[0].strip().lower()
313
+ before = len(self._ddx_list)
314
+ self._ddx_list = [d for d in self._ddx_list if d.lower() != dx]
315
+ if len(self._ddx_list) < before:
316
+ return f"Removed. Differential has {len(self._ddx_list)} entry(ies).", 0.0, False
317
+ return f"'{args[0]}' not found in differential.", 0.0, False
318
+
319
+ if cmd == "ddx.confirm":
320
+ if not args:
321
+ return "Usage: ddx.confirm <diagnosis>", 0.0, False
322
+ self._confirmed_diagnosis = args[0].strip()
323
+ terminal = graders.diagnosis_terminal_reward(
324
+ confirmed=self._confirmed_diagnosis,
325
+ ground_truth_diagnosis=self._ground_truth.get("diagnosis", ""),
326
+ accessed_sections=self._accessed_sections,
327
+ relevant_sections=self._relevant_sections,
328
+ ddx_list=self._ddx_list,
329
+ steps_taken=self._state.step_count,
330
+ ) + penalty
331
+ return f"Diagnosis submitted: '{self._confirmed_diagnosis}'. Score: {terminal:.2f}" + warning, terminal, True
332
+
333
+ return f"Unknown diagnosis tool: {cmd}", 0.0, False
334
+
335
+ # ------------------------------------------------------------------
336
+ # Calculation tools
337
+ # ------------------------------------------------------------------
338
+
339
+ def _dispatch_calculation(self, cmd: str, args: list[str]) -> tuple[str, float, bool]:
340
+ if cmd == "case.read":
341
+ note = self._task_data.get("Patient Note", "No patient note available.")
342
+ question = self._task_data.get("Question", "")
343
+ output = note + (f"\n\nQuestion: {question}" if question else "")
344
+ reward = graders.calculation_step_reward(cmd, self._case_read, self._calculator_declared)
345
+ self._case_read = True
346
+ return output, reward, False
347
+
348
+ if cmd == "calculate":
349
+ if not args:
350
+ return "Usage: calculate <calculator_name>", 0.0, False
351
+ self._calculator_used = args[0].strip()
352
+ reward = graders.calculation_step_reward("calculate", self._case_read, self._calculator_declared)
353
+ self._calculator_declared = True
354
+ return f"Calculator noted: {self._calculator_used}. Use 'submit <value>' with your answer.", reward, False
355
+
356
+ if cmd == "submit":
357
+ if not args:
358
+ return "Usage: submit <numeric_value>", 0.0, False
359
+ try:
360
+ self._submitted_value = float(args[0].strip())
361
+ except ValueError:
362
+ return f"Cannot parse '{args[0]}' as a number.", 0.0, False
363
+
364
+ gt = self._ground_truth
365
+ try:
366
+ gt_answer = float(gt.get("answer", 0))
367
+ lower = float(gt.get("lower_limit", gt_answer))
368
+ upper = float(gt.get("upper_limit", gt_answer))
369
+ except (ValueError, TypeError):
370
+ gt_answer, lower, upper = 0.0, 0.0, 0.0
371
+
372
+ terminal = graders.calculation_terminal_reward(
373
+ submitted_value=self._submitted_value,
374
+ ground_truth=gt_answer,
375
+ lower_limit=lower,
376
+ upper_limit=upper,
377
+ calculator_used=self._calculator_used,
378
+ expected_calculator=gt.get("calculator_name", ""),
379
+ steps_taken=self._state.step_count,
380
+ )
381
+ return f"Submitted: {self._submitted_value}. Score: {terminal:.2f}", terminal, True
382
+
383
+ return f"Unknown calculation tool: {cmd}", 0.0, False
384
+
385
+ # ------------------------------------------------------------------
386
+ # Note review tools
387
+ # ------------------------------------------------------------------
388
+
389
+ def _dispatch_note(self, cmd: str, args: list[str]) -> tuple[str, float, bool]:
390
+ if cmd == "note.read":
391
+ sentences_raw = self._task_data.get("Sentences", "")
392
+ output = self._format_note_sentences(sentences_raw) if sentences_raw else self._task_data.get("Text", "No note available.")
393
+ reward = graders.note_step_reward(cmd, self._note_read)
394
+ self._note_read = True
395
+ return output, reward, False
396
+
397
+ if cmd == "note.correct":
398
+ if len(args) < 2:
399
+ return "Usage: note.correct <sentence_id> <corrected_text>", 0.0, False
400
+ self._corrections[args[0].strip()] = args[1].strip()
401
+ return f"Correction recorded for sentence {args[0].strip()}.", 0.0, False
402
+
403
+ if cmd == "note.approve":
404
+ gt = self._ground_truth
405
+ terminal = graders.note_terminal_reward(
406
+ corrections=self._corrections,
407
+ has_error=bool(gt.get("has_error", False)),
408
+ error_sentence_id=gt.get("error_sentence_id"),
409
+ corrected_sentence=gt.get("corrected_sentence"),
410
+ )
411
+ status = "Corrections submitted." if self._corrections else "Note approved as correct."
412
+ return f"{status} Score: {terminal:.2f}", terminal, True
413
+
414
+ return f"Unknown note review tool: {cmd}", 0.0, False
415
+
416
+ # ------------------------------------------------------------------
417
+ # Setup helpers
418
+ # ------------------------------------------------------------------
419
+
420
+ def _setup_ground_truth(self, case: dict):
421
+ if self._task_type == "diagnosis":
422
+ extracted = case.get("extracted", {})
423
+ gt = extracted.get("ground_truth", {})
424
+ self._ground_truth = {
425
+ "diagnosis": gt.get("diagnosis", case.get("final_diagnosis", "")),
426
+ "organ_system": gt.get("organ_system", ""),
427
+ "key_findings": gt.get("key_findings", []),
428
+ }
429
+ elif self._task_type == "calculation":
430
+ self._ground_truth = {
431
+ "answer": case.get("Ground Truth Answer", "0"),
432
+ "lower_limit": case.get("Lower Limit", case.get("Ground Truth Answer", "0")),
433
+ "upper_limit": case.get("Upper Limit", case.get("Ground Truth Answer", "0")),
434
+ "calculator_name": case.get("Calculator Name", ""),
435
+ "explanation": case.get("Ground Truth Explanation", ""),
436
+ }
437
+ elif self._task_type == "note_review":
438
+ try:
439
+ has_error = int(float(case.get("Error Flag", 0))) == 1
440
+ except (ValueError, TypeError):
441
+ has_error = False
442
+ self._ground_truth = {
443
+ "has_error": has_error,
444
+ "error_sentence_id": str(case.get("Error Sentence ID", "")).strip() if has_error else None,
445
+ "error_sentence": case.get("Error Sentence", "") if has_error else None,
446
+ "corrected_sentence": case.get("Corrected Sentence", "") if has_error else None,
447
+ }
448
+
449
+ def _build_initial_observation(self, case: dict) -> str:
450
+ if self._task_type == "diagnosis":
451
+ extracted = case.get("extracted", {})
452
+ demo = extracted.get("demographics", {})
453
+ cc = extracted.get("chief_complaint", case.get("case_prompt", "")[:150])
454
+ return f"Patient: {demo.get('age', '?')}{demo.get('sex', '?')}, {cc}\nType 'help' for available tools."
455
+ elif self._task_type == "calculation":
456
+ return (
457
+ f"Medical Calculation Task — {case.get('Calculator Name', '')}\n"
458
+ f"{case.get('Question', '')}\n"
459
+ f"Type 'case.read' to view the full patient note."
460
+ )
461
+ elif self._task_type == "note_review":
462
+ return "Clinical Note Review Task\nReview the note for medical errors. Correct any you find, then approve.\nType 'note.read' to view the clinical note."
463
+ return "Unknown task type."
464
+
465
+ def _handle_help(self) -> str:
466
+ tools = TASK_TOOLS.get(self._task_type, ["help"])
467
+ lines = [f"Available tools ({self._task_type}):"]
468
+ for t in tools:
469
+ lines.append(f" {t}")
470
+ return "\n".join(lines)
471
+
472
+ def _force_terminal(self) -> float:
473
+ if self._task_type == "diagnosis":
474
+ return graders.diagnosis_terminal_reward(
475
+ confirmed=self._confirmed_diagnosis or "",
476
+ ground_truth_diagnosis=self._ground_truth.get("diagnosis", ""),
477
+ accessed_sections=self._accessed_sections,
478
+ relevant_sections=self._relevant_sections,
479
+ ddx_list=self._ddx_list,
480
+ steps_taken=self._state.step_count,
481
+ )
482
+ elif self._task_type == "note_review":
483
+ return graders.note_terminal_reward(
484
+ corrections=self._corrections,
485
+ has_error=bool(self._ground_truth.get("has_error", False)),
486
+ error_sentence_id=self._ground_truth.get("error_sentence_id"),
487
+ corrected_sentence=self._ground_truth.get("corrected_sentence"),
488
+ )
489
+ return 0.0
490
+
491
+ def _check_prerequisites(self, cmd: str, args: list[str]) -> tuple[float, str]:
492
+ if cmd == "chart.imaging" and args:
493
+ if "vitals" not in self._accessed_sections:
494
+ return PROTOCOL_PENALTY, f"\n[WARNING] Ordering imaging without baseline vitals: {PROTOCOL_PENALTY} protocol penalty"
495
+
496
+ if cmd == "chart.labs" and args:
497
+ if args[0].lower() in SPECIALIZED_LAB_PANELS:
498
+ has_basic = any(s.startswith("labs.") and s.split(".")[-1] in ("cbc", "bmp") for s in self._accessed_sections)
499
+ if not has_basic:
500
+ return PROTOCOL_PENALTY, f"\n[WARNING] Ordering specialized labs without basic panels (CBC/BMP): {PROTOCOL_PENALTY} protocol penalty"
501
+
502
+ if cmd == "ddx.confirm" and len(self._ddx_list) < 2:
503
+ return PROTOCOL_PENALTY, f"\n[WARNING] Confirming diagnosis with <2 differentials: {PROTOCOL_PENALTY} protocol penalty"
504
+
505
+ return 0.0, ""
506
+
507
+ # ------------------------------------------------------------------
508
+ # Observation + status
509
+ # ------------------------------------------------------------------
510
+
511
+ def _obs(self, output: str, reward: float = 0.0, done: bool = False, error: str = "") -> MedObservation:
512
+ if not done and self._task_type:
513
+ output = output + "\n\n" + self._status_footer()
514
+ return MedObservation(
515
+ output=output,
516
+ error=error,
517
+ available_commands=TASK_TOOLS.get(self._task_type, ["help"]),
518
+ task_type=self._task_type,
519
+ step_number=self._state.step_count,
520
+ max_steps=self._max_steps,
521
+ done=done,
522
+ reward=reward,
523
+ )
524
+
525
+ def _status_footer(self) -> str:
526
+ step_info = f"Step: {self._state.step_count}/{self._max_steps}"
527
+ if self._task_type == "diagnosis":
528
+ ddx = ", ".join(self._ddx_list) if self._ddx_list else "empty"
529
+ accessed = ", ".join(sorted(self._accessed_sections)) if self._accessed_sections else "none"
530
+ return f"[STATUS] DDX: [{ddx}] | Accessed: {accessed} | {step_info}"
531
+ if self._task_type == "calculation":
532
+ return f"[STATUS] Case read: {'yes' if self._case_read else 'no'} | Calculator: {self._calculator_used or 'none'} | {step_info}"
533
+ if self._task_type == "note_review":
534
+ corr = str(dict(self._corrections)) if self._corrections else "none"
535
+ return f"[STATUS] Note read: {'yes' if self._note_read else 'no'} | Corrections: {corr} | {step_info}"
536
+ return f"[STATUS] {step_info}"
537
+
538
+ # ------------------------------------------------------------------
539
+ # Formatting
540
+ # ------------------------------------------------------------------
541
+
542
+ def _format_history(self, history: dict) -> str:
543
+ if not history or not any(history.values()):
544
+ return "No history data available."
545
+ field_map = {"pmh": "PMH", "medications": "Medications", "allergies": "Allergies", "social": "Social", "family": "Family"}
546
+ lines = []
547
+ for key, label in field_map.items():
548
+ val = history.get(key)
549
+ if val:
550
+ lines.append(f"{label}: {', '.join(val) if isinstance(val, list) else val}")
551
+ return "\n".join(lines) if lines else "No history data available."
552
+
553
+ def _format_vitals(self, vitals: dict) -> str:
554
+ if not vitals or not any(v for v in vitals.values() if v):
555
+ return "No vital signs recorded."
556
+ label_map = {"bp": "BP", "hr": "HR", "temp": "Temp", "rr": "RR", "spo2": "SpO2"}
557
+ parts = [f"{label}: {vitals[key]}" for key, label in label_map.items() if vitals.get(key)]
558
+ return " | ".join(parts) if parts else "No vital signs recorded."
559
+
560
+ def _format_dict(self, data, title: str = "") -> str:
561
+ if isinstance(data, dict):
562
+ lines = ([f"{title}:"] if title else []) + [f" {k}: {v}" for k, v in data.items()]
563
+ return "\n".join(lines)
564
+ return f"{title}: {data}" if title else str(data)
565
+
566
+ def _format_note_sentences(self, sentences_raw: str) -> str:
567
+ formatted = []
568
+ for line in sentences_raw.strip().split("\n"):
569
+ line = line.strip()
570
+ if not line:
571
+ continue
572
+ parts = line.split(None, 1)
573
+ if parts[0].isdigit():
574
+ formatted.append(f"[{parts[0]}] {parts[1] if len(parts) > 1 else ''}")
575
+ else:
576
+ formatted.append(line)
577
+ return "\n".join(formatted)
578
+
579
+ @staticmethod
580
+ def _fuzzy_key_match(query: str, data: dict) -> str | None:
581
+ q = query.lower().strip()
582
+ for key in data:
583
+ if key.lower() == q:
584
+ return key
585
+ for key in data:
586
+ if q in key.lower() or key.lower() in q:
587
+ return key
588
+ return None
server/command_parser.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parse CLI command strings into (command_name, args) tuples."""
2
+
3
+
4
+ def parse(raw: str) -> tuple[str, list[str]]:
5
+ """
6
+ Parse a raw command string.
7
+
8
+ 'chart.labs CBC' -> ('chart.labs', ['CBC'])
9
+ 'ddx.confirm Dengue fever' -> ('ddx.confirm', ['Dengue fever'])
10
+ 'note.correct 5 Fixed.' -> ('note.correct', ['5', 'Fixed.'])
11
+ 'submit 25.2' -> ('submit', ['25.2'])
12
+ 'chart.vitals' -> ('chart.vitals', [])
13
+ '' -> ('', [])
14
+ """
15
+ stripped = raw.strip()
16
+ if not stripped:
17
+ return ("", [])
18
+
19
+ parts = stripped.split(None, 1)
20
+ cmd = parts[0].lower()
21
+ rest = parts[1] if len(parts) > 1 else ""
22
+
23
+ if cmd == "note.correct" and rest:
24
+ tokens = rest.split(None, 1)
25
+ sentence_id = tokens[0]
26
+ correction_text = tokens[1] if len(tokens) > 1 else ""
27
+ return (cmd, [sentence_id, correction_text])
28
+
29
+ if rest:
30
+ return (cmd, [rest])
31
+ return (cmd, [])
server/constants.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Loads medical reference data from data/reference/ and provides lookup functions."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ _data_dir: Path | None = None
7
+ _lab_ranges: dict | None = None
8
+ _diagnostic_criteria: dict | None = None
9
+ _drug_info: dict | None = None
10
+
11
+
12
+ def _find_reference_dir() -> Path:
13
+ here = Path(__file__).resolve().parent
14
+ candidates = [
15
+ here.parent / "data" / "reference",
16
+ here / "data" / "reference",
17
+ ]
18
+ for c in candidates:
19
+ if c.is_dir():
20
+ return c
21
+ raise FileNotFoundError(f"data/reference/ not found. Checked: {candidates}")
22
+
23
+
24
+ def _load():
25
+ global _lab_ranges, _diagnostic_criteria, _drug_info, _data_dir
26
+ if _lab_ranges is not None:
27
+ return
28
+ _data_dir = _find_reference_dir()
29
+ with open(_data_dir / "lab_ranges.json", encoding="utf-8") as f:
30
+ _lab_ranges = json.load(f)
31
+ with open(_data_dir / "diagnostic_criteria.json", encoding="utf-8") as f:
32
+ _diagnostic_criteria = json.load(f)
33
+ with open(_data_dir / "drug_info.json", encoding="utf-8") as f:
34
+ _drug_info = json.load(f)
35
+
36
+
37
+ def _fuzzy_get(data: dict, key: str) -> tuple[str, any] | None:
38
+ k = key.strip().lower()
39
+ if k in data:
40
+ return k, data[k]
41
+ for dk, dv in data.items():
42
+ if k in dk or dk in k:
43
+ return dk, dv
44
+ return None
45
+
46
+
47
+ def lookup_range(test_name: str) -> str | None:
48
+ _load()
49
+ match = _fuzzy_get(_lab_ranges, test_name)
50
+ if match is None:
51
+ return None
52
+ key, entry = match
53
+ result = f"{key.upper()}: Normal range {entry['low']}-{entry['high']} {entry['unit']}".strip()
54
+ if entry.get("context"):
55
+ result += f"\n {entry['context']}"
56
+ return result
57
+
58
+
59
+ def lookup_criteria(condition: str) -> str | None:
60
+ _load()
61
+ match = _fuzzy_get(_diagnostic_criteria, condition)
62
+ if match is None:
63
+ return None
64
+ return match[1]
65
+
66
+
67
+ def lookup_drug(drug_name: str) -> str | None:
68
+ _load()
69
+ match = _fuzzy_get(_drug_info, drug_name)
70
+ if match is None:
71
+ return None
72
+ return match[1]
73
+
74
+
75
+ def interpret_value(test_name: str, value_str: str) -> str | None:
76
+ _load()
77
+ match = _fuzzy_get(_lab_ranges, test_name)
78
+ if match is None:
79
+ return None
80
+
81
+ try:
82
+ value = float(value_str)
83
+ except (ValueError, TypeError):
84
+ return f"Cannot parse '{value_str}' as a numeric value."
85
+
86
+ key, entry = match
87
+ low, high, unit = entry["low"], entry["high"], entry["unit"]
88
+
89
+ if value < low:
90
+ status = "LOW"
91
+ severity = "critically low" if value < low * 0.7 else "below normal"
92
+ elif value > high:
93
+ status = "HIGH"
94
+ severity = "critically elevated" if value > high * 1.5 else "above normal"
95
+ else:
96
+ status = "NORMAL"
97
+ severity = "within normal range"
98
+
99
+ result = f"{key.upper()} {value} {unit}: {status} — {severity} (normal {low}-{high})"
100
+ if entry.get("context") and status != "NORMAL":
101
+ result += f"\n {entry['context']}"
102
+ return result
server/data_loader.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lazy-loading data access for all three clinical datasets."""
2
+
3
+ import csv
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def _find_data_dir() -> Path:
10
+ here = Path(__file__).resolve().parent
11
+ candidates = [
12
+ here.parent / "data",
13
+ here / "data",
14
+ Path(os.getcwd()) / "data",
15
+ ]
16
+ for c in candidates:
17
+ if c.is_dir():
18
+ return c
19
+ raise FileNotFoundError(f"data/ directory not found. Checked: {candidates}")
20
+
21
+
22
+ class DataLoader:
23
+ def __init__(self):
24
+ self._diagnosis_cases: list[dict] | None = None
25
+ self._calculation_cases: list[dict] | None = None
26
+ self._note_cases: list[dict] | None = None
27
+ self._loaded = False
28
+
29
+ def load_all(self) -> None:
30
+ if self._loaded:
31
+ return
32
+ data_dir = _find_data_dir()
33
+ self._load_diagnosis(data_dir / "MedCaseReasoning")
34
+ self._load_calculations(data_dir / "MedCalcBench")
35
+ self._load_notes(data_dir / "MEDEC")
36
+ self._loaded = True
37
+
38
+ def _load_diagnosis(self, path: Path) -> None:
39
+ jsonl_path = path / "extracted_cases.jsonl"
40
+ cases = []
41
+ with open(jsonl_path, "r", encoding="utf-8") as f:
42
+ for line in f:
43
+ line = line.strip()
44
+ if line:
45
+ cases.append(json.loads(line))
46
+ self._diagnosis_cases = cases
47
+
48
+ def _load_calculations(self, path: Path) -> None:
49
+ cases = []
50
+ for filename in ["train_data.csv", "test_data.csv"]:
51
+ filepath = path / filename
52
+ if not filepath.exists():
53
+ continue
54
+ with open(filepath, "r", encoding="utf-8") as f:
55
+ reader = csv.DictReader(f)
56
+ for row in reader:
57
+ answer = row.get("Ground Truth Answer", "")
58
+ if not answer or answer == "None":
59
+ continue
60
+ try:
61
+ float(answer)
62
+ except (ValueError, TypeError):
63
+ continue
64
+ cases.append(row)
65
+ self._calculation_cases = cases
66
+
67
+ def _load_notes(self, path: Path) -> None:
68
+ cases = []
69
+ filenames = [
70
+ "MEDEC-Full-TrainingSet-with-ErrorType.csv",
71
+ "MEDEC-MS-ValidationSet-with-GroundTruth-and-ErrorType.csv",
72
+ "MEDEC-MS-TestSet-with-GroundTruth-and-ErrorType.csv",
73
+ ]
74
+ for filename in filenames:
75
+ filepath = path / filename
76
+ if not filepath.exists():
77
+ continue
78
+ with open(filepath, "r", encoding="utf-8") as f:
79
+ reader = csv.DictReader(f)
80
+ for row in reader:
81
+ if not row.get("Text", "").strip() and not row.get("Sentences", "").strip():
82
+ continue
83
+ flag = row.get("Error Flag", "0") or "0"
84
+ try:
85
+ row["Error Flag"] = int(float(flag))
86
+ except (ValueError, TypeError):
87
+ row["Error Flag"] = 0
88
+ cases.append(row)
89
+ self._note_cases = cases
90
+
91
+ def get_diagnosis_cases(self) -> list[dict]:
92
+ self.load_all()
93
+ return self._diagnosis_cases or []
94
+
95
+ def get_calculation_cases(self) -> list[dict]:
96
+ self.load_all()
97
+ return self._calculation_cases or []
98
+
99
+ def get_note_cases(self) -> list[dict]:
100
+ self.load_all()
101
+ return self._note_cases or []
server/graders.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dense reward functions for diagnosis, calculation, and note review tasks.
2
+
3
+ Reward budgets per task type:
4
+ diagnosis: 0.30 intermediate + 0.70 terminal = 1.0
5
+ calculation: 0.15 intermediate + 0.85 terminal = 1.0
6
+ note_review: 0.10 intermediate + 0.90 terminal = 1.0
7
+ """
8
+
9
+ from rapidfuzz import fuzz
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Diagnosis grading
14
+ # ---------------------------------------------------------------------------
15
+
16
+ def diagnosis_step_reward(
17
+ command: str,
18
+ args: list[str],
19
+ accessed_sections: set[str],
20
+ relevant_sections: set[str],
21
+ ) -> float:
22
+ """Step reward for chart exploration commands. Budget: 0.30 total."""
23
+ n = len(relevant_sections)
24
+ if n == 0:
25
+ return 0.0
26
+
27
+ per_section = 0.30 / n
28
+ section_key = _chart_command_to_section_key(command, args)
29
+ if section_key is None:
30
+ return 0.0
31
+ if section_key in accessed_sections:
32
+ return 0.0
33
+ if section_key not in relevant_sections:
34
+ return 0.0
35
+
36
+ accessed_sections.add(section_key)
37
+ return round(per_section, 4)
38
+
39
+
40
+ def _chart_command_to_section_key(command: str, args: list[str]) -> str | None:
41
+ if command == "chart.history":
42
+ return "history"
43
+ if command == "chart.vitals":
44
+ return "vitals"
45
+ if command == "chart.labs" and args:
46
+ return f"labs.{args[0].lower()}"
47
+ if command == "chart.imaging" and args:
48
+ return f"imaging.{args[0].lower()}"
49
+ if command == "chart.exam" and args:
50
+ return f"exam.{args[0].lower()}"
51
+ return None
52
+
53
+
54
+ def diagnosis_terminal_reward(
55
+ confirmed: str,
56
+ ground_truth_diagnosis: str,
57
+ accessed_sections: set[str],
58
+ relevant_sections: set[str],
59
+ ddx_list: list[str],
60
+ steps_taken: int,
61
+ ) -> float:
62
+ """Terminal reward on ddx.confirm. Budget: 0.70 total."""
63
+ n = max(len(relevant_sections), 1)
64
+
65
+ # Diagnostic accuracy (0.40)
66
+ ratio = fuzz.token_sort_ratio(confirmed.lower(), ground_truth_diagnosis.lower())
67
+ if ratio >= 80:
68
+ accuracy_score = 1.0
69
+ elif ratio >= 60:
70
+ accuracy_score = 0.5
71
+ else:
72
+ accuracy_score = 0.0
73
+ accuracy = 0.40 * accuracy_score
74
+
75
+ # Workup completeness (0.10)
76
+ accessed_relevant = len(accessed_sections & relevant_sections)
77
+ completeness = 0.10 * (accessed_relevant / n)
78
+
79
+ # Efficiency (0.10) — baseline is N+2 steps
80
+ excess = max(0, steps_taken - n - 2)
81
+ efficiency = 0.10 * max(0.0, 1.0 - excess / 20.0)
82
+
83
+ # Reasoning quality (0.10) — DDX breadth + whether answer was in DDX
84
+ ddx_breadth = min(len(ddx_list), 3) / 3.0 * 0.5
85
+ confirmed_in_ddx = 0.5 if any(
86
+ fuzz.token_sort_ratio(confirmed.lower(), d.lower()) >= 70
87
+ for d in ddx_list
88
+ ) else 0.0
89
+ reasoning = 0.10 * (ddx_breadth + confirmed_in_ddx)
90
+
91
+ return round(accuracy + completeness + efficiency + reasoning, 4)
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Calculation grading
96
+ # ---------------------------------------------------------------------------
97
+
98
+ def calculation_step_reward(command: str, case_read: bool, calculator_declared: bool) -> float:
99
+ """Step reward for case reading and calculator declaration. Budget: 0.15."""
100
+ if command == "case.read" and not case_read:
101
+ return 0.10
102
+ if command == "calculate" and not calculator_declared:
103
+ return 0.05
104
+ return 0.0
105
+
106
+
107
+ def calculation_terminal_reward(
108
+ submitted_value: float,
109
+ ground_truth: float,
110
+ lower_limit: float,
111
+ upper_limit: float,
112
+ calculator_used: str,
113
+ expected_calculator: str,
114
+ steps_taken: int,
115
+ ) -> float:
116
+ """Terminal reward on submit. Budget: 0.85."""
117
+ # Numeric accuracy (0.50)
118
+ if lower_limit <= submitted_value <= upper_limit:
119
+ numeric_score = 1.0
120
+ else:
121
+ band = upper_limit - lower_limit
122
+ extended_lower = lower_limit - band
123
+ extended_upper = upper_limit + band
124
+ if extended_lower <= submitted_value <= extended_upper:
125
+ numeric_score = 0.5
126
+ else:
127
+ numeric_score = 0.0
128
+ numeric = 0.50 * numeric_score
129
+
130
+ # Correct calculator (0.25)
131
+ calc_ratio = fuzz.token_sort_ratio(calculator_used.lower(), expected_calculator.lower())
132
+ calc_match = 0.25 * (1.0 if calc_ratio >= 75 else 0.0)
133
+
134
+ # Efficiency (0.10) — perfect if ≤3 steps, linear decay to 0 at 10
135
+ if steps_taken <= 3:
136
+ eff_score = 1.0
137
+ elif steps_taken >= 10:
138
+ eff_score = 0.0
139
+ else:
140
+ eff_score = 1.0 - (steps_taken - 3) / 7.0
141
+ efficiency = 0.10 * eff_score
142
+
143
+ return round(numeric + calc_match + efficiency, 4)
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Note review grading
148
+ # ---------------------------------------------------------------------------
149
+
150
+ def note_step_reward(command: str, note_read: bool) -> float:
151
+ """Step reward for reading the note. Budget: 0.10."""
152
+ if command == "note.read" and not note_read:
153
+ return 0.10
154
+ return 0.0
155
+
156
+
157
+ def note_terminal_reward(
158
+ corrections: dict[str, str],
159
+ has_error: bool,
160
+ error_sentence_id: str | None,
161
+ corrected_sentence: str | None,
162
+ ) -> float:
163
+ """Terminal reward on note.approve. Budget: 0.90."""
164
+ if not has_error:
165
+ # No error in note — agent should approve without corrections
166
+ if len(corrections) == 0:
167
+ return 0.90
168
+ # False positive penalty
169
+ return round(0.90 * max(0.0, 1.0 - len(corrections) * 0.3), 4)
170
+
171
+ # Note has an error — evaluate detection + correction
172
+ found_correct_sentence = False
173
+ correction_quality = 0.0
174
+
175
+ if error_sentence_id is not None:
176
+ target_id = str(error_sentence_id).strip()
177
+ if target_id in corrections:
178
+ found_correct_sentence = True
179
+ if corrected_sentence:
180
+ ratio = fuzz.ratio(
181
+ corrections[target_id].strip().lower(),
182
+ corrected_sentence.strip().lower(),
183
+ )
184
+ correction_quality = ratio / 100.0
185
+
186
+ # Error detection (0.40)
187
+ detection = 0.40 * (1.0 if found_correct_sentence else 0.0)
188
+
189
+ # Correction accuracy (0.40)
190
+ correction = 0.40 * correction_quality
191
+
192
+ # False positive penalty (0.10)
193
+ total_corrections = len(corrections)
194
+ true_positives = 1 if found_correct_sentence else 0
195
+ false_positives = total_corrections - true_positives
196
+ fp_penalty = 1.0 - (false_positives / max(total_corrections, 1))
197
+ no_fp = 0.10 * max(0.0, fp_penalty)
198
+
199
+ return round(detection + correction + no_fp, 4)
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Utility: compute relevant sections from extracted case data
204
+ # ---------------------------------------------------------------------------
205
+
206
+ def compute_relevant_sections(extracted: dict) -> set[str]:
207
+ """Build the set of non-empty data sections for a diagnosis case."""
208
+ sections = set()
209
+
210
+ if _has_data(extracted.get("vitals")):
211
+ sections.add("vitals")
212
+ if _has_data(extracted.get("history")):
213
+ sections.add("history")
214
+
215
+ for panel_name, panel_data in (extracted.get("labs") or {}).items():
216
+ if _has_data(panel_data):
217
+ sections.add(f"labs.{panel_name.lower()}")
218
+
219
+ for modality, findings in (extracted.get("imaging") or {}).items():
220
+ if _has_data(findings):
221
+ sections.add(f"imaging.{modality.lower()}")
222
+
223
+ for system, findings in (extracted.get("physical_exam") or {}).items():
224
+ if _has_data(findings):
225
+ sections.add(f"exam.{system.lower()}")
226
+
227
+ return sections
228
+
229
+
230
+ def _has_data(value) -> bool:
231
+ if value is None:
232
+ return False
233
+ if isinstance(value, str):
234
+ return bool(value.strip())
235
+ if isinstance(value, dict):
236
+ return any(_has_data(v) for v in value.values())
237
+ if isinstance(value, list):
238
+ return len(value) > 0
239
+ return True
server/requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ openenv[core]>=0.2.0
2
+ fastapi>=0.115.0
3
+ uvicorn>=0.24.0
4
+ rapidfuzz>=3.0.0
5
+
6
+
7
+
server/task_configs.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Difficulty tier definitions and case selection logic."""
2
+
3
+ from random import Random
4
+
5
+ SIMPLE_CALCULATORS = {
6
+ "bmi", "body mass index",
7
+ "anion gap",
8
+ "mean arterial pressure", "map",
9
+ "ideal body weight", "ibw",
10
+ "body surface area", "bsa",
11
+ "corrected sodium",
12
+ "corrected calcium",
13
+ "free water deficit",
14
+ }
15
+
16
+ COMPLEX_CALCULATORS = {
17
+ "apache ii", "apache",
18
+ "wells", "wells criteria",
19
+ "cha2ds2-vasc", "cha2ds2",
20
+ "curb-65", "curb",
21
+ "gcs", "glasgow coma scale",
22
+ "meld", "meld score",
23
+ "child-pugh", "child pugh",
24
+ "sofa", "sofa score",
25
+ "ranson", "ranson criteria",
26
+ }
27
+
28
+ SUBTLE_ERROR_TYPES = {"pharmacotherapy", "causalorganism", "causal organism"}
29
+
30
+
31
+ def select_case(task_type: str, difficulty: str, cases: list[dict], rng: Random) -> dict:
32
+ filtered = _filter_by_difficulty(task_type, difficulty, cases)
33
+ if not filtered:
34
+ filtered = cases
35
+ return rng.choice(filtered)
36
+
37
+
38
+ DEFAULT_TASK_TYPE = {
39
+ "easy": "note_review",
40
+ "medium": "calculation",
41
+ "hard": "diagnosis",
42
+ }
43
+
44
+
45
+ def get_default_task_type(difficulty: str, rng: Random | None = None) -> str:
46
+ return DEFAULT_TASK_TYPE.get(difficulty, "diagnosis")
47
+
48
+
49
+ def _filter_by_difficulty(task_type: str, difficulty: str, cases: list[dict]) -> list[dict]:
50
+ if task_type == "diagnosis":
51
+ return _filter_diagnosis(difficulty, cases)
52
+ elif task_type == "calculation":
53
+ return _filter_calculation(difficulty, cases)
54
+ elif task_type == "note_review":
55
+ return _filter_notes(difficulty, cases)
56
+ return cases
57
+
58
+
59
+ def _filter_diagnosis(difficulty: str, cases: list[dict]) -> list[dict]:
60
+ def score(c: dict) -> int:
61
+ try:
62
+ return int(c.get("score", 0))
63
+ except (ValueError, TypeError):
64
+ return 0
65
+
66
+ if difficulty == "easy":
67
+ return [c for c in cases if 12 <= score(c) <= 17]
68
+ elif difficulty == "medium":
69
+ return [c for c in cases if 17 < score(c) <= 22]
70
+ elif difficulty == "hard":
71
+ return [c for c in cases if score(c) > 22]
72
+ return cases
73
+
74
+
75
+ def _matches_set(name: str, keyword_set: set[str]) -> bool:
76
+ return any(kw in name for kw in keyword_set)
77
+
78
+
79
+ def _filter_calculation(difficulty: str, cases: list[dict]) -> list[dict]:
80
+ def calc_name(c: dict) -> str:
81
+ return (c.get("Calculator Name") or "").lower()
82
+
83
+ if difficulty == "easy":
84
+ return [c for c in cases if _matches_set(calc_name(c), SIMPLE_CALCULATORS)]
85
+ elif difficulty == "hard":
86
+ return [c for c in cases if _matches_set(calc_name(c), COMPLEX_CALCULATORS)]
87
+ elif difficulty == "medium":
88
+ return [
89
+ c for c in cases
90
+ if not _matches_set(calc_name(c), SIMPLE_CALCULATORS)
91
+ and not _matches_set(calc_name(c), COMPLEX_CALCULATORS)
92
+ ]
93
+ return cases
94
+
95
+
96
+ def _filter_notes(difficulty: str, cases: list[dict]) -> list[dict]:
97
+ def error_flag(c: dict) -> int:
98
+ try:
99
+ return int(float(c.get("Error Flag", 0)))
100
+ except (ValueError, TypeError):
101
+ return 0
102
+
103
+ def error_type(c: dict) -> str:
104
+ return (c.get("Error Type") or "").lower().strip()
105
+
106
+ if difficulty == "easy":
107
+ return [c for c in cases if error_flag(c) == 0]
108
+ elif difficulty == "medium":
109
+ return [
110
+ c for c in cases
111
+ if error_flag(c) == 1
112
+ and error_type(c) not in SUBTLE_ERROR_TYPES
113
+ ]
114
+ elif difficulty == "hard":
115
+ return [
116
+ c for c in cases
117
+ if error_flag(c) == 1
118
+ and error_type(c) in SUBTLE_ERROR_TYPES
119
+ ]
120
+ return cases
server/ui.py ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom Gradio dashboard — plugs into OpenEnv's ``gradio_builder`` hook at /web."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ import gradio as gr
8
+
9
+ _CSS = """
10
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
11
+
12
+ .term-bar {
13
+ background: #1a2133;
14
+ border: 1px solid rgba(255,255,255,0.12);
15
+ border-bottom: none;
16
+ border-radius: 12px 12px 0 0;
17
+ padding: 11px 16px;
18
+ display: flex;
19
+ align-items: center;
20
+ gap: 12px;
21
+ margin-top: 6px;
22
+ position: relative;
23
+ z-index: 2;
24
+ box-shadow: 0 -4px 20px rgba(0,0,0,0.3);
25
+ }
26
+ .term-dots { display: flex; gap: 7px; }
27
+ .term-dot { width: 11px; height: 11px; border-radius: 50%; }
28
+ .term-dot.r { background: #ff5f57; }
29
+ .term-dot.y { background: #febc2e; }
30
+ .term-dot.g { background: #28c840; }
31
+ .term-title {
32
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
33
+ font-size: 11px;
34
+ color: #4a5568;
35
+ letter-spacing: 0.02em;
36
+ }
37
+
38
+ .terminal-area {
39
+ margin-top: 0 !important;
40
+ border: 1px solid rgba(255,255,255,0.12) !important;
41
+ border-top: 1px solid rgba(255,255,255,0.05) !important;
42
+ border-radius: 0 0 12px 12px !important;
43
+ overflow: hidden;
44
+ position: relative;
45
+ z-index: 1;
46
+ box-shadow:
47
+ 0 8px 32px rgba(0,0,0,0.5),
48
+ 0 2px 8px rgba(0,0,0,0.3),
49
+ inset 0 1px 0 rgba(255,255,255,0.03);
50
+ max-height: 720px !important;
51
+ }
52
+ .terminal-area .cm-scroller,
53
+ .terminal-area .code-block,
54
+ .terminal-area pre { max-height: 680px !important; overflow-y: auto !important; }
55
+ .terminal-area label { display: none !important; }
56
+ .terminal-area pre, .terminal-area code, .terminal-area textarea {
57
+ font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code',
58
+ ui-monospace, monospace !important;
59
+ font-size: 13px !important;
60
+ line-height: 1.7 !important;
61
+ background: #0a0f18 !important;
62
+ color: #c9d1d9 !important;
63
+ letter-spacing: 0.01em !important;
64
+ }
65
+
66
+ .cmd-input input, .cmd-input textarea {
67
+ font-family: 'JetBrains Mono', ui-monospace, monospace !important;
68
+ font-size: 13px !important;
69
+ background: #151c28 !important;
70
+ color: #e2e8f0 !important;
71
+ border: 1px solid rgba(255,255,255,0.15) !important;
72
+ border-radius: 8px !important;
73
+ padding: 11px 14px !important;
74
+ }
75
+ .cmd-input input::placeholder, .cmd-input textarea::placeholder {
76
+ color: #64748b !important;
77
+ }
78
+
79
+ .sidebar-panel > div { padding: 0 !important; }
80
+ .execute-btn { min-width: 110px !important; }
81
+ """
82
+
83
+
84
+ def _header_html() -> str:
85
+ return (
86
+ '<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:'
87
+ 'wght@400;500;600;700&display=swap" rel="stylesheet">'
88
+ '<div style="padding:12px 0 4px;display:flex;align-items:baseline;gap:10px;">'
89
+ '<span style="font-family:&#39;JetBrains Mono&#39;,monospace;font-size:18px;'
90
+ 'font-weight:700;color:#e2e8f0;letter-spacing:-0.03em;">'
91
+ '\U0001f3e5 Clinical Terminal</span>'
92
+ '<span style="font-family:&#39;JetBrains Mono&#39;,monospace;font-size:10px;'
93
+ 'color:#3d4a5c;letter-spacing:0.08em;padding:2px 8px;'
94
+ 'border:1px solid rgba(255,255,255,0.06);border-radius:4px;">v1.0</span>'
95
+ '</div>'
96
+ )
97
+
98
+
99
+ def _terminal_bar_html() -> str:
100
+ return (
101
+ '<div class="term-bar">'
102
+ '<div class="term-dots">'
103
+ '<span class="term-dot r"></span>'
104
+ '<span class="term-dot y"></span>'
105
+ '<span class="term-dot g"></span>'
106
+ '</div>'
107
+ '<span class="term-title">claude code for healthcare</span>'
108
+ '</div>'
109
+ )
110
+
111
+
112
+ def _score_html(score: float) -> str:
113
+ if score > 0:
114
+ color, glow, bg = "#4ade80", "rgba(74,222,128,0.3)", "#0c1f14"
115
+ elif score < 0:
116
+ color, glow, bg = "#f87171", "rgba(248,113,113,0.3)", "#1f0c0c"
117
+ else:
118
+ color, glow, bg = "#94a3b8", "rgba(148,163,184,0.1)", "#151c28"
119
+
120
+ return (
121
+ f'<div style="background:{bg};border:1px solid rgba(255,255,255,0.1);'
122
+ 'border-radius:10px;padding:20px;text-align:center;margin-bottom:10px;">'
123
+ '<div style="font-family:&#39;JetBrains Mono&#39;,monospace;font-size:10px;'
124
+ 'color:#8b949e;text-transform:uppercase;letter-spacing:2px;'
125
+ 'margin-bottom:8px;">Episode Score</div>'
126
+ f'<div style="font-family:&#39;JetBrains Mono&#39;,monospace;font-size:38px;'
127
+ f'font-weight:700;color:{color};font-variant-numeric:tabular-nums;'
128
+ f'text-shadow:0 0 30px {glow},0 0 60px {glow};'
129
+ f'letter-spacing:-0.02em;">{score:.2f}</div></div>'
130
+ )
131
+
132
+
133
+ def _status_html(
134
+ task_type: str,
135
+ difficulty: str,
136
+ step: int,
137
+ max_steps: int,
138
+ ) -> str:
139
+ pct = int(step / max_steps * 100) if max_steps else 0
140
+ bar_color = "#3b82f6" if pct < 75 else "#f59e0b" if pct < 95 else "#ef4444"
141
+
142
+ badge_bg, badge_fg = "rgba(96,165,250,0.15)", "#7db8f7"
143
+ if task_type == "diagnosis":
144
+ badge_bg, badge_fg = "rgba(251,191,36,0.15)", "#fcd34d"
145
+ elif task_type == "calculation":
146
+ badge_bg, badge_fg = "rgba(167,139,250,0.15)", "#c4b5fd"
147
+ elif task_type == "note_review":
148
+ badge_bg, badge_fg = "rgba(52,211,153,0.15)", "#6ee7b7"
149
+
150
+ lbl = ("font-size:10px;color:#6b7d94;text-transform:uppercase;"
151
+ "letter-spacing:1px;font-family:'JetBrains Mono',monospace;")
152
+
153
+ return (
154
+ '<div style="background:#151c28;border:1px solid rgba(255,255,255,0.1);'
155
+ 'border-radius:10px;padding:16px;margin-bottom:10px;">'
156
+ f'<div style="{lbl}margin-bottom:14px;font-weight:600;">Status</div>'
157
+ f'<div style="margin-bottom:14px;"><span style="{lbl}">Task</span><br/>'
158
+ f'<span style="display:inline-block;background:{badge_bg};'
159
+ f'color:{badge_fg};padding:3px 10px;border-radius:5px;'
160
+ 'font-family:&#39;JetBrains Mono&#39;,monospace;font-size:12px;'
161
+ f'font-weight:600;margin-top:4px;">{task_type or chr(0x2014)}</span></div>'
162
+ f'<div style="margin-bottom:14px;"><span style="{lbl}">Difficulty</span><br/>'
163
+ '<span style="font-family:&#39;JetBrains Mono&#39;,monospace;font-size:13px;'
164
+ f'color:#c9d1d9;margin-top:2px;display:inline-block;">'
165
+ f'{difficulty or chr(0x2014)}</span></div>'
166
+ f'<div><span style="{lbl}">Progress</span>'
167
+ '<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">'
168
+ '<div style="flex:1;height:4px;background:rgba(255,255,255,0.08);'
169
+ 'border-radius:2px;overflow:hidden;">'
170
+ f'<div style="width:{pct}%;height:100%;background:{bar_color};'
171
+ 'border-radius:2px;transition:width .4s ease;"></div></div>'
172
+ '<span style="font-family:&#39;JetBrains Mono&#39;,monospace;font-size:11px;'
173
+ f'color:#8b949e;font-weight:600;">{step}/{max_steps}</span>'
174
+ '</div></div></div>'
175
+ )
176
+
177
+
178
+ def _commands_html(cmds: List[str]) -> str:
179
+ lbl = ("font-family:'JetBrains Mono',monospace;font-size:10px;color:#6b7d94;"
180
+ "text-transform:uppercase;letter-spacing:1px;font-weight:600;")
181
+ if not cmds:
182
+ return (
183
+ '<div style="background:#151c28;border:1px solid rgba(255,255,255,0.1);'
184
+ 'border-radius:10px;padding:16px;">'
185
+ f'<div style="{lbl}margin-bottom:8px;">Commands</div>'
186
+ '<p style="font-family:&#39;JetBrains Mono&#39;,monospace;font-size:11px;'
187
+ 'color:#6b7d94;margin:0;font-style:italic;">awaiting reset\u2026</p></div>'
188
+ )
189
+
190
+ items = "".join(
191
+ f'<div style="padding:4px 0;font-family:&#39;JetBrains Mono&#39;,monospace;'
192
+ f'font-size:12px;color:#c9d1d9;border-bottom:1px solid rgba(255,255,255,0.05);">'
193
+ f'<span style="color:#58a6ff;margin-right:6px;">\u203a</span>{c}</div>'
194
+ for c in cmds
195
+ )
196
+ return (
197
+ '<div style="background:#151c28;border:1px solid rgba(255,255,255,0.1);'
198
+ 'border-radius:10px;padding:16px;">'
199
+ f'<div style="{lbl}margin-bottom:10px;">Commands</div>'
200
+ f'{items}</div>'
201
+ )
202
+
203
+ items = "".join(
204
+ f'<div style="padding:3px 0;font-family:&#39;JetBrains Mono&#39;,monospace;'
205
+ f'font-size:11px;color:#8b949e;border-bottom:1px solid rgba(255,255,255,0.03);">'
206
+ f'<span style="color:#3d4a5c;margin-right:4px;">\u203a</span> {c}</div>'
207
+ for c in cmds
208
+ )
209
+ return (
210
+ '<div style="background:#0d1117;border:1px solid rgba(255,255,255,0.06);'
211
+ 'border-radius:10px;padding:16px;">'
212
+ f'<div style="{lbl}font-size:10px;color:#4a5568;margin-bottom:10px;">Commands</div>'
213
+ f'{items}</div>'
214
+ )
215
+
216
+
217
+ _TASK_OPTIONS = [
218
+ "Easy \u2014 Note Review",
219
+ "Medium \u2014 Calculation",
220
+ "Hard \u2014 Diagnosis",
221
+ ]
222
+ _TASK_KEY = {
223
+ _TASK_OPTIONS[0]: "easy",
224
+ _TASK_OPTIONS[1]: "medium",
225
+ _TASK_OPTIONS[2]: "hard",
226
+ }
227
+
228
+
229
+ def build_custom_dashboard(
230
+ web_manager: Any,
231
+ action_fields: List[Dict[str, Any]],
232
+ metadata: Any,
233
+ is_chat_env: bool,
234
+ title: str,
235
+ quick_start_md: Optional[str],
236
+ ) -> gr.Blocks:
237
+ """Return a ``gr.Blocks`` app for the Custom tab at /web."""
238
+
239
+ async def on_reset(difficulty: str):
240
+ task_key = _TASK_KEY.get(difficulty, "easy")
241
+ try:
242
+ data = await web_manager.reset_environment(
243
+ {"options": {"task": task_key}}
244
+ )
245
+ except Exception as exc:
246
+ return (
247
+ f"ERROR: {exc}",
248
+ _status_html("\u2014", "\u2014", 0, 50),
249
+ _commands_html([]),
250
+ _score_html(0.0),
251
+ "",
252
+ )
253
+
254
+ obs = data.get("observation", {})
255
+ output = obs.get("output", "")
256
+ task_type = obs.get("task_type", "")
257
+ step = obs.get("step_number", 0)
258
+ max_steps = obs.get("max_steps", 50)
259
+ cmds = obs.get("available_commands", [])
260
+
261
+ pipe = "\u2502"
262
+ indented_output = output.replace(chr(10), chr(10) + " " + pipe + " ")
263
+ terminal = (
264
+ f" \u250c\u2500 {task_type.upper()} \u2500\u2500 new episode\n"
265
+ f" {pipe}\n"
266
+ f" {pipe} {indented_output}\n"
267
+ f" {pipe}\n"
268
+ f" \u2514\u2500\u2500\u2500\n"
269
+ )
270
+
271
+ return (
272
+ terminal,
273
+ _status_html(task_type, task_key, step, max_steps),
274
+ _commands_html(cmds),
275
+ _score_html(0.0),
276
+ "",
277
+ )
278
+
279
+ async def on_step(command: str, history: str):
280
+ if not command or not command.strip():
281
+ return (
282
+ history or "",
283
+ "",
284
+ gr.update(),
285
+ gr.update(),
286
+ gr.update(),
287
+ )
288
+
289
+ try:
290
+ data = await web_manager.step_environment(
291
+ {"command": command.strip()}
292
+ )
293
+ except Exception as exc:
294
+ return (
295
+ (history or "") + f"\n\u276f {command}\n \u2718 {exc}\n",
296
+ "",
297
+ gr.update(),
298
+ gr.update(),
299
+ gr.update(),
300
+ )
301
+
302
+ obs = data.get("observation", {})
303
+ output = obs.get("output", "")
304
+ error = obs.get("error", "")
305
+ reward = data.get("reward", 0.0)
306
+ done = data.get("done", False)
307
+ step = obs.get("step_number", 0)
308
+ max_steps = obs.get("max_steps", 50)
309
+ task_type = obs.get("task_type", "")
310
+ cmds = obs.get("available_commands", [])
311
+
312
+ entry = f"\n\u276f {command}\n"
313
+ if error:
314
+ entry += f" \u2718 {error}\n"
315
+ entry += f" {output.replace(chr(10), chr(10) + ' ')}\n"
316
+ if reward != 0:
317
+ sign = "+" if reward > 0 else ""
318
+ entry += f" \u2500\u2500 reward: {sign}{reward:.4f}\n"
319
+ if done:
320
+ entry += "\n \u2588\u2588 EPISODE COMPLETE \u2588\u2588\n"
321
+
322
+ full = (history or "") + entry
323
+
324
+ try:
325
+ state = web_manager.get_state()
326
+ score = state.get("total_score", 0.0)
327
+ difficulty = state.get("difficulty", "")
328
+ except Exception:
329
+ score = 0.0
330
+ difficulty = ""
331
+
332
+ return (
333
+ full,
334
+ "",
335
+ _status_html(task_type, difficulty, step, max_steps),
336
+ _commands_html(cmds),
337
+ _score_html(score),
338
+ )
339
+
340
+ _SCROLL_JS = """
341
+ () => {
342
+ setTimeout(() => {
343
+ const s = document.querySelector('.terminal-area .cm-scroller')
344
+ || document.querySelector('.terminal-area pre');
345
+ if (s) s.scrollTop = s.scrollHeight;
346
+ }, 150);
347
+ }
348
+ """
349
+
350
+ with gr.Blocks() as blocks:
351
+ gr.HTML(f"<style>{_CSS}</style>" + _header_html())
352
+
353
+ with gr.Row(equal_height=False):
354
+ with gr.Column(scale=7, min_width=480):
355
+ with gr.Row():
356
+ difficulty = gr.Dropdown(
357
+ choices=_TASK_OPTIONS,
358
+ value=_TASK_OPTIONS[0],
359
+ label="Task",
360
+ scale=3,
361
+ interactive=True,
362
+ )
363
+ reset_btn = gr.Button(
364
+ "Start Episode",
365
+ variant="primary",
366
+ scale=1,
367
+ )
368
+
369
+ gr.HTML(_terminal_bar_html())
370
+
371
+ terminal = gr.Code(
372
+ value=(
373
+ " Welcome to Claude Code for Healthcare.\n"
374
+ " Select a task and press Start Episode.\n"
375
+ + "\n" * 18
376
+ ),
377
+ label="Terminal",
378
+ language=None,
379
+ lines=20,
380
+ interactive=False,
381
+ elem_classes=["terminal-area"],
382
+ )
383
+
384
+ with gr.Column(scale=3, min_width=250, elem_classes=["sidebar-panel"]):
385
+ score_md = gr.HTML(_score_html(0.0))
386
+ cmd_input = gr.Textbox(
387
+ placeholder="\u276f type a command\u2026",
388
+ label="Command",
389
+ elem_classes=["cmd-input"],
390
+ )
391
+ send_btn = gr.Button(
392
+ "Execute \u21b5",
393
+ variant="primary",
394
+ elem_classes=["execute-btn"],
395
+ )
396
+ status_md = gr.HTML(_status_html("\u2014", "\u2014", 0, 50))
397
+ commands_md = gr.HTML(_commands_html([]))
398
+
399
+ reset_outputs = [terminal, status_md, commands_md, score_md, cmd_input]
400
+ step_outputs = [terminal, cmd_input, status_md, commands_md, score_md]
401
+
402
+ reset_btn.click(
403
+ fn=on_reset,
404
+ inputs=[difficulty],
405
+ outputs=reset_outputs,
406
+ ).then(fn=None, js=_SCROLL_JS)
407
+ send_btn.click(
408
+ fn=on_step,
409
+ inputs=[cmd_input, terminal],
410
+ outputs=step_outputs,
411
+ ).then(fn=None, js=_SCROLL_JS)
412
+ cmd_input.submit(
413
+ fn=on_step,
414
+ inputs=[cmd_input, terminal],
415
+ outputs=step_outputs,
416
+ ).then(fn=None, js=_SCROLL_JS)
417
+
418
+ return blocks
validate-submission.sh ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #
3
+ # validate-submission.sh — OpenEnv Submission Validator
4
+ #
5
+ # Checks that your HF Space is live, Docker image builds, and openenv validate passes.
6
+ #
7
+ # Prerequisites:
8
+ # - Docker: https://docs.docker.com/get-docker/
9
+ # - openenv-core: pip install openenv-core
10
+ # - curl (usually pre-installed)
11
+ #
12
+ # Run:
13
+ # curl -fsSL https://raw.githubusercontent.com/<owner>/<repo>/main/scripts/validate-submission.sh | bash -s -- <ping_url> [repo_dir]
14
+ #
15
+ # Or download and run locally:
16
+ # chmod +x validate-submission.sh
17
+ # ./validate-submission.sh <ping_url> [repo_dir]
18
+ #
19
+ # Arguments:
20
+ # ping_url Your HuggingFace Space URL (e.g. https://your-space.hf.space)
21
+ # repo_dir Path to your repo (default: current directory)
22
+ #
23
+ # Examples:
24
+ # ./validate-submission.sh https://my-team.hf.space
25
+ # ./validate-submission.sh https://my-team.hf.space ./my-repo
26
+ #
27
+
28
+ set -uo pipefail
29
+
30
+ DOCKER_BUILD_TIMEOUT=600
31
+ if [ -t 1 ]; then
32
+ RED='\033[0;31m'
33
+ GREEN='\033[0;32m'
34
+ YELLOW='\033[1;33m'
35
+ BOLD='\033[1m'
36
+ NC='\033[0m'
37
+ else
38
+ RED='' GREEN='' YELLOW='' BOLD='' NC=''
39
+ fi
40
+
41
+ run_with_timeout() {
42
+ local secs="$1"; shift
43
+ if command -v timeout &>/dev/null; then
44
+ timeout "$secs" "$@"
45
+ elif command -v gtimeout &>/dev/null; then
46
+ gtimeout "$secs" "$@"
47
+ else
48
+ "$@" &
49
+ local pid=$!
50
+ ( sleep "$secs" && kill "$pid" 2>/dev/null ) &
51
+ local watcher=$!
52
+ wait "$pid" 2>/dev/null
53
+ local rc=$?
54
+ kill "$watcher" 2>/dev/null
55
+ wait "$watcher" 2>/dev/null
56
+ return $rc
57
+ fi
58
+ }
59
+
60
+ portable_mktemp() {
61
+ local prefix="${1:-validate}"
62
+ mktemp "${TMPDIR:-/tmp}/${prefix}-XXXXXX" 2>/dev/null || mktemp
63
+ }
64
+
65
+ CLEANUP_FILES=()
66
+ cleanup() { rm -f "${CLEANUP_FILES[@]+"${CLEANUP_FILES[@]}"}"; }
67
+ trap cleanup EXIT
68
+
69
+ PING_URL="${1:-}"
70
+ REPO_DIR="${2:-.}"
71
+
72
+ if [ -z "$PING_URL" ]; then
73
+ printf "Usage: %s <ping_url> [repo_dir]\n" "$0"
74
+ printf "\n"
75
+ printf " ping_url Your HuggingFace Space URL (e.g. https://your-space.hf.space)\n"
76
+ printf " repo_dir Path to your repo (default: current directory)\n"
77
+ exit 1
78
+ fi
79
+
80
+ if ! REPO_DIR="$(cd "$REPO_DIR" 2>/dev/null && pwd)"; then
81
+ printf "Error: directory '%s' not found\n" "${2:-.}"
82
+ exit 1
83
+ fi
84
+ PING_URL="${PING_URL%/}"
85
+ export PING_URL
86
+ PASS=0
87
+
88
+ log() { printf "[%s] %b\n" "$(date -u +%H:%M:%S)" "$*"; }
89
+ pass() { log "${GREEN}PASSED${NC} -- $1"; PASS=$((PASS + 1)); }
90
+ fail() { log "${RED}FAILED${NC} -- $1"; }
91
+ hint() { printf " ${YELLOW}Hint:${NC} %b\n" "$1"; }
92
+ stop_at() {
93
+ printf "\n"
94
+ printf "${RED}${BOLD}Validation stopped at %s.${NC} Fix the above before continuing.\n" "$1"
95
+ exit 1
96
+ }
97
+
98
+ printf "\n"
99
+ printf "${BOLD}========================================${NC}\n"
100
+ printf "${BOLD} OpenEnv Submission Validator${NC}\n"
101
+ printf "${BOLD}========================================${NC}\n"
102
+ log "Repo: $REPO_DIR"
103
+ log "Ping URL: $PING_URL"
104
+ printf "\n"
105
+
106
+ log "${BOLD}Step 1/3: Pinging HF Space${NC} ($PING_URL/reset) ..."
107
+
108
+ CURL_OUTPUT=$(portable_mktemp "validate-curl")
109
+ CLEANUP_FILES+=("$CURL_OUTPUT")
110
+ HTTP_CODE=$(curl -s -o "$CURL_OUTPUT" -w "%{http_code}" -X POST \
111
+ -H "Content-Type: application/json" -d '{}' \
112
+ "$PING_URL/reset" --max-time 30 2>"$CURL_OUTPUT" || printf "000")
113
+
114
+ if [ "$HTTP_CODE" = "200" ]; then
115
+ pass "HF Space is live and responds to /reset"
116
+ elif [ "$HTTP_CODE" = "000" ]; then
117
+ fail "HF Space not reachable (connection failed or timed out)"
118
+ hint "Check your network connection and that the Space is running."
119
+ hint "Try: curl -s -o /dev/null -w '%%{http_code}' -X POST $PING_URL/reset"
120
+ stop_at "Step 1"
121
+ else
122
+ fail "HF Space /reset returned HTTP $HTTP_CODE (expected 200)"
123
+ hint "Make sure your Space is running and the URL is correct."
124
+ hint "Try opening $PING_URL in your browser first."
125
+ stop_at "Step 1"
126
+ fi
127
+
128
+ log "${BOLD}Step 2/3: Running docker build${NC} ..."
129
+
130
+ if ! command -v docker &>/dev/null; then
131
+ fail "docker command not found"
132
+ hint "Install Docker: https://docs.docker.com/get-docker/"
133
+ stop_at "Step 2"
134
+ fi
135
+
136
+ if [ -f "$REPO_DIR/Dockerfile" ]; then
137
+ DOCKER_CONTEXT="$REPO_DIR"
138
+ elif [ -f "$REPO_DIR/server/Dockerfile" ]; then
139
+ DOCKER_CONTEXT="$REPO_DIR/server"
140
+ else
141
+ fail "No Dockerfile found in repo root or server/ directory"
142
+ stop_at "Step 2"
143
+ fi
144
+
145
+ log " Found Dockerfile in $DOCKER_CONTEXT"
146
+
147
+ BUILD_OK=false
148
+ BUILD_OUTPUT=$(run_with_timeout "$DOCKER_BUILD_TIMEOUT" docker build "$DOCKER_CONTEXT" 2>&1) && BUILD_OK=true
149
+
150
+ if [ "$BUILD_OK" = true ]; then
151
+ pass "Docker build succeeded"
152
+ else
153
+ fail "Docker build failed (timeout=${DOCKER_BUILD_TIMEOUT}s)"
154
+ printf "%s\n" "$BUILD_OUTPUT" | tail -20
155
+ stop_at "Step 2"
156
+ fi
157
+
158
+ log "${BOLD}Step 3/3: Running openenv validate${NC} ..."
159
+
160
+ if ! command -v openenv &>/dev/null; then
161
+ fail "openenv command not found"
162
+ hint "Install it: pip install openenv-core"
163
+ stop_at "Step 3"
164
+ fi
165
+
166
+ VALIDATE_OK=false
167
+ VALIDATE_OUTPUT=$(cd "$REPO_DIR" && openenv validate 2>&1) && VALIDATE_OK=true
168
+
169
+ if [ "$VALIDATE_OK" = true ]; then
170
+ pass "openenv validate passed"
171
+ [ -n "$VALIDATE_OUTPUT" ] && log " $VALIDATE_OUTPUT"
172
+ else
173
+ fail "openenv validate failed"
174
+ printf "%s\n" "$VALIDATE_OUTPUT"
175
+ stop_at "Step 3"
176
+ fi
177
+
178
+ printf "\n"
179
+ printf "${BOLD}========================================${NC}\n"
180
+ printf "${GREEN}${BOLD} All 3/3 checks passed!${NC}\n"
181
+ printf "${GREEN}${BOLD} Your submission is ready to submit.${NC}\n"
182
+ printf "${BOLD}========================================${NC}\n"
183
+ printf "\n"
184
+
185
+ exit 0