Spaces:
Runtime error
Runtime error
Deploy: claude-code-for-health
Browse files- .gitattributes +2 -0
- CHANGELOG.md +39 -0
- Dockerfile +81 -0
- README.md +281 -4
- __init__.py +9 -0
- assets/architecture.png +3 -0
- client.py +44 -0
- data/MEDEC/MEDEC-Full-TrainingSet-with-ErrorType.csv +0 -0
- data/MEDEC/MEDEC-MS-TestSet-with-GroundTruth-and-ErrorType.csv +0 -0
- data/MEDEC/MEDEC-MS-ValidationSet-with-GroundTruth-and-ErrorType.csv +0 -0
- data/MedCalcBench/one_shot_data.csv +0 -0
- data/MedCalcBench/test_data.csv +0 -0
- data/MedCalcBench/train_data.csv +3 -0
- data/MedCaseReasoning/extracted_cases.jsonl +0 -0
- data/MedCaseReasoning/extraction_errors.jsonl +2 -0
- data/reference/diagnostic_criteria.json +23 -0
- data/reference/drug_info.json +21 -0
- data/reference/lab_ranges.json +350 -0
- inference.py +202 -0
- models.py +38 -0
- openenv.yaml +24 -0
- pyproject.toml +38 -0
- server/__init__.py +3 -0
- server/app.py +33 -0
- server/claude_code_for_health_environment.py +588 -0
- server/command_parser.py +31 -0
- server/constants.py +102 -0
- server/data_loader.py +101 -0
- server/graders.py +239 -0
- server/requirements.txt +7 -0
- server/task_configs.py +120 -0
- server/ui.py +418 -0
- validate-submission.sh +185 -0
.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:
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+

|
| 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
|
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:'JetBrains Mono',monospace;font-size:18px;'
|
| 90 |
+
'font-weight:700;color:#e2e8f0;letter-spacing:-0.03em;">'
|
| 91 |
+
'\U0001f3e5 Clinical Terminal</span>'
|
| 92 |
+
'<span style="font-family:'JetBrains Mono',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:'JetBrains Mono',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:'JetBrains Mono',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:'JetBrains Mono',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:'JetBrains Mono',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:'JetBrains Mono',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:'JetBrains Mono',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:'JetBrains Mono',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:'JetBrains Mono',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
|