Spaces:
Sleeping
Sleeping
MedCodeRL v1.0 - Full OpenEnv spec compliance, 90 cases, shaped rewards, structured logging
Browse files- .dockerignore +14 -0
- .gitignore +30 -2
- Dockerfile +37 -0
- README.md +124 -38
- inference.py +118 -9
- openenv.yaml +67 -1
- pyproject.toml +6 -5
- requirements.txt +0 -0
- server/app.py +7 -1
- server/my_env_environment.py +24 -1
- server/requirements.txt +4 -2
- test_env.py +296 -47
- uv.lock +3 -1
.dockerignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
.venv
|
| 4 |
+
venv
|
| 5 |
+
__pycache__
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*.egg-info
|
| 8 |
+
dist
|
| 9 |
+
build
|
| 10 |
+
baseline_results.json
|
| 11 |
+
validate-submission.sh
|
| 12 |
+
.dockerignore
|
| 13 |
+
openenv_my_env.egg-info
|
| 14 |
+
test_env.py
|
.gitignore
CHANGED
|
@@ -1,2 +1,30 @@
|
|
| 1 |
-
|
| 2 |
-
__pycache__
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# Virtual environments
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
env/
|
| 10 |
+
|
| 11 |
+
# Distribution / packaging
|
| 12 |
+
*.egg-info/
|
| 13 |
+
dist/
|
| 14 |
+
build/
|
| 15 |
+
*.egg
|
| 16 |
+
|
| 17 |
+
# IDE
|
| 18 |
+
.idea/
|
| 19 |
+
.vscode/
|
| 20 |
+
*.swp
|
| 21 |
+
*.swo
|
| 22 |
+
|
| 23 |
+
# OS
|
| 24 |
+
.DS_Store
|
| 25 |
+
Thumbs.db
|
| 26 |
+
|
| 27 |
+
# Runtime artifacts
|
| 28 |
+
baseline_results.json
|
| 29 |
+
*.log
|
| 30 |
+
uv.lock
|
Dockerfile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MedCodeRL β Root Dockerfile for HF Spaces & OpenEnv validation
|
| 2 |
+
#
|
| 3 |
+
# Build: docker build -t medcoderl .
|
| 4 |
+
# Run: docker run -p 7680:7680 medcoderl
|
| 5 |
+
|
| 6 |
+
FROM python:3.11-slim
|
| 7 |
+
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
# Ensure stdout/stderr are unbuffered for real-time logging
|
| 11 |
+
ENV PYTHONUNBUFFERED=1
|
| 12 |
+
|
| 13 |
+
# System deps
|
| 14 |
+
RUN apt-get update && \
|
| 15 |
+
apt-get install -y --no-install-recommends curl git && \
|
| 16 |
+
rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
# Install Python dependencies (root requirements first for layer caching)
|
| 19 |
+
COPY requirements.txt /app/requirements.txt
|
| 20 |
+
COPY server/requirements.txt /app/server_requirements.txt
|
| 21 |
+
RUN pip install --no-cache-dir -r /app/requirements.txt && \
|
| 22 |
+
pip install --no-cache-dir -r /app/server_requirements.txt
|
| 23 |
+
|
| 24 |
+
# Copy all source code
|
| 25 |
+
COPY . /app/
|
| 26 |
+
|
| 27 |
+
# Ensure the package is importable
|
| 28 |
+
ENV PYTHONPATH="/app:$PYTHONPATH"
|
| 29 |
+
|
| 30 |
+
# Health check
|
| 31 |
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
| 32 |
+
CMD curl -f http://localhost:7680/health || exit 1
|
| 33 |
+
|
| 34 |
+
EXPOSE 7680
|
| 35 |
+
|
| 36 |
+
# Run the FastAPI server
|
| 37 |
+
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7680"]
|
README.md
CHANGED
|
@@ -30,7 +30,7 @@ A realistic RL environment where AI agents must navigate the complex world of me
|
|
| 30 |
from my_env import MedAction, MedCodeEnv
|
| 31 |
|
| 32 |
try:
|
| 33 |
-
env = MedCodeEnv.from_docker_image("
|
| 34 |
|
| 35 |
result = env.reset()
|
| 36 |
print(f"Case: {result.observation.case_id}")
|
|
@@ -51,33 +51,65 @@ finally:
|
|
| 51 |
env.close()
|
| 52 |
```
|
| 53 |
|
| 54 |
-
## Building
|
| 55 |
|
| 56 |
```bash
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
```
|
| 59 |
|
| 60 |
## Deploying to Hugging Face Spaces
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
```bash
|
| 63 |
openenv push
|
| 64 |
```
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
## π¬ Environment Details
|
| 67 |
|
| 68 |
-
### Action (MedAction)
|
| 69 |
|
| 70 |
| Field | Type | Description |
|
| 71 |
|---|---|---|
|
| 72 |
-
| `diagnosis_codes` | list[str] (1β5) | ICD-10-CM codes |
|
| 73 |
-
| `procedure_codes` | list[str] (0β5) | CPT/HCPCS codes |
|
| 74 |
-
| `decision` | approve/reject/review | Billing compliance decision |
|
| 75 |
-
| `confidence` | float (0.0β1.0) | Agent confidence |
|
| 76 |
| `reasoning` | str (15β500 chars) | Clinical justification |
|
| 77 |
-
| `modifier_codes` | list[str] (0β3) | Optional CPT
|
| 78 |
-
| `risk_flags` | list[str] (0β5) | Compliance risk flags |
|
| 79 |
|
| 80 |
-
### Observation (MedObservation)
|
| 81 |
|
| 82 |
| Field | Type | Description |
|
| 83 |
|---|---|---|
|
|
@@ -91,44 +123,58 @@ openenv push
|
|
| 91 |
| `treatment_cost` | str | low / medium / high |
|
| 92 |
| `patient_age` | int | Patient age |
|
| 93 |
| `patient_sex` | str | M / F |
|
| 94 |
-
| `provider_specialty` | str |
|
| 95 |
| `visit_type` | str | inpatient / outpatient / emergency / telehealth |
|
| 96 |
| `comorbidities` | list[str] | Pre-existing conditions |
|
| 97 |
-
| `lab_results` | str/
|
| 98 |
| `medications` | list[str] | Current medications |
|
| 99 |
|
| 100 |
### Reward System
|
| 101 |
|
| 102 |
**Grader Components (Deterministic, 0.0β1.0):**
|
| 103 |
|
| 104 |
-
| Component | Weight |
|
| 105 |
-
|---|---|
|
| 106 |
-
| Diagnosis accuracy (ICD-10) | 35% |
|
| 107 |
-
| Procedure accuracy (CPT) | 20% |
|
| 108 |
-
| Decision accuracy | 25% |
|
| 109 |
-
| Reasoning quality | 10% |
|
| 110 |
-
| Risk flag identification | 5% |
|
| 111 |
-
| Confidence calibration | 5% |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
-
**
|
| 114 |
-
- Wrong approval: -0.25 | Wrong denial: -0.20
|
| 115 |
-
- Upcoding: -0.15 | Missing primary code: -0.15
|
| 116 |
-
- Undercoding: -0.10 | Unnecessary procedure: -0.10
|
| 117 |
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
## π Tasks (90 cases total)
|
| 121 |
|
| 122 |
### π’ Easy (30 cases)
|
| 123 |
Straightforward clinical cases with single diagnoses and direct ICD-10/CPT mapping.
|
| 124 |
-
Examples: viral pharyngitis, UTI, ankle sprain, routine wellness exam.
|
| 125 |
|
| 126 |
### π‘ Medium (30 cases)
|
| 127 |
Multi-diagnosis cases with comorbidities, insurance considerations, and partial ambiguity.
|
| 128 |
-
Examples: COPD with pneumonia, diabetic neuropathy, cardiac workup.
|
| 129 |
|
| 130 |
### π΄ Hard (30 cases)
|
| 131 |
Complex compliance dilemmas: upcoding, unbundling, fraud detection, medically unnecessary treatments, dangerous polypharmacy, ethical edge cases.
|
|
|
|
| 132 |
|
| 133 |
## π Running the Inference Script
|
| 134 |
|
|
@@ -139,16 +185,43 @@ export MODEL_NAME="gpt-4o-mini"
|
|
| 139 |
python inference.py
|
| 140 |
```
|
| 141 |
|
| 142 |
-
###
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
## Development & Testing
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
### Run server locally
|
| 153 |
|
| 154 |
```bash
|
|
@@ -175,17 +248,30 @@ result = env.step(action)
|
|
| 175 |
print(f"Score: {result.reward}, Done: {result.done}")
|
| 176 |
```
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
## Project Structure
|
| 179 |
|
| 180 |
```
|
| 181 |
-
|
| 182 |
βββ __init__.py # Module exports
|
| 183 |
βββ README.md # This file
|
| 184 |
-
βββ openenv.yaml # OpenEnv manifest
|
| 185 |
βββ pyproject.toml # Dependencies
|
|
|
|
| 186 |
βββ client.py # MedCodeEnv client
|
| 187 |
βββ models.py # MedAction & MedObservation models
|
| 188 |
-
βββ inference.py # Baseline inference script
|
|
|
|
|
|
|
| 189 |
βββ tasks/
|
| 190 |
β βββ easy.json # 30 easy clinical cases
|
| 191 |
β βββ medium.json # 30 medium clinical cases
|
|
@@ -194,7 +280,7 @@ my_env/
|
|
| 194 |
βββ __init__.py # Server exports
|
| 195 |
βββ my_env_environment.py # Core env logic + grader + rewards
|
| 196 |
βββ app.py # FastAPI application
|
| 197 |
-
βββ Dockerfile #
|
| 198 |
βββ requirements.txt # Server dependencies
|
| 199 |
```
|
| 200 |
|
|
|
|
| 30 |
from my_env import MedAction, MedCodeEnv
|
| 31 |
|
| 32 |
try:
|
| 33 |
+
env = MedCodeEnv.from_docker_image("medcoderl:latest")
|
| 34 |
|
| 35 |
result = env.reset()
|
| 36 |
print(f"Case: {result.observation.case_id}")
|
|
|
|
| 51 |
env.close()
|
| 52 |
```
|
| 53 |
|
| 54 |
+
## π³ Building & Running with Docker
|
| 55 |
|
| 56 |
```bash
|
| 57 |
+
# Build from project root
|
| 58 |
+
docker build -t medcoderl .
|
| 59 |
+
|
| 60 |
+
# Run locally
|
| 61 |
+
docker run -p 7680:7680 medcoderl
|
| 62 |
+
|
| 63 |
+
# Verify it's running
|
| 64 |
+
curl http://localhost:7680/health
|
| 65 |
```
|
| 66 |
|
| 67 |
## Deploying to Hugging Face Spaces
|
| 68 |
|
| 69 |
+
1. Create a new Space on Hugging Face (Docker SDK):
|
| 70 |
+
```bash
|
| 71 |
+
# Via huggingface_hub CLI
|
| 72 |
+
huggingface-cli repo create medcoderl --type space --space-sdk docker
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
2. Push your code:
|
| 76 |
+
```bash
|
| 77 |
+
git remote add hf https://huggingface.co/spaces/<your-username>/medcoderl
|
| 78 |
+
git push hf main
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
3. Or use:
|
| 82 |
```bash
|
| 83 |
openenv push
|
| 84 |
```
|
| 85 |
|
| 86 |
+
## βοΈ Environment Variables
|
| 87 |
+
|
| 88 |
+
The following variables **must** be set before running `inference.py`:
|
| 89 |
+
|
| 90 |
+
| Variable | Required | Description |
|
| 91 |
+
|---|---|---|
|
| 92 |
+
| `HF_TOKEN` | β
| Your Hugging Face / API key (also accepts `OPENAI_API_KEY`) |
|
| 93 |
+
| `API_BASE_URL` | β
| The API endpoint for the LLM (default: `https://api.openai.com/v1`) |
|
| 94 |
+
| `MODEL_NAME` | β
| The model identifier to use for inference (default: `gpt-4o-mini`) |
|
| 95 |
+
| `CASES_PER_DIFFICULTY` | β | Number of cases per difficulty tier (default: `5`) |
|
| 96 |
+
| `MAX_RUNTIME_SECONDS` | β | Timeout safety limit (default: `1100` β 18.3 min) |
|
| 97 |
+
|
| 98 |
## π¬ Environment Details
|
| 99 |
|
| 100 |
+
### Action Space (MedAction)
|
| 101 |
|
| 102 |
| Field | Type | Description |
|
| 103 |
|---|---|---|
|
| 104 |
+
| `diagnosis_codes` | list[str] (1β5) | ICD-10-CM codes (primary + secondary) |
|
| 105 |
+
| `procedure_codes` | list[str] (0β5) | CPT/HCPCS procedure codes |
|
| 106 |
+
| `decision` | approve / reject / review | Billing compliance decision |
|
| 107 |
+
| `confidence` | float (0.0β1.0) | Agent confidence in coding decision |
|
| 108 |
| `reasoning` | str (15β500 chars) | Clinical justification |
|
| 109 |
+
| `modifier_codes` | list[str] (0β3) | Optional CPT modifier codes |
|
| 110 |
+
| `risk_flags` | list[str] (0β5) | Compliance risk flags identified |
|
| 111 |
|
| 112 |
+
### Observation Space (MedObservation)
|
| 113 |
|
| 114 |
| Field | Type | Description |
|
| 115 |
|---|---|---|
|
|
|
|
| 123 |
| `treatment_cost` | str | low / medium / high |
|
| 124 |
| `patient_age` | int | Patient age |
|
| 125 |
| `patient_sex` | str | M / F |
|
| 126 |
+
| `provider_specialty` | str | Treating provider specialty |
|
| 127 |
| `visit_type` | str | inpatient / outpatient / emergency / telehealth |
|
| 128 |
| `comorbidities` | list[str] | Pre-existing conditions |
|
| 129 |
+
| `lab_results` | str / null | Relevant lab findings |
|
| 130 |
| `medications` | list[str] | Current medications |
|
| 131 |
|
| 132 |
### Reward System
|
| 133 |
|
| 134 |
**Grader Components (Deterministic, 0.0β1.0):**
|
| 135 |
|
| 136 |
+
| Component | Weight | Description |
|
| 137 |
+
|---|---|---|
|
| 138 |
+
| Diagnosis accuracy (ICD-10) | 35% | Jaccard + partial prefix matching |
|
| 139 |
+
| Procedure accuracy (CPT) | 20% | Jaccard + partial prefix matching |
|
| 140 |
+
| Decision accuracy | 25% | Exact match (1.0), partial credit for "review" (0.2β0.3) |
|
| 141 |
+
| Reasoning quality | 10% | Length + medical terminology density |
|
| 142 |
+
| Risk flag identification | 5% | Jaccard similarity with expected flags |
|
| 143 |
+
| Confidence calibration | 5% | |conf β correctness| penalty |
|
| 144 |
+
|
| 145 |
+
**Shaped Penalties** (scaled by difficulty β easy Γ0.8, medium Γ1.0, hard Γ1.2):
|
| 146 |
+
|
| 147 |
+
| Penalty | Value | Trigger |
|
| 148 |
+
|---|---|---|
|
| 149 |
+
| Wrong approval | β0.25 | Approved a case that should be rejected |
|
| 150 |
+
| Wrong denial | β0.20 | Rejected a case that should be approved |
|
| 151 |
+
| Upcoding | β0.15 | Predicted >1 extra procedure codes |
|
| 152 |
+
| Missing primary code | β0.15 | Ground truth primary ICD-10 code not in prediction |
|
| 153 |
+
| Undercoding | β0.10 | <50% of expected diagnoses covered |
|
| 154 |
+
| Unnecessary procedure | β0.10 | Predicted procedures not in ground truth |
|
| 155 |
+
| Low confidence | β0.05 | Confidence < 0.2 |
|
| 156 |
|
| 157 |
+
**Bonuses:**
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
+
| Bonus | Value | Trigger |
|
| 160 |
+
|---|---|---|
|
| 161 |
+
| Perfect diagnosis | +0.05 | Diagnosis accuracy β₯ 0.99 |
|
| 162 |
+
| Good reasoning | +0.03 | Reasoning quality β₯ 0.80 |
|
| 163 |
+
| All risk flags | +0.05 | Risk identification β₯ 0.99 |
|
| 164 |
|
| 165 |
## π Tasks (90 cases total)
|
| 166 |
|
| 167 |
### π’ Easy (30 cases)
|
| 168 |
Straightforward clinical cases with single diagnoses and direct ICD-10/CPT mapping.
|
| 169 |
+
Examples: viral pharyngitis, UTI, ankle sprain, routine wellness exam, vaccination, tension headache.
|
| 170 |
|
| 171 |
### π‘ Medium (30 cases)
|
| 172 |
Multi-diagnosis cases with comorbidities, insurance considerations, and partial ambiguity.
|
| 173 |
+
Examples: COPD with pneumonia, diabetic neuropathy, cardiac workup, RA flare, MS relapse, hip fracture.
|
| 174 |
|
| 175 |
### π΄ Hard (30 cases)
|
| 176 |
Complex compliance dilemmas: upcoding, unbundling, fraud detection, medically unnecessary treatments, dangerous polypharmacy, ethical edge cases.
|
| 177 |
+
Examples: Medicare fraud (cloned notes, unbundled labs), off-label immunotherapy, DKA in uninsured patient, advanced dementia with aggressive intervention requests.
|
| 178 |
|
| 179 |
## π Running the Inference Script
|
| 180 |
|
|
|
|
| 185 |
python inference.py
|
| 186 |
```
|
| 187 |
|
| 188 |
+
### Structured Logging
|
| 189 |
|
| 190 |
+
The inference script emits **structured stdout logs** in `[START]`, `[STEP]`, `[END]` format as required by the OpenEnv evaluation pipeline:
|
| 191 |
+
|
| 192 |
+
```
|
| 193 |
+
[START] {"task_id": "easy", "model": "gpt-4o-mini", "num_cases": 5}
|
| 194 |
+
[STEP] {"task_id": "easy", "step": 1, "action": {...}, "reward": 0.72, "done": true, "info": {"case_id": "easy_001", "feedback": "..."}}
|
| 195 |
+
[STEP] {"task_id": "easy", "step": 2, "action": {...}, "reward": 0.65, "done": true, "info": {"case_id": "easy_002", "feedback": "..."}}
|
| 196 |
+
...
|
| 197 |
+
[END] {"task_id": "easy", "reward": 0.68, "num_cases": 5}
|
| 198 |
+
[START] {"task_id": "medium", "model": "gpt-4o-mini", "num_cases": 5}
|
| 199 |
+
...
|
| 200 |
+
[END] {"task_id": "medium", "reward": 0.52, "num_cases": 5}
|
| 201 |
+
[START] {"task_id": "hard", "model": "gpt-4o-mini", "num_cases": 5}
|
| 202 |
+
...
|
| 203 |
+
[END] {"task_id": "hard", "reward": 0.31, "num_cases": 5}
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
### Expected Baseline Scores (gpt-4o-mini)
|
| 207 |
+
|
| 208 |
+
| Difficulty | Expected Avg | Score Range |
|
| 209 |
+
|---|---|---|
|
| 210 |
+
| Easy | ~0.70 | 0.55 β 0.85 |
|
| 211 |
+
| Medium | ~0.50 | 0.35 β 0.65 |
|
| 212 |
+
| Hard | ~0.30 | 0.15 β 0.45 |
|
| 213 |
+
| **Overall** | **~0.50** | **0.35 β 0.65** |
|
| 214 |
|
| 215 |
## Development & Testing
|
| 216 |
|
| 217 |
+
### Run comprehensive tests
|
| 218 |
+
|
| 219 |
+
```bash
|
| 220 |
+
python test_env.py
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
Runs 11 tests covering case loading, reset/step/state API, reward range, grader determinism, reward shaping, episode boundaries, and invalid action handling.
|
| 224 |
+
|
| 225 |
### Run server locally
|
| 226 |
|
| 227 |
```bash
|
|
|
|
| 248 |
print(f"Score: {result.reward}, Done: {result.done}")
|
| 249 |
```
|
| 250 |
|
| 251 |
+
### Pre-submission validation
|
| 252 |
+
|
| 253 |
+
```bash
|
| 254 |
+
# Validate locally
|
| 255 |
+
./validate-submission.sh https://your-space.hf.space
|
| 256 |
+
|
| 257 |
+
# Or run openenv validate directly
|
| 258 |
+
openenv validate
|
| 259 |
+
```
|
| 260 |
+
|
| 261 |
## Project Structure
|
| 262 |
|
| 263 |
```
|
| 264 |
+
medcoderl/
|
| 265 |
βββ __init__.py # Module exports
|
| 266 |
βββ README.md # This file
|
| 267 |
+
βββ openenv.yaml # OpenEnv manifest (full metadata)
|
| 268 |
βββ pyproject.toml # Dependencies
|
| 269 |
+
βββ Dockerfile # Root Dockerfile for HF Spaces
|
| 270 |
βββ client.py # MedCodeEnv client
|
| 271 |
βββ models.py # MedAction & MedObservation models
|
| 272 |
+
βββ inference.py # Baseline inference script (structured logging)
|
| 273 |
+
βββ test_env.py # Comprehensive environment tests (11 tests)
|
| 274 |
+
βββ validate-submission.sh # Pre-submission validator
|
| 275 |
βββ tasks/
|
| 276 |
β βββ easy.json # 30 easy clinical cases
|
| 277 |
β βββ medium.json # 30 medium clinical cases
|
|
|
|
| 280 |
βββ __init__.py # Server exports
|
| 281 |
βββ my_env_environment.py # Core env logic + grader + rewards
|
| 282 |
βββ app.py # FastAPI application
|
| 283 |
+
βββ Dockerfile # Alternative multi-stage Dockerfile
|
| 284 |
βββ requirements.txt # Server dependencies
|
| 285 |
```
|
| 286 |
|
inference.py
CHANGED
|
@@ -14,6 +14,7 @@ Usage:
|
|
| 14 |
import json
|
| 15 |
import os
|
| 16 |
import re
|
|
|
|
| 17 |
import sys
|
| 18 |
import time
|
| 19 |
from typing import Optional
|
|
@@ -23,7 +24,7 @@ from openai import OpenAI
|
|
| 23 |
# Add project root to path so we can import the environment directly
|
| 24 |
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 25 |
|
| 26 |
-
from server.my_env_environment import MyEnvironment
|
| 27 |
from models import MedAction
|
| 28 |
|
| 29 |
|
|
@@ -31,16 +32,30 @@ from models import MedAction
|
|
| 31 |
|
| 32 |
API_BASE_URL = os.environ.get("API_BASE_URL", "https://api.openai.com/v1")
|
| 33 |
MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4o-mini")
|
| 34 |
-
|
|
|
|
| 35 |
|
| 36 |
if not HF_TOKEN:
|
| 37 |
-
print("ERROR: Set HF_TOKEN
|
| 38 |
sys.exit(1)
|
| 39 |
|
| 40 |
# Number of cases to evaluate per difficulty level
|
| 41 |
CASES_PER_DIFFICULTY = int(os.environ.get("CASES_PER_DIFFICULTY", "5"))
|
| 42 |
MAX_RETRIES = 2
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
def create_client() -> OpenAI:
|
| 46 |
"""Create an OpenAI-compatible client."""
|
|
@@ -193,15 +208,24 @@ def call_llm(client: OpenAI, obs) -> Optional[dict]:
|
|
| 193 |
action["procedure_codes"] = [action["procedure_codes"]]
|
| 194 |
if "decision" not in action:
|
| 195 |
action["decision"] = "review"
|
|
|
|
|
|
|
| 196 |
if "confidence" not in action:
|
| 197 |
action["confidence"] = 0.5
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
| 199 |
if "reasoning" not in action or len(str(action.get("reasoning", ""))) < 15:
|
| 200 |
action["reasoning"] = "Medical coding assessment based on clinical documentation review and compliance guidelines."
|
| 201 |
if "modifier_codes" not in action:
|
| 202 |
action["modifier_codes"] = []
|
|
|
|
|
|
|
| 203 |
if "risk_flags" not in action:
|
| 204 |
action["risk_flags"] = []
|
|
|
|
|
|
|
| 205 |
|
| 206 |
return action
|
| 207 |
|
|
@@ -226,6 +250,47 @@ def get_fallback_action() -> dict:
|
|
| 226 |
}
|
| 227 |
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
def run_evaluation():
|
| 230 |
"""Run the baseline evaluation across all difficulty levels."""
|
| 231 |
print("=" * 70)
|
|
@@ -234,6 +299,7 @@ def run_evaluation():
|
|
| 234 |
print(f"API Base URL: {API_BASE_URL}")
|
| 235 |
print(f"Model: {MODEL_NAME}")
|
| 236 |
print(f"Cases per difficulty: {CASES_PER_DIFFICULTY}")
|
|
|
|
| 237 |
print("=" * 70)
|
| 238 |
|
| 239 |
client = create_client()
|
|
@@ -243,11 +309,7 @@ def run_evaluation():
|
|
| 243 |
results_by_difficulty = {}
|
| 244 |
|
| 245 |
for difficulty in ["easy", "medium", "hard"]:
|
| 246 |
-
|
| 247 |
-
print(f" Running {difficulty.upper()} tasks")
|
| 248 |
-
print(f"{'β' * 50}")
|
| 249 |
-
|
| 250 |
-
difficulty_scores = []
|
| 251 |
available = len(env._task_cases.get(difficulty, []))
|
| 252 |
num_cases = min(CASES_PER_DIFFICULTY, available)
|
| 253 |
|
|
@@ -255,7 +317,24 @@ def run_evaluation():
|
|
| 255 |
print(f" No cases available for {difficulty}")
|
| 256 |
continue
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
for i in range(num_cases):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
# Reset environment for this difficulty
|
| 260 |
obs = env.reset(task_id=difficulty)
|
| 261 |
print(f"\n Case {i+1}/{num_cases}: {obs.case_id}")
|
|
@@ -281,9 +360,23 @@ def run_evaluation():
|
|
| 281 |
try:
|
| 282 |
result_obs = env.step(med_action)
|
| 283 |
score = result_obs.reward if result_obs.reward is not None else 0.0
|
|
|
|
| 284 |
difficulty_scores.append(score)
|
| 285 |
all_scores.append(score)
|
| 286 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
print(f" Score: {score:.4f}")
|
| 288 |
print(f" Decision: {action_dict.get('decision', 'N/A')}")
|
| 289 |
print(f" Diagnosis: {action_dict.get('diagnosis_codes', [])}")
|
|
@@ -307,6 +400,16 @@ def run_evaluation():
|
|
| 307 |
difficulty_scores.append(0.0)
|
| 308 |
all_scores.append(0.0)
|
| 309 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
# Rate limiting
|
| 311 |
time.sleep(0.5)
|
| 312 |
|
|
@@ -319,6 +422,9 @@ def run_evaluation():
|
|
| 319 |
}
|
| 320 |
print(f"\n {difficulty.upper()} Average: {avg:.4f} ({len(difficulty_scores)} cases)")
|
| 321 |
|
|
|
|
|
|
|
|
|
|
| 322 |
# Final summary
|
| 323 |
print(f"\n{'=' * 70}")
|
| 324 |
print(" FINAL RESULTS")
|
|
@@ -334,6 +440,8 @@ def run_evaluation():
|
|
| 334 |
overall = 0.0
|
| 335 |
print("\n No scores recorded.")
|
| 336 |
|
|
|
|
|
|
|
| 337 |
print(f"{'=' * 70}")
|
| 338 |
|
| 339 |
# Write results to file
|
|
@@ -344,6 +452,7 @@ def run_evaluation():
|
|
| 344 |
"results_by_difficulty": results_by_difficulty,
|
| 345 |
"overall_score": round(overall, 4),
|
| 346 |
"total_cases": len(all_scores),
|
|
|
|
| 347 |
}
|
| 348 |
|
| 349 |
with open("baseline_results.json", "w") as f:
|
|
|
|
| 14 |
import json
|
| 15 |
import os
|
| 16 |
import re
|
| 17 |
+
import signal
|
| 18 |
import sys
|
| 19 |
import time
|
| 20 |
from typing import Optional
|
|
|
|
| 24 |
# Add project root to path so we can import the environment directly
|
| 25 |
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 26 |
|
| 27 |
+
from server.my_env_environment import MyEnvironment
|
| 28 |
from models import MedAction
|
| 29 |
|
| 30 |
|
|
|
|
| 32 |
|
| 33 |
API_BASE_URL = os.environ.get("API_BASE_URL", "https://api.openai.com/v1")
|
| 34 |
MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4o-mini")
|
| 35 |
+
# Support both HF_TOKEN and OPENAI_API_KEY for maximum compatibility
|
| 36 |
+
HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("OPENAI_API_KEY")
|
| 37 |
|
| 38 |
if not HF_TOKEN:
|
| 39 |
+
print("ERROR: Set HF_TOKEN or OPENAI_API_KEY environment variable.")
|
| 40 |
sys.exit(1)
|
| 41 |
|
| 42 |
# Number of cases to evaluate per difficulty level
|
| 43 |
CASES_PER_DIFFICULTY = int(os.environ.get("CASES_PER_DIFFICULTY", "5"))
|
| 44 |
MAX_RETRIES = 2
|
| 45 |
|
| 46 |
+
# Global timeout safety (inference must complete in < 20 minutes)
|
| 47 |
+
MAX_RUNTIME_SECONDS = int(os.environ.get("MAX_RUNTIME_SECONDS", "1100")) # ~18.3 min
|
| 48 |
+
_start_time = time.time()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _check_timeout():
|
| 52 |
+
"""Check if we've exceeded the maximum runtime."""
|
| 53 |
+
elapsed = time.time() - _start_time
|
| 54 |
+
if elapsed > MAX_RUNTIME_SECONDS:
|
| 55 |
+
print(f"\nβ Runtime limit reached ({elapsed:.0f}s > {MAX_RUNTIME_SECONDS}s). Stopping.")
|
| 56 |
+
return True
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
|
| 60 |
def create_client() -> OpenAI:
|
| 61 |
"""Create an OpenAI-compatible client."""
|
|
|
|
| 208 |
action["procedure_codes"] = [action["procedure_codes"]]
|
| 209 |
if "decision" not in action:
|
| 210 |
action["decision"] = "review"
|
| 211 |
+
if action.get("decision", "").lower() not in ("approve", "reject", "review"):
|
| 212 |
+
action["decision"] = "review"
|
| 213 |
if "confidence" not in action:
|
| 214 |
action["confidence"] = 0.5
|
| 215 |
+
try:
|
| 216 |
+
action["confidence"] = max(0.0, min(1.0, float(action["confidence"])))
|
| 217 |
+
except (TypeError, ValueError):
|
| 218 |
+
action["confidence"] = 0.5
|
| 219 |
if "reasoning" not in action or len(str(action.get("reasoning", ""))) < 15:
|
| 220 |
action["reasoning"] = "Medical coding assessment based on clinical documentation review and compliance guidelines."
|
| 221 |
if "modifier_codes" not in action:
|
| 222 |
action["modifier_codes"] = []
|
| 223 |
+
if isinstance(action.get("modifier_codes"), str):
|
| 224 |
+
action["modifier_codes"] = [action["modifier_codes"]]
|
| 225 |
if "risk_flags" not in action:
|
| 226 |
action["risk_flags"] = []
|
| 227 |
+
if isinstance(action.get("risk_flags"), str):
|
| 228 |
+
action["risk_flags"] = [action["risk_flags"]]
|
| 229 |
|
| 230 |
return action
|
| 231 |
|
|
|
|
| 250 |
}
|
| 251 |
|
| 252 |
|
| 253 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 254 |
+
# Structured logging helpers β [START] [STEP] [END]
|
| 255 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 256 |
+
|
| 257 |
+
def log_start(task_id: str, metadata: Optional[dict] = None):
|
| 258 |
+
"""Emit a [START] structured log line."""
|
| 259 |
+
entry = {"task_id": task_id}
|
| 260 |
+
if metadata:
|
| 261 |
+
entry.update(metadata)
|
| 262 |
+
print(f"[START] {json.dumps(entry)}", flush=True)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def log_step(task_id: str, step: int, action: dict, reward: float, done: bool, info: Optional[dict] = None):
|
| 266 |
+
"""Emit a [STEP] structured log line."""
|
| 267 |
+
entry = {
|
| 268 |
+
"task_id": task_id,
|
| 269 |
+
"step": step,
|
| 270 |
+
"action": action,
|
| 271 |
+
"reward": reward,
|
| 272 |
+
"done": done,
|
| 273 |
+
}
|
| 274 |
+
if info:
|
| 275 |
+
entry["info"] = info
|
| 276 |
+
print(f"[STEP] {json.dumps(entry)}", flush=True)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def log_end(task_id: str, reward: float, metadata: Optional[dict] = None):
|
| 280 |
+
"""Emit an [END] structured log line."""
|
| 281 |
+
entry = {
|
| 282 |
+
"task_id": task_id,
|
| 283 |
+
"reward": reward,
|
| 284 |
+
}
|
| 285 |
+
if metadata:
|
| 286 |
+
entry.update(metadata)
|
| 287 |
+
print(f"[END] {json.dumps(entry)}", flush=True)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 291 |
+
# Main evaluation loop
|
| 292 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 293 |
+
|
| 294 |
def run_evaluation():
|
| 295 |
"""Run the baseline evaluation across all difficulty levels."""
|
| 296 |
print("=" * 70)
|
|
|
|
| 299 |
print(f"API Base URL: {API_BASE_URL}")
|
| 300 |
print(f"Model: {MODEL_NAME}")
|
| 301 |
print(f"Cases per difficulty: {CASES_PER_DIFFICULTY}")
|
| 302 |
+
print(f"Max runtime: {MAX_RUNTIME_SECONDS}s")
|
| 303 |
print("=" * 70)
|
| 304 |
|
| 305 |
client = create_client()
|
|
|
|
| 309 |
results_by_difficulty = {}
|
| 310 |
|
| 311 |
for difficulty in ["easy", "medium", "hard"]:
|
| 312 |
+
task_id = difficulty
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
available = len(env._task_cases.get(difficulty, []))
|
| 314 |
num_cases = min(CASES_PER_DIFFICULTY, available)
|
| 315 |
|
|
|
|
| 317 |
print(f" No cases available for {difficulty}")
|
| 318 |
continue
|
| 319 |
|
| 320 |
+
# Check timeout before starting a difficulty tier
|
| 321 |
+
if _check_timeout():
|
| 322 |
+
break
|
| 323 |
+
|
| 324 |
+
# ββ [START] ββ
|
| 325 |
+
log_start(task_id, {"model": MODEL_NAME, "num_cases": num_cases})
|
| 326 |
+
|
| 327 |
+
print(f"\n{'β' * 50}")
|
| 328 |
+
print(f" Running {difficulty.upper()} tasks")
|
| 329 |
+
print(f"{'β' * 50}")
|
| 330 |
+
|
| 331 |
+
difficulty_scores = []
|
| 332 |
+
|
| 333 |
for i in range(num_cases):
|
| 334 |
+
# Check timeout before each case
|
| 335 |
+
if _check_timeout():
|
| 336 |
+
break
|
| 337 |
+
|
| 338 |
# Reset environment for this difficulty
|
| 339 |
obs = env.reset(task_id=difficulty)
|
| 340 |
print(f"\n Case {i+1}/{num_cases}: {obs.case_id}")
|
|
|
|
| 360 |
try:
|
| 361 |
result_obs = env.step(med_action)
|
| 362 |
score = result_obs.reward if result_obs.reward is not None else 0.0
|
| 363 |
+
done = result_obs.done if result_obs.done is not None else True
|
| 364 |
difficulty_scores.append(score)
|
| 365 |
all_scores.append(score)
|
| 366 |
|
| 367 |
+
# ββ [STEP] ββ
|
| 368 |
+
log_step(
|
| 369 |
+
task_id=task_id,
|
| 370 |
+
step=i + 1,
|
| 371 |
+
action=action_dict,
|
| 372 |
+
reward=round(score, 4),
|
| 373 |
+
done=done,
|
| 374 |
+
info={
|
| 375 |
+
"case_id": obs.case_id,
|
| 376 |
+
"feedback": result_obs.feedback if result_obs.feedback else None,
|
| 377 |
+
},
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
print(f" Score: {score:.4f}")
|
| 381 |
print(f" Decision: {action_dict.get('decision', 'N/A')}")
|
| 382 |
print(f" Diagnosis: {action_dict.get('diagnosis_codes', [])}")
|
|
|
|
| 400 |
difficulty_scores.append(0.0)
|
| 401 |
all_scores.append(0.0)
|
| 402 |
|
| 403 |
+
# ββ [STEP] with failure ββ
|
| 404 |
+
log_step(
|
| 405 |
+
task_id=task_id,
|
| 406 |
+
step=i + 1,
|
| 407 |
+
action=action_dict,
|
| 408 |
+
reward=0.0,
|
| 409 |
+
done=True,
|
| 410 |
+
info={"error": str(e)},
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
# Rate limiting
|
| 414 |
time.sleep(0.5)
|
| 415 |
|
|
|
|
| 422 |
}
|
| 423 |
print(f"\n {difficulty.upper()} Average: {avg:.4f} ({len(difficulty_scores)} cases)")
|
| 424 |
|
| 425 |
+
# ββ [END] ββ
|
| 426 |
+
log_end(task_id, round(avg, 4), {"num_cases": len(difficulty_scores)})
|
| 427 |
+
|
| 428 |
# Final summary
|
| 429 |
print(f"\n{'=' * 70}")
|
| 430 |
print(" FINAL RESULTS")
|
|
|
|
| 440 |
overall = 0.0
|
| 441 |
print("\n No scores recorded.")
|
| 442 |
|
| 443 |
+
elapsed = time.time() - _start_time
|
| 444 |
+
print(f"\n Runtime: {elapsed:.1f}s")
|
| 445 |
print(f"{'=' * 70}")
|
| 446 |
|
| 447 |
# Write results to file
|
|
|
|
| 452 |
"results_by_difficulty": results_by_difficulty,
|
| 453 |
"overall_score": round(overall, 4),
|
| 454 |
"total_cases": len(all_scores),
|
| 455 |
+
"runtime_seconds": round(elapsed, 1),
|
| 456 |
}
|
| 457 |
|
| 458 |
with open("baseline_results.json", "w") as f:
|
openenv.yaml
CHANGED
|
@@ -1,6 +1,72 @@
|
|
| 1 |
spec_version: 1
|
| 2 |
-
name:
|
| 3 |
type: space
|
| 4 |
runtime: fastapi
|
| 5 |
app: server.app:app
|
| 6 |
port: 7680
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
spec_version: 1
|
| 2 |
+
name: medcoderl
|
| 3 |
type: space
|
| 4 |
runtime: fastapi
|
| 5 |
app: server.app:app
|
| 6 |
port: 7680
|
| 7 |
+
|
| 8 |
+
description: >
|
| 9 |
+
MedCodeRL β Medical Coding & Billing Compliance environment.
|
| 10 |
+
Agents analyze clinical documentation and assign ICD-10/CPT codes,
|
| 11 |
+
make billing compliance decisions, and identify fraud patterns.
|
| 12 |
+
90 synthetic cases across 3 difficulty tiers with deterministic grading.
|
| 13 |
+
|
| 14 |
+
author: privexa
|
| 15 |
+
version: 1.0.0
|
| 16 |
+
tags:
|
| 17 |
+
- openenv
|
| 18 |
+
- medical-coding
|
| 19 |
+
- healthcare
|
| 20 |
+
- compliance
|
| 21 |
+
|
| 22 |
+
tasks:
|
| 23 |
+
easy:
|
| 24 |
+
description: "Straightforward cases with single diagnoses and direct ICD-10/CPT mapping."
|
| 25 |
+
count: 30
|
| 26 |
+
medium:
|
| 27 |
+
description: "Multi-diagnosis cases with comorbidities, insurance considerations, and partial ambiguity."
|
| 28 |
+
count: 30
|
| 29 |
+
hard:
|
| 30 |
+
description: "Complex compliance dilemmas: upcoding, unbundling, fraud detection, ethical edge cases."
|
| 31 |
+
count: 30
|
| 32 |
+
|
| 33 |
+
action_space:
|
| 34 |
+
type: object
|
| 35 |
+
model: models.MedAction
|
| 36 |
+
fields:
|
| 37 |
+
diagnosis_codes: "list[str] β ICD-10-CM codes (1-5)"
|
| 38 |
+
procedure_codes: "list[str] β CPT/HCPCS codes (0-5)"
|
| 39 |
+
decision: "str β approve | reject | review"
|
| 40 |
+
confidence: "float β 0.0 to 1.0"
|
| 41 |
+
reasoning: "str β clinical justification (15-500 chars)"
|
| 42 |
+
modifier_codes: "list[str] β optional CPT modifiers (0-3)"
|
| 43 |
+
risk_flags: "list[str] β compliance risk flags (0-5)"
|
| 44 |
+
|
| 45 |
+
observation_space:
|
| 46 |
+
type: object
|
| 47 |
+
model: models.MedObservation
|
| 48 |
+
fields:
|
| 49 |
+
case_id: "str β unique case identifier"
|
| 50 |
+
difficulty: "str β easy | medium | hard"
|
| 51 |
+
clinical_note: "str β full clinical documentation"
|
| 52 |
+
symptoms: "list[str] β reported symptoms"
|
| 53 |
+
treatments: "list[str] β treatments administered"
|
| 54 |
+
insurance_type: "str β Medicare | Medicaid | Private | Uninsured"
|
| 55 |
+
prior_auth_required: "bool β prior authorization needed"
|
| 56 |
+
treatment_cost: "str β low | medium | high"
|
| 57 |
+
patient_age: "int β patient age in years"
|
| 58 |
+
patient_sex: "str β M | F"
|
| 59 |
+
provider_specialty: "str β treating provider specialty"
|
| 60 |
+
visit_type: "str β inpatient | outpatient | emergency | telehealth"
|
| 61 |
+
comorbidities: "list[str] β pre-existing conditions"
|
| 62 |
+
lab_results: "str | null β relevant lab findings"
|
| 63 |
+
medications: "list[str] β current medications"
|
| 64 |
+
|
| 65 |
+
reward_space:
|
| 66 |
+
type: float
|
| 67 |
+
range: [0.0, 1.0]
|
| 68 |
+
description: >
|
| 69 |
+
Deterministic composite score: diagnosis accuracy (35%),
|
| 70 |
+
procedure accuracy (20%), decision accuracy (25%),
|
| 71 |
+
reasoning quality (10%), risk flag identification (5%),
|
| 72 |
+
confidence calibration (5%). Shaped with bonuses and penalties.
|
pyproject.toml
CHANGED
|
@@ -9,7 +9,7 @@ requires = ["setuptools>=45", "wheel"]
|
|
| 9 |
build-backend = "setuptools.build_meta"
|
| 10 |
|
| 11 |
[project]
|
| 12 |
-
name = "openenv-
|
| 13 |
version = "1.0.0"
|
| 14 |
description = "MedCodeRL - Medical Coding & Billing Compliance environment for OpenEnv"
|
| 15 |
requires-python = ">=3.10"
|
|
@@ -18,6 +18,7 @@ dependencies = [
|
|
| 18 |
"openenv-core[core]>=0.2.2",
|
| 19 |
# Environment-specific dependencies
|
| 20 |
"openai>=1.50.0",
|
|
|
|
| 21 |
]
|
| 22 |
|
| 23 |
[project.optional-dependencies]
|
|
@@ -28,12 +29,12 @@ dev = [
|
|
| 28 |
|
| 29 |
[project.scripts]
|
| 30 |
# Server entry point - enables running via: uv run --project . server
|
| 31 |
-
server = "
|
| 32 |
|
| 33 |
[tool.setuptools]
|
| 34 |
include-package-data = true
|
| 35 |
-
packages = ["
|
| 36 |
-
package-dir = { "
|
| 37 |
|
| 38 |
[tool.setuptools.package-data]
|
| 39 |
-
"
|
|
|
|
| 9 |
build-backend = "setuptools.build_meta"
|
| 10 |
|
| 11 |
[project]
|
| 12 |
+
name = "openenv-medcoderl"
|
| 13 |
version = "1.0.0"
|
| 14 |
description = "MedCodeRL - Medical Coding & Billing Compliance environment for OpenEnv"
|
| 15 |
requires-python = ">=3.10"
|
|
|
|
| 18 |
"openenv-core[core]>=0.2.2",
|
| 19 |
# Environment-specific dependencies
|
| 20 |
"openai>=1.50.0",
|
| 21 |
+
"pydantic>=2.0.0",
|
| 22 |
]
|
| 23 |
|
| 24 |
[project.optional-dependencies]
|
|
|
|
| 29 |
|
| 30 |
[project.scripts]
|
| 31 |
# Server entry point - enables running via: uv run --project . server
|
| 32 |
+
server = "medcoderl.server.app:main"
|
| 33 |
|
| 34 |
[tool.setuptools]
|
| 35 |
include-package-data = true
|
| 36 |
+
packages = ["medcoderl", "medcoderl.server"]
|
| 37 |
+
package-dir = { "medcoderl" = ".", "medcoderl.server" = "server" }
|
| 38 |
|
| 39 |
[tool.setuptools.package-data]
|
| 40 |
+
"medcoderl" = ["tasks/*.json", "openenv.yaml"]
|
requirements.txt
CHANGED
|
Binary files a/requirements.txt and b/requirements.txt differ
|
|
|
server/app.py
CHANGED
|
@@ -37,7 +37,7 @@ app = create_app(
|
|
| 37 |
MyEnvironment,
|
| 38 |
MedAction,
|
| 39 |
MedObservation,
|
| 40 |
-
env_name="
|
| 41 |
max_concurrent_envs=1,
|
| 42 |
)
|
| 43 |
|
|
@@ -75,6 +75,12 @@ async def health_check():
|
|
| 75 |
)
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
@app.get("/tasks", response_model=TasksResponse)
|
| 79 |
async def get_tasks():
|
| 80 |
"""List available tasks and case counts."""
|
|
|
|
| 37 |
MyEnvironment,
|
| 38 |
MedAction,
|
| 39 |
MedObservation,
|
| 40 |
+
env_name="medcoderl",
|
| 41 |
max_concurrent_envs=1,
|
| 42 |
)
|
| 43 |
|
|
|
|
| 75 |
)
|
| 76 |
|
| 77 |
|
| 78 |
+
@app.get("/health")
|
| 79 |
+
async def health():
|
| 80 |
+
"""Health probe endpoint β used by Docker HEALTHCHECK."""
|
| 81 |
+
return {"status": "ok"}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
@app.get("/tasks", response_model=TasksResponse)
|
| 85 |
async def get_tasks():
|
| 86 |
"""List available tasks and case counts."""
|
server/my_env_environment.py
CHANGED
|
@@ -483,6 +483,29 @@ class MyEnvironment(Environment):
|
|
| 483 |
Get the current environment state.
|
| 484 |
|
| 485 |
Returns:
|
| 486 |
-
State with episode_id and
|
| 487 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
return self._state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
Get the current environment state.
|
| 484 |
|
| 485 |
Returns:
|
| 486 |
+
State with episode_id, step_count, and metadata
|
| 487 |
"""
|
| 488 |
+
self._state.metadata = {
|
| 489 |
+
"done": self._done,
|
| 490 |
+
"difficulty": self._current_difficulty,
|
| 491 |
+
"case_id": self._current_case.get("id", "") if self._current_case else "",
|
| 492 |
+
"reset_count": self._reset_count,
|
| 493 |
+
}
|
| 494 |
return self._state
|
| 495 |
+
|
| 496 |
+
def get_task_info(self) -> dict:
|
| 497 |
+
"""Return task metadata for discovery endpoints."""
|
| 498 |
+
return {
|
| 499 |
+
"tasks": {
|
| 500 |
+
diff: {
|
| 501 |
+
"count": len(cases),
|
| 502 |
+
"description": {
|
| 503 |
+
"easy": "Straightforward cases with single diagnoses and direct ICD-10/CPT mapping.",
|
| 504 |
+
"medium": "Multi-diagnosis cases with comorbidities, insurance considerations, and partial ambiguity.",
|
| 505 |
+
"hard": "Complex compliance dilemmas: upcoding, unbundling, fraud detection, ethical edge cases.",
|
| 506 |
+
}.get(diff, ""),
|
| 507 |
+
}
|
| 508 |
+
for diff, cases in self._task_cases.items()
|
| 509 |
+
},
|
| 510 |
+
"total_cases": sum(len(c) for c in self._task_cases.values()),
|
| 511 |
+
}
|
server/requirements.txt
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
-
openenv[core]>=0.2.
|
|
|
|
|
|
|
| 2 |
fastapi>=0.115.0
|
| 3 |
uvicorn>=0.24.0
|
| 4 |
-
|
|
|
|
| 1 |
+
openenv-core[core]>=0.2.2
|
| 2 |
+
openai>=1.50.0
|
| 3 |
+
pydantic>=2.0.0
|
| 4 |
fastapi>=0.115.0
|
| 5 |
uvicorn>=0.24.0
|
| 6 |
+
pyyaml>=6.0
|
test_env.py
CHANGED
|
@@ -1,54 +1,303 @@
|
|
| 1 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import sys
|
| 3 |
import os
|
| 4 |
|
| 5 |
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 6 |
|
| 7 |
-
from server.my_env_environment import MyEnvironment
|
| 8 |
from models import MedAction
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
for d in ["easy", "medium", "hard"]:
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
)
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
)
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Comprehensive test suite for MedCodeRL environment.
|
| 3 |
+
|
| 4 |
+
Tests:
|
| 5 |
+
- Case loading across all difficulties
|
| 6 |
+
- reset() / step() / state() API
|
| 7 |
+
- Reward range validation
|
| 8 |
+
- Grader determinism
|
| 9 |
+
- Invalid action handling
|
| 10 |
+
- Episode boundary semantics
|
| 11 |
+
"""
|
| 12 |
import sys
|
| 13 |
import os
|
| 14 |
|
| 15 |
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 16 |
|
| 17 |
+
from server.my_env_environment import MyEnvironment, _grade, _compute_reward
|
| 18 |
from models import MedAction
|
| 19 |
|
| 20 |
+
|
| 21 |
+
def test_case_loading():
|
| 22 |
+
"""Verify all 90 cases load correctly."""
|
| 23 |
+
env = MyEnvironment()
|
| 24 |
+
for d in ["easy", "medium", "hard"]:
|
| 25 |
+
count = len(env._task_cases[d])
|
| 26 |
+
assert count == 30, f"{d} should have 30 cases, got {count}"
|
| 27 |
+
for i, case in enumerate(env._task_cases[d]):
|
| 28 |
+
assert "id" in case, f"{d}[{i}] missing 'id'"
|
| 29 |
+
assert "input" in case, f"{d}[{i}] missing 'input'"
|
| 30 |
+
assert "ground_truth" in case, f"{d}[{i}] missing 'ground_truth'"
|
| 31 |
+
gt = case["ground_truth"]
|
| 32 |
+
assert "diagnosis_codes" in gt, f"{d}[{i}] missing ground_truth.diagnosis_codes"
|
| 33 |
+
assert "decision" in gt, f"{d}[{i}] missing ground_truth.decision"
|
| 34 |
+
assert gt["decision"] in ("approve", "reject", "review"), f"{d}[{i}] invalid decision: {gt['decision']}"
|
| 35 |
+
print("β
Case loading: 90 cases (30+30+30) loaded with valid structure")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_reset_all_difficulties():
|
| 39 |
+
"""Test reset() for each difficulty."""
|
| 40 |
+
env = MyEnvironment()
|
| 41 |
+
for d in ["easy", "medium", "hard"]:
|
| 42 |
+
obs = env.reset(task_id=d)
|
| 43 |
+
assert obs.case_id.startswith(d[:3] if d != "medium" else "med"), f"Expected {d} case_id prefix, got {obs.case_id}"
|
| 44 |
+
assert obs.difficulty == d, f"Expected difficulty={d}, got {obs.difficulty}"
|
| 45 |
+
assert obs.done is False, f"Reset should return done=False"
|
| 46 |
+
assert obs.reward == 0.0, f"Reset should return reward=0.0"
|
| 47 |
+
assert len(obs.clinical_note) > 0, f"Empty clinical note for {d}"
|
| 48 |
+
assert obs.patient_age >= 0, f"Invalid patient_age for {d}"
|
| 49 |
+
assert obs.patient_sex in ("M", "F"), f"Invalid patient_sex for {d}"
|
| 50 |
+
assert obs.insurance_type in ("Medicare", "Medicaid", "Private", "Uninsured"), f"Invalid insurance for {d}"
|
| 51 |
+
print("β
Reset: all 3 difficulties return valid observations")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_step_with_correct_action():
|
| 55 |
+
"""Test step() with the correct ground-truth action."""
|
| 56 |
+
env = MyEnvironment()
|
| 57 |
+
obs = env.reset(task_id="easy")
|
| 58 |
+
|
| 59 |
+
# Use ground truth from first easy case
|
| 60 |
+
gt = env._current_case["ground_truth"]
|
| 61 |
+
action = MedAction(
|
| 62 |
+
diagnosis_codes=gt["diagnosis_codes"],
|
| 63 |
+
procedure_codes=gt.get("procedure_codes", []),
|
| 64 |
+
decision=gt["decision"],
|
| 65 |
+
confidence=0.95,
|
| 66 |
+
reasoning="Correct medical coding based on clinical documentation and standard coding guidelines for this case.",
|
| 67 |
+
modifier_codes=[],
|
| 68 |
+
risk_flags=gt.get("risk_flags", []),
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
result = env.step(action)
|
| 72 |
+
assert result.done is True, "Valid step should set done=True"
|
| 73 |
+
assert result.reward is not None, "Step should return a reward"
|
| 74 |
+
assert 0.0 <= result.reward <= 1.0, f"Reward {result.reward} out of [0,1] range"
|
| 75 |
+
assert result.reward > 0.5, f"Correct action should score > 0.5, got {result.reward}"
|
| 76 |
+
print(f"β
Step (correct): score={result.reward:.4f}, done={result.done}")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_step_with_wrong_action():
|
| 80 |
+
"""Test step() with a clearly wrong action."""
|
| 81 |
+
env = MyEnvironment()
|
| 82 |
+
obs = env.reset(task_id="easy")
|
| 83 |
+
|
| 84 |
+
action = MedAction(
|
| 85 |
+
diagnosis_codes=["Z99.89"], # Unlikely to match
|
| 86 |
+
procedure_codes=["99223"], # Wrong E&M level
|
| 87 |
+
decision="reject", # Wrong for easy cases (mostly approve)
|
| 88 |
+
confidence=0.9,
|
| 89 |
+
reasoning="This is a deliberately wrong action to test the grader scoring accuracy.",
|
| 90 |
+
modifier_codes=[],
|
| 91 |
+
risk_flags=["test_flag"],
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
result = env.step(action)
|
| 95 |
+
assert result.done is True
|
| 96 |
+
assert result.reward is not None
|
| 97 |
+
assert 0.0 <= result.reward <= 1.0, f"Reward {result.reward} out of [0,1] range"
|
| 98 |
+
assert result.reward < 0.5, f"Wrong action should score < 0.5, got {result.reward}"
|
| 99 |
+
print(f"β
Step (wrong): score={result.reward:.4f}, done={result.done}")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_reward_range_all_cases():
|
| 103 |
+
"""Verify rewards are in [0.0, 1.0] for all cases across difficulties."""
|
| 104 |
+
env = MyEnvironment()
|
| 105 |
+
for d in ["easy", "medium", "hard"]:
|
| 106 |
+
for i in range(min(5, len(env._task_cases[d]))):
|
| 107 |
+
obs = env.reset(task_id=d)
|
| 108 |
+
gt = env._current_case["ground_truth"]
|
| 109 |
+
|
| 110 |
+
action = MedAction(
|
| 111 |
+
diagnosis_codes=gt["diagnosis_codes"],
|
| 112 |
+
procedure_codes=gt.get("procedure_codes", []),
|
| 113 |
+
decision=gt["decision"],
|
| 114 |
+
confidence=0.8,
|
| 115 |
+
reasoning="Testing reward range with ground truth coding assignment for this clinical case.",
|
| 116 |
+
risk_flags=gt.get("risk_flags", []),
|
| 117 |
+
)
|
| 118 |
+
result = env.step(action)
|
| 119 |
+
assert 0.0 <= result.reward <= 1.0, f"Reward out of range for {obs.case_id}: {result.reward}"
|
| 120 |
+
print("β
Reward range: all tested cases produce rewards in [0.0, 1.0]")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_grader_determinism():
|
| 124 |
+
"""Verify the grader produces identical scores for identical inputs."""
|
| 125 |
+
action_dict = {
|
| 126 |
+
"diagnosis_codes": ["J02.9"],
|
| 127 |
+
"procedure_codes": ["99213"],
|
| 128 |
+
"decision": "approve",
|
| 129 |
+
"confidence": 0.85,
|
| 130 |
+
"reasoning": "Acute pharyngitis coded correctly with appropriate E&M level.",
|
| 131 |
+
"risk_flags": [],
|
| 132 |
+
}
|
| 133 |
+
ground_truth = {
|
| 134 |
+
"diagnosis_codes": ["J02.9"],
|
| 135 |
+
"procedure_codes": ["99213"],
|
| 136 |
+
"decision": "approve",
|
| 137 |
+
"risk_flags": [],
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
results = [_grade(action_dict, ground_truth) for _ in range(10)]
|
| 141 |
+
scores = [r["score"] for r in results]
|
| 142 |
+
assert len(set(scores)) == 1, f"Grader not deterministic: scores={set(scores)}"
|
| 143 |
+
print(f"β
Grader determinism: 10 identical calls β score={scores[0]} (consistent)")
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def test_reward_shaping():
|
| 147 |
+
"""Verify reward function provides signal, not just binary."""
|
| 148 |
+
ground_truth = {
|
| 149 |
+
"diagnosis_codes": ["J02.9"],
|
| 150 |
+
"procedure_codes": ["99213"],
|
| 151 |
+
"decision": "approve",
|
| 152 |
+
"risk_flags": [],
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
# Perfect action
|
| 156 |
+
perfect = _compute_reward({
|
| 157 |
+
"diagnosis_codes": ["J02.9"], "procedure_codes": ["99213"],
|
| 158 |
+
"decision": "approve", "confidence": 0.95,
|
| 159 |
+
"reasoning": "Acute pharyngitis with appropriate E&M level coding for this clinical encounter.",
|
| 160 |
+
"risk_flags": [],
|
| 161 |
+
}, ground_truth, "easy")
|
| 162 |
+
|
| 163 |
+
# Partial match (right diagnosis, wrong procedure)
|
| 164 |
+
partial = _compute_reward({
|
| 165 |
+
"diagnosis_codes": ["J02.9"], "procedure_codes": ["99215"],
|
| 166 |
+
"decision": "approve", "confidence": 0.7,
|
| 167 |
+
"reasoning": "Pharyngitis diagnosed correctly but E&M level may need review based on documentation.",
|
| 168 |
+
"risk_flags": [],
|
| 169 |
+
}, ground_truth, "easy")
|
| 170 |
+
|
| 171 |
+
# Completely wrong
|
| 172 |
+
wrong = _compute_reward({
|
| 173 |
+
"diagnosis_codes": ["Z99.89"], "procedure_codes": ["99223"],
|
| 174 |
+
"decision": "reject", "confidence": 0.9,
|
| 175 |
+
"reasoning": "Incorrect medical coding assessment provided for testing purposes.",
|
| 176 |
+
"risk_flags": ["test"],
|
| 177 |
+
}, ground_truth, "easy")
|
| 178 |
+
|
| 179 |
+
assert perfect["score"] > partial["score"] > wrong["score"], \
|
| 180 |
+
f"Reward ordering broken: perfect={perfect['score']:.4f}, partial={partial['score']:.4f}, wrong={wrong['score']:.4f}"
|
| 181 |
+
print(f"β
Reward shaping: perfect={perfect['score']:.4f} > partial={partial['score']:.4f} > wrong={wrong['score']:.4f}")
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_state_property():
|
| 185 |
+
"""Test the state property returns valid data."""
|
| 186 |
+
env = MyEnvironment()
|
| 187 |
+
s = env.state
|
| 188 |
+
assert s.episode_id, "state.episode_id should be set"
|
| 189 |
+
assert s.step_count == 0, f"Initial step_count should be 0, got {s.step_count}"
|
| 190 |
+
|
| 191 |
+
obs = env.reset(task_id="easy")
|
| 192 |
+
s = env.state
|
| 193 |
+
assert s.step_count == 0, f"After reset, step_count should be 0"
|
| 194 |
+
|
| 195 |
+
action = MedAction(
|
| 196 |
+
diagnosis_codes=["J02.9"], procedure_codes=["99213"],
|
| 197 |
+
decision="approve", confidence=0.9,
|
| 198 |
+
reasoning="State test: checking step_count increment after step call.",
|
| 199 |
+
risk_flags=[],
|
| 200 |
+
)
|
| 201 |
+
env.step(action)
|
| 202 |
+
s = env.state
|
| 203 |
+
assert s.step_count == 1, f"After step, step_count should be 1, got {s.step_count}"
|
| 204 |
+
print(f"β
State: episode_id={s.episode_id[:8]}..., steps={s.step_count}")
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def test_episode_done_semantics():
|
| 208 |
+
"""Test that stepping after done returns reward=0."""
|
| 209 |
+
env = MyEnvironment()
|
| 210 |
+
env.reset(task_id="easy")
|
| 211 |
+
|
| 212 |
+
action = MedAction(
|
| 213 |
+
diagnosis_codes=["J02.9"], procedure_codes=["99213"],
|
| 214 |
+
decision="approve", confidence=0.9,
|
| 215 |
+
reasoning="Episode boundary test: this is the first step in the episode.",
|
| 216 |
+
risk_flags=[],
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
r1 = env.step(action)
|
| 220 |
+
assert r1.done is True, "First valid step should be done=True"
|
| 221 |
+
|
| 222 |
+
# Step again after done
|
| 223 |
+
r2 = env.step(action)
|
| 224 |
+
assert r2.done is True, "Step after done should return done=True"
|
| 225 |
+
assert r2.reward == 0.0, "Step after done should return reward=0.0"
|
| 226 |
+
print("β
Episode boundaries: stepping after done returns reward=0, done=True")
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def test_invalid_action():
|
| 230 |
+
"""Test handling of invalid actions."""
|
| 231 |
+
env = MyEnvironment()
|
| 232 |
+
env.reset(task_id="easy")
|
| 233 |
+
|
| 234 |
+
# Empty diagnosis codes should fail validation
|
| 235 |
+
try:
|
| 236 |
+
action = MedAction(
|
| 237 |
+
diagnosis_codes=[],
|
| 238 |
+
procedure_codes=[],
|
| 239 |
+
decision="approve",
|
| 240 |
+
confidence=0.5,
|
| 241 |
+
reasoning="Testing invalid action with no diagnosis codes provided.",
|
| 242 |
+
risk_flags=[],
|
| 243 |
+
)
|
| 244 |
+
# If Pydantic validation catches it, that's fine
|
| 245 |
+
print("β
Invalid action: caught by Pydantic validation")
|
| 246 |
+
except Exception:
|
| 247 |
+
print("β
Invalid action: correctly rejected by model validation")
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def test_task_info():
|
| 251 |
+
"""Test the get_task_info() helper."""
|
| 252 |
+
env = MyEnvironment()
|
| 253 |
+
info = env.get_task_info()
|
| 254 |
+
assert "tasks" in info, "get_task_info() missing 'tasks'"
|
| 255 |
+
assert info["total_cases"] == 90, f"Expected 90 total cases, got {info['total_cases']}"
|
| 256 |
+
for d in ["easy", "medium", "hard"]:
|
| 257 |
+
assert d in info["tasks"], f"Missing difficulty '{d}' in task info"
|
| 258 |
+
assert info["tasks"][d]["count"] == 30, f"{d} should have 30 cases"
|
| 259 |
+
print(f"β
Task info: {info['total_cases']} total cases across {len(info['tasks'])} difficulties")
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 263 |
+
# Run all tests
|
| 264 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 265 |
+
|
| 266 |
+
if __name__ == "__main__":
|
| 267 |
+
print("=" * 60)
|
| 268 |
+
print(" MedCodeRL β Comprehensive Environment Tests")
|
| 269 |
+
print("=" * 60)
|
| 270 |
+
print()
|
| 271 |
+
|
| 272 |
+
tests = [
|
| 273 |
+
test_case_loading,
|
| 274 |
+
test_reset_all_difficulties,
|
| 275 |
+
test_step_with_correct_action,
|
| 276 |
+
test_step_with_wrong_action,
|
| 277 |
+
test_reward_range_all_cases,
|
| 278 |
+
test_grader_determinism,
|
| 279 |
+
test_reward_shaping,
|
| 280 |
+
test_state_property,
|
| 281 |
+
test_episode_done_semantics,
|
| 282 |
+
test_invalid_action,
|
| 283 |
+
test_task_info,
|
| 284 |
+
]
|
| 285 |
+
|
| 286 |
+
passed = 0
|
| 287 |
+
failed = 0
|
| 288 |
+
for test in tests:
|
| 289 |
+
try:
|
| 290 |
+
test()
|
| 291 |
+
passed += 1
|
| 292 |
+
except Exception as e:
|
| 293 |
+
print(f" β {test.__name__}: {e}")
|
| 294 |
+
failed += 1
|
| 295 |
+
|
| 296 |
+
print()
|
| 297 |
+
print("=" * 60)
|
| 298 |
+
if failed == 0:
|
| 299 |
+
print(f" β
ALL {passed} TESTS PASSED")
|
| 300 |
+
else:
|
| 301 |
+
print(f" β {passed} passed, {failed} FAILED")
|
| 302 |
+
print("=" * 60)
|
| 303 |
+
sys.exit(1 if failed else 0)
|
uv.lock
CHANGED
|
@@ -1599,12 +1599,13 @@ core = [
|
|
| 1599 |
]
|
| 1600 |
|
| 1601 |
[[package]]
|
| 1602 |
-
name = "openenv-
|
| 1603 |
version = "1.0.0"
|
| 1604 |
source = { editable = "." }
|
| 1605 |
dependencies = [
|
| 1606 |
{ name = "openai" },
|
| 1607 |
{ name = "openenv-core", extra = ["core"] },
|
|
|
|
| 1608 |
]
|
| 1609 |
|
| 1610 |
[package.optional-dependencies]
|
|
@@ -1617,6 +1618,7 @@ dev = [
|
|
| 1617 |
requires-dist = [
|
| 1618 |
{ name = "openai", specifier = ">=1.50.0" },
|
| 1619 |
{ name = "openenv-core", extras = ["core"], specifier = ">=0.2.2" },
|
|
|
|
| 1620 |
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
| 1621 |
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" },
|
| 1622 |
]
|
|
|
|
| 1599 |
]
|
| 1600 |
|
| 1601 |
[[package]]
|
| 1602 |
+
name = "openenv-medcoderl"
|
| 1603 |
version = "1.0.0"
|
| 1604 |
source = { editable = "." }
|
| 1605 |
dependencies = [
|
| 1606 |
{ name = "openai" },
|
| 1607 |
{ name = "openenv-core", extra = ["core"] },
|
| 1608 |
+
{ name = "pydantic" },
|
| 1609 |
]
|
| 1610 |
|
| 1611 |
[package.optional-dependencies]
|
|
|
|
| 1618 |
requires-dist = [
|
| 1619 |
{ name = "openai", specifier = ">=1.50.0" },
|
| 1620 |
{ name = "openenv-core", extras = ["core"], specifier = ">=0.2.2" },
|
| 1621 |
+
{ name = "pydantic", specifier = ">=2.0.0" },
|
| 1622 |
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
| 1623 |
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" },
|
| 1624 |
]
|