Spaces:
Sleeping
Sleeping
Upload 14 files
Browse files- Dockerfile +48 -0
- README.md +124 -2
- __init__.py +25 -0
- client.py +100 -0
- env.py +328 -0
- inference.py +236 -0
- models.py +85 -0
- openenv.yaml +19 -0
- patient_data.json +143 -0
- pyproject.toml +46 -0
- server/__init__.py +11 -0
- server/app.py +79 -0
- server/clinical_trial_env_environment.py +10 -0
- server/requirements.txt +6 -0
Dockerfile
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
|
| 2 |
+
FROM ${BASE_IMAGE} AS builder
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
RUN apt-get update && \
|
| 7 |
+
apt-get install -y --no-install-recommends git && \
|
| 8 |
+
rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
COPY . /app/env
|
| 11 |
+
WORKDIR /app/env
|
| 12 |
+
|
| 13 |
+
RUN if ! command -v uv >/dev/null 2>&1; then \
|
| 14 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
| 15 |
+
mv /root/.local/bin/uv /usr/local/bin/uv && \
|
| 16 |
+
mv /root/.local/bin/uvx /usr/local/bin/uvx; \
|
| 17 |
+
fi
|
| 18 |
+
|
| 19 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 20 |
+
if [ -f uv.lock ]; then \
|
| 21 |
+
uv sync --frozen --no-install-project --no-editable; \
|
| 22 |
+
else \
|
| 23 |
+
uv sync --no-install-project --no-editable; \
|
| 24 |
+
fi
|
| 25 |
+
|
| 26 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 27 |
+
if [ -f uv.lock ]; then \
|
| 28 |
+
uv sync --frozen --no-editable; \
|
| 29 |
+
else \
|
| 30 |
+
uv sync --no-editable; \
|
| 31 |
+
fi
|
| 32 |
+
|
| 33 |
+
FROM ${BASE_IMAGE}
|
| 34 |
+
|
| 35 |
+
WORKDIR /app
|
| 36 |
+
|
| 37 |
+
COPY --from=builder /app/env/.venv /app/.venv
|
| 38 |
+
COPY --from=builder /app/env /app/env
|
| 39 |
+
COPY --from=builder /app/env/patient_data.json /app/patient_data.json
|
| 40 |
+
|
| 41 |
+
ENV PATH="/app/.venv/bin:$PATH"
|
| 42 |
+
ENV PYTHONPATH="/app/env:$PYTHONPATH"
|
| 43 |
+
ENV ENABLE_WEB_INTERFACE=true
|
| 44 |
+
|
| 45 |
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
| 46 |
+
CMD curl -f http://localhost:8000/health || exit 1
|
| 47 |
+
|
| 48 |
+
CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
|
README.md
CHANGED
|
@@ -6,8 +6,130 @@ colorTo: indigo
|
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
app_port: 8000
|
| 9 |
-
base_path: /web
|
| 10 |
tags:
|
| 11 |
- openenv
|
| 12 |
short_description: RL environment for clinical trial patient screening.
|
| 13 |
-
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
app_port: 8000
|
|
|
|
| 9 |
tags:
|
| 10 |
- openenv
|
| 11 |
short_description: RL environment for clinical trial patient screening.
|
| 12 |
+
---
|
| 13 |
+
# Clinical Trial Patient Screening Environment
|
| 14 |
+
|
| 15 |
+
OpenEnv environment for clinical trial patient screening with three deterministic healthcare tasks:
|
| 16 |
+
|
| 17 |
+
- `easy`: binary eligibility screening against 5 criteria
|
| 18 |
+
- `medium`: ranking 3 patients by protocol fit
|
| 19 |
+
- `hard`: protocol deviation and exclusion detection from unstructured clinical text
|
| 20 |
+
|
| 21 |
+
This environment is designed as a real-world screening workflow rather than a toy game. It uses typed Pydantic models, deterministic programmatic graders, incremental reward shaping for correct data extraction, and terminal rewards for correct screening outcomes.
|
| 22 |
+
|
| 23 |
+
## Task Overview
|
| 24 |
+
|
| 25 |
+
### Easy
|
| 26 |
+
EGFR-mutated metastatic NSCLC eligibility check using structured oncology data.
|
| 27 |
+
|
| 28 |
+
### Medium
|
| 29 |
+
Rank 3 HER2-positive metastatic breast cancer candidates by fit for a trial.
|
| 30 |
+
|
| 31 |
+
### Hard
|
| 32 |
+
Detect subtle exclusions from an AML screening note, including protocol deviations such as recent investigational treatment, active infection, QTc prolongation, and CYP3A4 inhibitor exposure.
|
| 33 |
+
|
| 34 |
+
## Reward Design
|
| 35 |
+
|
| 36 |
+
- `+0.20` for each correct clinical data point or valid deviation extracted
|
| 37 |
+
- `+1.00` for a correct final screening decision
|
| 38 |
+
- `-0.50` for hallucinated fields, invalid deviation claims, or destructive actions
|
| 39 |
+
|
| 40 |
+
Each task also produces a deterministic grader score in `[0.0, 1.0]`.
|
| 41 |
+
|
| 42 |
+
## Project Structure
|
| 43 |
+
|
| 44 |
+
```text
|
| 45 |
+
clinical_trial_env/
|
| 46 |
+
├── .env
|
| 47 |
+
├── Dockerfile
|
| 48 |
+
├── README.md
|
| 49 |
+
├── __init__.py
|
| 50 |
+
├── client.py
|
| 51 |
+
├── env.py
|
| 52 |
+
├── inference.py
|
| 53 |
+
├── models.py
|
| 54 |
+
├── openenv.yaml
|
| 55 |
+
├── pyproject.toml
|
| 56 |
+
└── server/
|
| 57 |
+
├── __init__.py
|
| 58 |
+
├── app.py
|
| 59 |
+
└── clinical_trial_env_environment.py
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
## Build And Run
|
| 63 |
+
|
| 64 |
+
Build the container from the project root:
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
docker build -t clinical-trial-env:latest .
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
Run the server locally:
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
docker run --rm -p 8000:8000 clinical-trial-env:latest
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
Validate the environment:
|
| 77 |
+
|
| 78 |
+
```bash
|
| 79 |
+
openenv validate .
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
## Inference
|
| 83 |
+
|
| 84 |
+
The root [inference.py](/Users/abhishekkanade/Desktop/Hackathon/OpenEnv/clinical_trial_env/inference.py) uses the OpenAI client and emits exactly:
|
| 85 |
+
|
| 86 |
+
- `[START]`
|
| 87 |
+
- `[STEP]`
|
| 88 |
+
- `[END]`
|
| 89 |
+
|
| 90 |
+
Required environment variables:
|
| 91 |
+
|
| 92 |
+
- `HF_TOKEN`
|
| 93 |
+
- `LOCAL_IMAGE_NAME` or `ENV_BASE_URL`
|
| 94 |
+
- `API_BASE_URL` optional, defaults to Hugging Face router
|
| 95 |
+
- `MODEL_NAME` optional
|
| 96 |
+
- `CLINICAL_TRIAL_TASK` with values `easy`, `medium`, or `hard`
|
| 97 |
+
|
| 98 |
+
Example:
|
| 99 |
+
|
| 100 |
+
```bash
|
| 101 |
+
set -a
|
| 102 |
+
source .env
|
| 103 |
+
set +a
|
| 104 |
+
python3 inference.py
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
## Python Usage
|
| 108 |
+
|
| 109 |
+
```python
|
| 110 |
+
import asyncio
|
| 111 |
+
|
| 112 |
+
from clinical_trial_env import ClinicalTrialAction, ClinicalTrialEnv
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
async def main() -> None:
|
| 116 |
+
env = await ClinicalTrialEnv.from_docker_image("clinical-trial-env:latest")
|
| 117 |
+
try:
|
| 118 |
+
result = await env.reset(task_id="easy")
|
| 119 |
+
result = await env.step(
|
| 120 |
+
ClinicalTrialAction(action_type="extract_data", field_name="age", value="56")
|
| 121 |
+
)
|
| 122 |
+
print(result.reward, result.observation.reward_details.grader_score)
|
| 123 |
+
finally:
|
| 124 |
+
await env.close()
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
asyncio.run(main())
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
## Audit Notes
|
| 131 |
+
|
| 132 |
+
- `reset()`, `step(action)`, and OpenEnv `state` access are implemented in [env.py](/Users/abhishekkanade/Desktop/Hackathon/OpenEnv/clinical_trial_env/env.py).
|
| 133 |
+
- Gold answers are not exposed through observation metadata.
|
| 134 |
+
- Graders are deterministic and bounded in `[0.0, 1.0]`.
|
| 135 |
+
- The hard task uses unstructured medical text and clinically realistic exclusion criteria.
|
__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"""Clinical Trial screening environment."""
|
| 8 |
+
|
| 9 |
+
from .client import ClinicalTrialEnv
|
| 10 |
+
from .env import ClinicalTrialEnvironment
|
| 11 |
+
from .models import (
|
| 12 |
+
ClinicalTrialAction,
|
| 13 |
+
ClinicalTrialObservation,
|
| 14 |
+
ClinicalTrialReward,
|
| 15 |
+
ClinicalTrialState,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
__all__ = [
|
| 19 |
+
"ClinicalTrialAction",
|
| 20 |
+
"ClinicalTrialObservation",
|
| 21 |
+
"ClinicalTrialReward",
|
| 22 |
+
"ClinicalTrialState",
|
| 23 |
+
"ClinicalTrialEnvironment",
|
| 24 |
+
"ClinicalTrialEnv",
|
| 25 |
+
]
|
client.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"""Clinical Trial Env environment client."""
|
| 8 |
+
|
| 9 |
+
from typing import Dict
|
| 10 |
+
|
| 11 |
+
from openenv.core import EnvClient
|
| 12 |
+
from openenv.core.client_types import StepResult
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
from .models import ClinicalTrialAction, ClinicalTrialObservation, ClinicalTrialState
|
| 16 |
+
except ImportError:
|
| 17 |
+
from models import ClinicalTrialAction, ClinicalTrialObservation, ClinicalTrialState
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ClinicalTrialEnv(
|
| 21 |
+
EnvClient[ClinicalTrialAction, ClinicalTrialObservation, ClinicalTrialState]
|
| 22 |
+
):
|
| 23 |
+
"""
|
| 24 |
+
Client for the clinical trial screening environment.
|
| 25 |
+
|
| 26 |
+
This client maintains a persistent WebSocket connection to the environment server,
|
| 27 |
+
enabling efficient multi-step interactions with lower latency.
|
| 28 |
+
Each client instance has its own dedicated environment session on the server.
|
| 29 |
+
|
| 30 |
+
Example:
|
| 31 |
+
>>> # Connect to a running server
|
| 32 |
+
>>> with ClinicalTrialEnv(base_url="http://localhost:8000") as client:
|
| 33 |
+
... result = await client.reset(task_id="easy")
|
| 34 |
+
... print(result.observation.title)
|
| 35 |
+
...
|
| 36 |
+
... result = await client.step(
|
| 37 |
+
... ClinicalTrialAction(action_type="submit_decision", final_decision="eligible")
|
| 38 |
+
... )
|
| 39 |
+
... print(result.observation.reward_details.grader_score)
|
| 40 |
+
|
| 41 |
+
Example with Docker:
|
| 42 |
+
>>> # Automatically start container and connect
|
| 43 |
+
>>> client = await ClinicalTrialEnv.from_docker_image("clinical-trial-env:latest")
|
| 44 |
+
>>> try:
|
| 45 |
+
... result = await client.reset(task_id="hard")
|
| 46 |
+
... result = await client.step(
|
| 47 |
+
... ClinicalTrialAction(action_type="flag_deviation", deviations=["active uncontrolled infection"])
|
| 48 |
+
... )
|
| 49 |
+
... finally:
|
| 50 |
+
... await client.close()
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
def _step_payload(self, action: ClinicalTrialAction) -> Dict:
|
| 54 |
+
"""
|
| 55 |
+
Convert ClinicalTrialAction to JSON payload for step message.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
action: ClinicalTrialAction instance
|
| 59 |
+
|
| 60 |
+
Returns:
|
| 61 |
+
Dictionary representation suitable for JSON encoding
|
| 62 |
+
"""
|
| 63 |
+
return action.model_dump(mode="json")
|
| 64 |
+
|
| 65 |
+
def _parse_result(self, payload: Dict) -> StepResult[ClinicalTrialObservation]:
|
| 66 |
+
"""
|
| 67 |
+
Parse server response into StepResult[ClinicalTrialObservation].
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
payload: JSON response data from server
|
| 71 |
+
|
| 72 |
+
Returns:
|
| 73 |
+
StepResult with ClinicalTrialObservation
|
| 74 |
+
"""
|
| 75 |
+
obs_data = payload.get("observation", {})
|
| 76 |
+
observation = ClinicalTrialObservation.model_validate(
|
| 77 |
+
{
|
| 78 |
+
**obs_data,
|
| 79 |
+
"done": obs_data.get("done", payload.get("done", False)),
|
| 80 |
+
"reward": obs_data.get("reward", payload.get("reward")),
|
| 81 |
+
}
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
return StepResult(
|
| 85 |
+
observation=observation,
|
| 86 |
+
reward=payload.get("reward"),
|
| 87 |
+
done=payload.get("done", False),
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
def _parse_state(self, payload: Dict) -> ClinicalTrialState:
|
| 91 |
+
"""
|
| 92 |
+
Parse server response into State object.
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
payload: JSON response from state request
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
State object with episode_id and step_count
|
| 99 |
+
"""
|
| 100 |
+
return ClinicalTrialState.model_validate(payload)
|
env.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core RL environment for clinical trial patient screening."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
from uuid import uuid4
|
| 9 |
+
|
| 10 |
+
from openenv.core.env_server.interfaces import Environment
|
| 11 |
+
from pydantic import BaseModel, Field
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
from .models import (
|
| 15 |
+
ClinicalTrialAction,
|
| 16 |
+
ClinicalTrialObservation,
|
| 17 |
+
ClinicalTrialReward,
|
| 18 |
+
ClinicalTrialState,
|
| 19 |
+
)
|
| 20 |
+
except ImportError:
|
| 21 |
+
from models import (
|
| 22 |
+
ClinicalTrialAction,
|
| 23 |
+
ClinicalTrialObservation,
|
| 24 |
+
ClinicalTrialReward,
|
| 25 |
+
ClinicalTrialState,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
INCREMENTAL_REWARD = 0.20
|
| 29 |
+
FINAL_REWARD = 1.00
|
| 30 |
+
HALLUCINATION_PENALTY = -0.50
|
| 31 |
+
TASK_SEQUENCE = ["easy", "medium", "hard"]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _normalize(value: Optional[str]) -> str:
|
| 35 |
+
return " ".join((value or "").strip().lower().replace("_", " ").split())
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class GroundTruth(BaseModel):
|
| 39 |
+
"""Deterministic grader targets for a scenario."""
|
| 40 |
+
|
| 41 |
+
extracted_fields: Dict[str, str] = Field(default_factory=dict)
|
| 42 |
+
ranking: List[str] = Field(default_factory=list)
|
| 43 |
+
final_decision: str
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class ScenarioSpec(BaseModel):
|
| 47 |
+
"""Scenario loaded from patient_data.json."""
|
| 48 |
+
|
| 49 |
+
task_id: str
|
| 50 |
+
difficulty: str
|
| 51 |
+
title: str
|
| 52 |
+
instructions: str
|
| 53 |
+
context: Dict[str, Any]
|
| 54 |
+
ground_truth: GroundTruth
|
| 55 |
+
hidden_exclusions: List[str] = Field(default_factory=list)
|
| 56 |
+
max_steps: int = 6
|
| 57 |
+
grader_name: str = "deterministic_json_grader"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class ClinicalTrialEnvironment(
|
| 61 |
+
Environment[ClinicalTrialAction, ClinicalTrialObservation, ClinicalTrialState]
|
| 62 |
+
):
|
| 63 |
+
"""Clinical trial screening environment backed by externalized JSON scenarios."""
|
| 64 |
+
|
| 65 |
+
SUPPORTS_CONCURRENT_SESSIONS = True
|
| 66 |
+
|
| 67 |
+
def __init__(self) -> None:
|
| 68 |
+
super().__init__()
|
| 69 |
+
data_path = Path(__file__).resolve().with_name("patient_data.json")
|
| 70 |
+
payload = json.loads(data_path.read_text(encoding="utf-8"))
|
| 71 |
+
task_payload = payload.get("tasks", {})
|
| 72 |
+
self._scenarios: Dict[str, ScenarioSpec] = {
|
| 73 |
+
task_id: ScenarioSpec.model_validate({**scenario, "task_id": task_id})
|
| 74 |
+
for task_id, scenario in task_payload.items()
|
| 75 |
+
}
|
| 76 |
+
self._task_cursor = -1
|
| 77 |
+
self._current_scenario: Optional[ScenarioSpec] = None
|
| 78 |
+
self._submitted_ranking: List[str] = []
|
| 79 |
+
self._state = ClinicalTrialState(episode_id=str(uuid4()), step_count=0)
|
| 80 |
+
|
| 81 |
+
def reset(
|
| 82 |
+
self,
|
| 83 |
+
seed: Optional[int] = None,
|
| 84 |
+
episode_id: Optional[str] = None,
|
| 85 |
+
task_id: Optional[str] = None,
|
| 86 |
+
**kwargs: Any,
|
| 87 |
+
) -> ClinicalTrialObservation:
|
| 88 |
+
del seed, kwargs
|
| 89 |
+
selected_task_id = task_id or self._next_task_id()
|
| 90 |
+
self._current_scenario = self._scenarios[selected_task_id]
|
| 91 |
+
self._submitted_ranking = []
|
| 92 |
+
self._state = ClinicalTrialState(
|
| 93 |
+
episode_id=episode_id or str(uuid4()),
|
| 94 |
+
step_count=0,
|
| 95 |
+
current_task_id=self._current_scenario.task_id,
|
| 96 |
+
difficulty=self._current_scenario.difficulty,
|
| 97 |
+
title=self._current_scenario.title,
|
| 98 |
+
extracted_fields={},
|
| 99 |
+
identified_deviations=[],
|
| 100 |
+
final_decision=None,
|
| 101 |
+
grading_score=0.0,
|
| 102 |
+
)
|
| 103 |
+
return self._build_observation(
|
| 104 |
+
reward_details=ClinicalTrialReward(notes=["Episode reset."]),
|
| 105 |
+
done=False,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
def step(
|
| 109 |
+
self,
|
| 110 |
+
action: ClinicalTrialAction,
|
| 111 |
+
timeout_s: Optional[float] = None,
|
| 112 |
+
**kwargs: Any,
|
| 113 |
+
) -> ClinicalTrialObservation:
|
| 114 |
+
del timeout_s, kwargs
|
| 115 |
+
if self._current_scenario is None:
|
| 116 |
+
return self.reset()
|
| 117 |
+
|
| 118 |
+
self._state.step_count += 1
|
| 119 |
+
reward = ClinicalTrialReward()
|
| 120 |
+
done = False
|
| 121 |
+
terminal_reason: Optional[str] = None
|
| 122 |
+
|
| 123 |
+
if action.action_type == "extract_data":
|
| 124 |
+
self._handle_extraction(action, reward)
|
| 125 |
+
elif action.action_type == "flag_deviation":
|
| 126 |
+
self._handle_deviation_flag(action, reward)
|
| 127 |
+
elif action.action_type == "rank_patients":
|
| 128 |
+
self._handle_ranking(action, reward)
|
| 129 |
+
if action.ranking:
|
| 130 |
+
done = True
|
| 131 |
+
terminal_reason = "ranking_submitted"
|
| 132 |
+
elif action.action_type == "submit_decision":
|
| 133 |
+
self._state.final_decision = action.final_decision
|
| 134 |
+
done = True
|
| 135 |
+
terminal_reason = "final_decision_submitted"
|
| 136 |
+
elif action.action_type == "delete_evidence":
|
| 137 |
+
reward.penalty += HALLUCINATION_PENALTY
|
| 138 |
+
reward.notes.append("Destructive action: deleting evidence is not allowed.")
|
| 139 |
+
else:
|
| 140 |
+
reward.penalty += HALLUCINATION_PENALTY
|
| 141 |
+
reward.notes.append(f"Unsupported action type: {action.action_type}")
|
| 142 |
+
|
| 143 |
+
if self._state.step_count >= self._current_scenario.max_steps and not done:
|
| 144 |
+
done = True
|
| 145 |
+
terminal_reason = "max_steps_reached"
|
| 146 |
+
|
| 147 |
+
if done:
|
| 148 |
+
reward.grader_score = self.grader()
|
| 149 |
+
if self._is_final_submission_correct():
|
| 150 |
+
reward.final_reward = FINAL_REWARD
|
| 151 |
+
reward.notes.append("Correct final screening decision.")
|
| 152 |
+
reward.missing_items = self._missing_items()
|
| 153 |
+
self._state.grading_score = reward.grader_score
|
| 154 |
+
|
| 155 |
+
reward.total_reward = round(
|
| 156 |
+
reward.incremental_reward + reward.final_reward + reward.penalty, 4
|
| 157 |
+
)
|
| 158 |
+
return self._build_observation(
|
| 159 |
+
reward_details=reward,
|
| 160 |
+
done=done,
|
| 161 |
+
terminal_reason=terminal_reason,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
@property
|
| 165 |
+
def state(self) -> ClinicalTrialState:
|
| 166 |
+
return self._state
|
| 167 |
+
|
| 168 |
+
def grader(self) -> float:
|
| 169 |
+
"""Deterministically compare agent outputs against the current scenario ground truth."""
|
| 170 |
+
assert self._current_scenario is not None
|
| 171 |
+
components: List[float] = []
|
| 172 |
+
truth = self._current_scenario.ground_truth
|
| 173 |
+
|
| 174 |
+
if truth.extracted_fields:
|
| 175 |
+
field_hits = sum(
|
| 176 |
+
1
|
| 177 |
+
for field_name, expected in truth.extracted_fields.items()
|
| 178 |
+
if _normalize(self._state.extracted_fields.get(field_name)) == _normalize(expected)
|
| 179 |
+
)
|
| 180 |
+
components.append(field_hits / len(truth.extracted_fields))
|
| 181 |
+
|
| 182 |
+
if self._current_scenario.hidden_exclusions:
|
| 183 |
+
exclusion_hits = sum(
|
| 184 |
+
1
|
| 185 |
+
for exclusion in self._current_scenario.hidden_exclusions
|
| 186 |
+
if exclusion in self._state.identified_deviations
|
| 187 |
+
)
|
| 188 |
+
components.append(exclusion_hits / len(self._current_scenario.hidden_exclusions))
|
| 189 |
+
|
| 190 |
+
if truth.ranking:
|
| 191 |
+
ranking = self._submitted_ranking
|
| 192 |
+
if ranking and len(ranking) == len(truth.ranking):
|
| 193 |
+
positional_hits = sum(
|
| 194 |
+
1 for actual, expected in zip(ranking, truth.ranking) if actual == expected
|
| 195 |
+
) / len(truth.ranking)
|
| 196 |
+
pairwise_hits = 0
|
| 197 |
+
total_pairs = 0
|
| 198 |
+
for index, higher in enumerate(truth.ranking):
|
| 199 |
+
for lower in truth.ranking[index + 1 :]:
|
| 200 |
+
total_pairs += 1
|
| 201 |
+
if ranking.index(higher) < ranking.index(lower):
|
| 202 |
+
pairwise_hits += 1
|
| 203 |
+
pairwise_score = pairwise_hits / max(total_pairs, 1)
|
| 204 |
+
components.append((0.6 * positional_hits) + (0.4 * pairwise_score))
|
| 205 |
+
else:
|
| 206 |
+
components.append(0.0)
|
| 207 |
+
|
| 208 |
+
components.append(
|
| 209 |
+
1.0
|
| 210 |
+
if _normalize(self._state.final_decision) == _normalize(truth.final_decision)
|
| 211 |
+
else 0.0
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
if not components:
|
| 215 |
+
return 0.0
|
| 216 |
+
return round(sum(components) / len(components), 4)
|
| 217 |
+
|
| 218 |
+
def _next_task_id(self) -> str:
|
| 219 |
+
self._task_cursor = (self._task_cursor + 1) % len(TASK_SEQUENCE)
|
| 220 |
+
return TASK_SEQUENCE[self._task_cursor]
|
| 221 |
+
|
| 222 |
+
def _handle_extraction(self, action: ClinicalTrialAction, reward: ClinicalTrialReward) -> None:
|
| 223 |
+
assert self._current_scenario is not None
|
| 224 |
+
if not action.field_name or action.value is None:
|
| 225 |
+
reward.penalty += HALLUCINATION_PENALTY
|
| 226 |
+
reward.notes.append("extract_data requires field_name and value.")
|
| 227 |
+
return
|
| 228 |
+
|
| 229 |
+
expected_value = self._current_scenario.ground_truth.extracted_fields.get(action.field_name)
|
| 230 |
+
if expected_value is None:
|
| 231 |
+
reward.penalty += HALLUCINATION_PENALTY
|
| 232 |
+
reward.notes.append(f"Hallucinated field: {action.field_name}")
|
| 233 |
+
return
|
| 234 |
+
|
| 235 |
+
if _normalize(action.value) == _normalize(expected_value):
|
| 236 |
+
if action.field_name not in self._state.extracted_fields:
|
| 237 |
+
reward.incremental_reward += INCREMENTAL_REWARD
|
| 238 |
+
reward.matched_items.append(action.field_name)
|
| 239 |
+
reward.notes.append(f"Validated extraction for {action.field_name}.")
|
| 240 |
+
self._state.extracted_fields[action.field_name] = action.value
|
| 241 |
+
else:
|
| 242 |
+
reward.penalty += HALLUCINATION_PENALTY
|
| 243 |
+
reward.notes.append(f"Incorrect value for {action.field_name}.")
|
| 244 |
+
|
| 245 |
+
def _handle_deviation_flag(self, action: ClinicalTrialAction, reward: ClinicalTrialReward) -> None:
|
| 246 |
+
assert self._current_scenario is not None
|
| 247 |
+
submitted = [_normalize(item) for item in action.deviations]
|
| 248 |
+
if not submitted:
|
| 249 |
+
reward.penalty += HALLUCINATION_PENALTY
|
| 250 |
+
reward.notes.append("flag_deviation requires at least one deviation.")
|
| 251 |
+
return
|
| 252 |
+
|
| 253 |
+
for deviation in submitted:
|
| 254 |
+
if deviation in self._current_scenario.hidden_exclusions:
|
| 255 |
+
if deviation not in self._state.identified_deviations:
|
| 256 |
+
self._state.identified_deviations.append(deviation)
|
| 257 |
+
reward.incremental_reward += INCREMENTAL_REWARD
|
| 258 |
+
reward.matched_items.append(deviation)
|
| 259 |
+
reward.notes.append(f"Validated deviation: {deviation}.")
|
| 260 |
+
else:
|
| 261 |
+
reward.penalty += HALLUCINATION_PENALTY
|
| 262 |
+
reward.notes.append(f"Unsupported deviation claim: {deviation}.")
|
| 263 |
+
|
| 264 |
+
def _handle_ranking(self, action: ClinicalTrialAction, reward: ClinicalTrialReward) -> None:
|
| 265 |
+
assert self._current_scenario is not None
|
| 266 |
+
ranking = action.ranking
|
| 267 |
+
valid_patients = [
|
| 268 |
+
patient["patient_id"]
|
| 269 |
+
for patient in self._current_scenario.context.get("patients", [])
|
| 270 |
+
]
|
| 271 |
+
if sorted(ranking) != sorted(valid_patients):
|
| 272 |
+
reward.penalty += HALLUCINATION_PENALTY
|
| 273 |
+
reward.notes.append("Ranking must include each patient exactly once.")
|
| 274 |
+
return
|
| 275 |
+
self._submitted_ranking = ranking
|
| 276 |
+
self._state.final_decision = "ranking_submitted"
|
| 277 |
+
|
| 278 |
+
def _is_final_submission_correct(self) -> bool:
|
| 279 |
+
assert self._current_scenario is not None
|
| 280 |
+
truth = self._current_scenario.ground_truth
|
| 281 |
+
if truth.ranking:
|
| 282 |
+
return self._submitted_ranking == truth.ranking
|
| 283 |
+
return _normalize(self._state.final_decision) == _normalize(truth.final_decision)
|
| 284 |
+
|
| 285 |
+
def _missing_items(self) -> List[str]:
|
| 286 |
+
assert self._current_scenario is not None
|
| 287 |
+
truth = self._current_scenario.ground_truth
|
| 288 |
+
missing_fields = [
|
| 289 |
+
field_name
|
| 290 |
+
for field_name, expected in truth.extracted_fields.items()
|
| 291 |
+
if _normalize(self._state.extracted_fields.get(field_name)) != _normalize(expected)
|
| 292 |
+
]
|
| 293 |
+
missing_fields.extend(
|
| 294 |
+
exclusion
|
| 295 |
+
for exclusion in self._current_scenario.hidden_exclusions
|
| 296 |
+
if exclusion not in self._state.identified_deviations
|
| 297 |
+
)
|
| 298 |
+
if truth.ranking and self._submitted_ranking != truth.ranking:
|
| 299 |
+
missing_fields.append("ranking")
|
| 300 |
+
if _normalize(self._state.final_decision) != _normalize(truth.final_decision):
|
| 301 |
+
missing_fields.append("final_decision")
|
| 302 |
+
return missing_fields
|
| 303 |
+
|
| 304 |
+
def _build_observation(
|
| 305 |
+
self,
|
| 306 |
+
reward_details: ClinicalTrialReward,
|
| 307 |
+
done: bool,
|
| 308 |
+
terminal_reason: Optional[str] = None,
|
| 309 |
+
) -> ClinicalTrialObservation:
|
| 310 |
+
assert self._current_scenario is not None
|
| 311 |
+
attempts_remaining = max(self._current_scenario.max_steps - self._state.step_count, 0)
|
| 312 |
+
return ClinicalTrialObservation(
|
| 313 |
+
task_id=self._current_scenario.task_id,
|
| 314 |
+
difficulty=self._current_scenario.difficulty, # type: ignore[arg-type]
|
| 315 |
+
title=self._current_scenario.title,
|
| 316 |
+
instructions=self._current_scenario.instructions,
|
| 317 |
+
context=self._current_scenario.context,
|
| 318 |
+
expected_fields=list(self._current_scenario.ground_truth.extracted_fields.keys()),
|
| 319 |
+
extracted_fields=dict(self._state.extracted_fields),
|
| 320 |
+
identified_deviations=list(self._state.identified_deviations),
|
| 321 |
+
attempts_remaining=attempts_remaining,
|
| 322 |
+
grader_name=self._current_scenario.grader_name,
|
| 323 |
+
reward_details=reward_details,
|
| 324 |
+
reward=reward_details.total_reward,
|
| 325 |
+
done=done,
|
| 326 |
+
metadata={"grading_score": self._state.grading_score},
|
| 327 |
+
terminal_reason=terminal_reason,
|
| 328 |
+
)
|
inference.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hackathon-compliant inference runner for the clinical trial environment."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import textwrap
|
| 9 |
+
from typing import Dict, List, Optional, Tuple
|
| 10 |
+
|
| 11 |
+
from openai import OpenAI
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
from clinical_trial_env import ClinicalTrialAction, ClinicalTrialEnv
|
| 15 |
+
except ImportError:
|
| 16 |
+
from client import ClinicalTrialEnv
|
| 17 |
+
from models import ClinicalTrialAction
|
| 18 |
+
|
| 19 |
+
LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME") or os.getenv("IMAGE_NAME")
|
| 20 |
+
API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
|
| 21 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 22 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
|
| 23 |
+
TASK_NAME = os.getenv("CLINICAL_TRIAL_TASK", "easy")
|
| 24 |
+
BENCHMARK = os.getenv("CLINICAL_TRIAL_BENCHMARK", "clinical_trial_env")
|
| 25 |
+
ENV_BASE_URL = os.getenv("ENV_BASE_URL")
|
| 26 |
+
MAX_STEPS = int(os.getenv("MAX_STEPS", "20"))
|
| 27 |
+
TEMPERATURE = float(os.getenv("TEMPERATURE", "0.1"))
|
| 28 |
+
MAX_TOKENS = int(os.getenv("MAX_TOKENS", "220"))
|
| 29 |
+
SUCCESS_SCORE_THRESHOLD = float(os.getenv("SUCCESS_SCORE_THRESHOLD", "0.8"))
|
| 30 |
+
|
| 31 |
+
SYSTEM_PROMPT = textwrap.dedent(
|
| 32 |
+
"""
|
| 33 |
+
You are operating a clinical trial screening environment.
|
| 34 |
+
Return exactly one compact JSON object with keys:
|
| 35 |
+
action_type, field_name, value, ranking, deviations, final_decision, rationale.
|
| 36 |
+
Use only supported action_type values:
|
| 37 |
+
extract_data, rank_patients, flag_deviation, submit_decision.
|
| 38 |
+
Do not add markdown, commentary, or code fences.
|
| 39 |
+
"""
|
| 40 |
+
).strip()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def log_start(task: str, env: str, model: str) -> None:
|
| 44 |
+
print(f"[START] task={task} env={env} model={model}", flush=True)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
|
| 48 |
+
error_text = error if error is not None else "null"
|
| 49 |
+
print(
|
| 50 |
+
f"[STEP] step={step} action={action} reward={reward:.2f} "
|
| 51 |
+
f"done={str(done).lower()} error={error_text}",
|
| 52 |
+
flush=True,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def log_end(success: bool, steps: int, rewards: List[float]) -> None:
|
| 57 |
+
rewards_str = ",".join(f"{reward:.2f}" for reward in rewards)
|
| 58 |
+
print(
|
| 59 |
+
f"[END] success={str(success).lower()} steps={steps} rewards={rewards_str}",
|
| 60 |
+
flush=True,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def sanitize_error(error: Optional[str]) -> Optional[str]:
|
| 65 |
+
if error is None:
|
| 66 |
+
return None
|
| 67 |
+
cleaned = " ".join(error.split())
|
| 68 |
+
return cleaned or "null"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def build_user_prompt(task_name: str, step: int, observation_payload: Dict, history: List[str]) -> str:
|
| 72 |
+
history_text = "\n".join(history[-4:]) if history else "None"
|
| 73 |
+
return textwrap.dedent(
|
| 74 |
+
f"""
|
| 75 |
+
Task: {task_name}
|
| 76 |
+
Step: {step}
|
| 77 |
+
Observation:
|
| 78 |
+
{json.dumps(observation_payload, indent=2, sort_keys=True)}
|
| 79 |
+
|
| 80 |
+
Recent history:
|
| 81 |
+
{history_text}
|
| 82 |
+
|
| 83 |
+
Return the next best JSON action.
|
| 84 |
+
"""
|
| 85 |
+
).strip()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def heuristic_action(task_name: str, step: int) -> ClinicalTrialAction:
|
| 89 |
+
heuristics: Dict[Tuple[str, int], ClinicalTrialAction] = {
|
| 90 |
+
("easy", 1): ClinicalTrialAction(action_type="extract_data", field_name="age", value="56"),
|
| 91 |
+
("easy", 2): ClinicalTrialAction(
|
| 92 |
+
action_type="extract_data", field_name="egfr_mutation", value="L858R positive"
|
| 93 |
+
),
|
| 94 |
+
("easy", 3): ClinicalTrialAction(action_type="submit_decision", final_decision="eligible"),
|
| 95 |
+
("medium", 1): ClinicalTrialAction(
|
| 96 |
+
action_type="extract_data", field_name="BC-101_her2_status", value="IHC 3+"
|
| 97 |
+
),
|
| 98 |
+
("medium", 2): ClinicalTrialAction(
|
| 99 |
+
action_type="extract_data", field_name="BC-102_trastuzumab_exposure", value="none"
|
| 100 |
+
),
|
| 101 |
+
("medium", 3): ClinicalTrialAction(
|
| 102 |
+
action_type="rank_patients", ranking=["BC-101", "BC-103", "BC-102"]
|
| 103 |
+
),
|
| 104 |
+
("hard", 1): ClinicalTrialAction(action_type="extract_data", field_name="biomarker", value="FLT3-ITD"),
|
| 105 |
+
("hard", 2): ClinicalTrialAction(
|
| 106 |
+
action_type="flag_deviation",
|
| 107 |
+
deviations=[
|
| 108 |
+
"neutropenic fever",
|
| 109 |
+
"qtc greater than 480 ms",
|
| 110 |
+
"recent strong CYP3A4 inhibitor",
|
| 111 |
+
],
|
| 112 |
+
),
|
| 113 |
+
("hard", 3): ClinicalTrialAction(action_type="submit_decision", final_decision="ineligible"),
|
| 114 |
+
}
|
| 115 |
+
return heuristics.get((task_name, step), ClinicalTrialAction(action_type="submit_decision", final_decision="ineligible"))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def parse_action(raw_text: str) -> ClinicalTrialAction:
|
| 119 |
+
payload = json.loads(raw_text)
|
| 120 |
+
return ClinicalTrialAction.model_validate(payload)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def get_model_action(
|
| 124 |
+
client: OpenAI,
|
| 125 |
+
task_name: str,
|
| 126 |
+
step: int,
|
| 127 |
+
observation_payload: Dict,
|
| 128 |
+
history: List[str],
|
| 129 |
+
) -> ClinicalTrialAction:
|
| 130 |
+
user_prompt = build_user_prompt(task_name, step, observation_payload, history)
|
| 131 |
+
try:
|
| 132 |
+
completion = client.chat.completions.create(
|
| 133 |
+
model=MODEL_NAME,
|
| 134 |
+
messages=[
|
| 135 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 136 |
+
{"role": "user", "content": user_prompt},
|
| 137 |
+
],
|
| 138 |
+
temperature=TEMPERATURE,
|
| 139 |
+
max_tokens=MAX_TOKENS,
|
| 140 |
+
stream=False,
|
| 141 |
+
)
|
| 142 |
+
content = (completion.choices[0].message.content or "").strip()
|
| 143 |
+
return parse_action(content)
|
| 144 |
+
except Exception:
|
| 145 |
+
return heuristic_action(task_name, step)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def format_action(action: ClinicalTrialAction) -> str:
|
| 149 |
+
payload = {
|
| 150 |
+
"action_type": action.action_type,
|
| 151 |
+
"field_name": action.field_name,
|
| 152 |
+
"value": action.value,
|
| 153 |
+
"ranking": action.ranking,
|
| 154 |
+
"deviations": action.deviations,
|
| 155 |
+
"final_decision": action.final_decision,
|
| 156 |
+
}
|
| 157 |
+
return json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
async def create_env() -> ClinicalTrialEnv:
|
| 161 |
+
if LOCAL_IMAGE_NAME:
|
| 162 |
+
return await ClinicalTrialEnv.from_docker_image(LOCAL_IMAGE_NAME)
|
| 163 |
+
if ENV_BASE_URL:
|
| 164 |
+
env = ClinicalTrialEnv(base_url=ENV_BASE_URL)
|
| 165 |
+
await env.connect()
|
| 166 |
+
return env
|
| 167 |
+
raise RuntimeError("Set LOCAL_IMAGE_NAME for Docker execution or ENV_BASE_URL for an existing server.")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
async def main() -> None:
|
| 171 |
+
client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
|
| 172 |
+
env: Optional[ClinicalTrialEnv] = None
|
| 173 |
+
rewards: List[float] = []
|
| 174 |
+
steps_taken = 0
|
| 175 |
+
score = 0.0
|
| 176 |
+
success = False
|
| 177 |
+
history: List[str] = []
|
| 178 |
+
last_error: Optional[str] = None
|
| 179 |
+
|
| 180 |
+
log_start(task=TASK_NAME, env=BENCHMARK, model=MODEL_NAME)
|
| 181 |
+
|
| 182 |
+
try:
|
| 183 |
+
env = await create_env()
|
| 184 |
+
result = await env.reset(task_id=TASK_NAME)
|
| 185 |
+
|
| 186 |
+
for step in range(1, MAX_STEPS + 1):
|
| 187 |
+
if result.done:
|
| 188 |
+
break
|
| 189 |
+
|
| 190 |
+
action = get_model_action(
|
| 191 |
+
client=client,
|
| 192 |
+
task_name=TASK_NAME,
|
| 193 |
+
step=step,
|
| 194 |
+
observation_payload=result.observation.model_dump(mode="json"),
|
| 195 |
+
history=history,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
try:
|
| 199 |
+
result = await env.step(action)
|
| 200 |
+
reward = float(result.reward or 0.0)
|
| 201 |
+
done = bool(result.done)
|
| 202 |
+
last_error = None
|
| 203 |
+
except Exception as exc:
|
| 204 |
+
reward = 0.0
|
| 205 |
+
done = True
|
| 206 |
+
last_error = sanitize_error(str(exc))
|
| 207 |
+
|
| 208 |
+
rewards.append(reward)
|
| 209 |
+
steps_taken = step
|
| 210 |
+
log_step(
|
| 211 |
+
step=step,
|
| 212 |
+
action=format_action(action),
|
| 213 |
+
reward=reward,
|
| 214 |
+
done=done,
|
| 215 |
+
error=sanitize_error(last_error),
|
| 216 |
+
)
|
| 217 |
+
history.append(f"step={step} action={format_action(action)} reward={reward:.2f}")
|
| 218 |
+
|
| 219 |
+
if last_error is not None or done:
|
| 220 |
+
break
|
| 221 |
+
|
| 222 |
+
if last_error is None and "result" in locals():
|
| 223 |
+
score = float(result.observation.reward_details.grader_score)
|
| 224 |
+
score = max(0.0, min(score, 1.0))
|
| 225 |
+
success = last_error is None and score >= SUCCESS_SCORE_THRESHOLD
|
| 226 |
+
finally:
|
| 227 |
+
if env is not None:
|
| 228 |
+
try:
|
| 229 |
+
await env.close()
|
| 230 |
+
except Exception as exc:
|
| 231 |
+
last_error = last_error or sanitize_error(str(exc))
|
| 232 |
+
log_end(success=success, steps=steps_taken, rewards=rewards)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
if __name__ == "__main__":
|
| 236 |
+
asyncio.run(main())
|
models.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Typed models for the clinical trial screening environment."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any, Dict, List, Literal, Optional
|
| 6 |
+
|
| 7 |
+
from openenv.core.env_server.types import Action, Observation, State
|
| 8 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ClinicalTrialReward(BaseModel):
|
| 12 |
+
"""Structured reward payload for deterministic grading and shaping."""
|
| 13 |
+
|
| 14 |
+
model_config = ConfigDict(
|
| 15 |
+
extra="forbid",
|
| 16 |
+
validate_assignment=True,
|
| 17 |
+
arbitrary_types_allowed=True,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
incremental_reward: float = Field(default=0.0, description="Reward from validated extractions.")
|
| 21 |
+
final_reward: float = Field(default=0.0, description="Terminal reward for a correct final decision.")
|
| 22 |
+
penalty: float = Field(default=0.0, description="Penalty for hallucinations or destructive actions.")
|
| 23 |
+
total_reward: float = Field(default=0.0, description="Net reward for the step.")
|
| 24 |
+
grader_score: float = Field(default=0.0, ge=0.0, le=1.0, description="Deterministic task score.")
|
| 25 |
+
matched_items: List[str] = Field(default_factory=list, description="Correctly matched items this step.")
|
| 26 |
+
missing_items: List[str] = Field(default_factory=list, description="Expected items still missing at grading time.")
|
| 27 |
+
notes: List[str] = Field(default_factory=list, description="Human-readable reward rationale.")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ClinicalTrialAction(Action):
|
| 31 |
+
"""Agent action for extracting evidence and making screening decisions."""
|
| 32 |
+
|
| 33 |
+
action_type: Literal[
|
| 34 |
+
"extract_data",
|
| 35 |
+
"rank_patients",
|
| 36 |
+
"flag_deviation",
|
| 37 |
+
"submit_decision",
|
| 38 |
+
"delete_evidence",
|
| 39 |
+
] = Field(..., description="Type of environment action.")
|
| 40 |
+
field_name: Optional[str] = Field(default=None, description="Clinical field being extracted.")
|
| 41 |
+
value: Optional[str] = Field(default=None, description="Normalized value for the extracted field.")
|
| 42 |
+
patient_id: Optional[str] = Field(default=None, description="Patient identifier for ranking or extraction.")
|
| 43 |
+
ranking: List[str] = Field(default_factory=list, description="Ordered patient IDs from best to worst fit.")
|
| 44 |
+
deviations: List[str] = Field(default_factory=list, description="Protocol deviations or exclusions identified.")
|
| 45 |
+
final_decision: Optional[str] = Field(
|
| 46 |
+
default=None,
|
| 47 |
+
description="Terminal decision such as eligible, ineligible, or ranking_submitted.",
|
| 48 |
+
)
|
| 49 |
+
rationale: Optional[str] = Field(default=None, description="Optional short reasoning trace.")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class ClinicalTrialObservation(Observation):
|
| 53 |
+
"""Observation returned after each environment interaction."""
|
| 54 |
+
|
| 55 |
+
task_id: str = Field(..., description="Current task identifier.")
|
| 56 |
+
difficulty: Literal["easy", "medium", "hard"] = Field(..., description="Task difficulty.")
|
| 57 |
+
title: str = Field(..., description="Scenario title.")
|
| 58 |
+
instructions: str = Field(..., description="Task instructions for the agent.")
|
| 59 |
+
context: Dict[str, Any] = Field(default_factory=dict, description="Structured and unstructured patient context.")
|
| 60 |
+
expected_fields: List[str] = Field(default_factory=list, description="High-value clinical fields to extract.")
|
| 61 |
+
extracted_fields: Dict[str, str] = Field(default_factory=dict, description="Validated data extracted so far.")
|
| 62 |
+
identified_deviations: List[str] = Field(default_factory=list, description="Validated protocol deviations found.")
|
| 63 |
+
attempts_remaining: int = Field(default=0, description="Steps left in the current episode.")
|
| 64 |
+
grader_name: str = Field(default="", description="Deterministic grader assigned to the task.")
|
| 65 |
+
reward_details: ClinicalTrialReward = Field(
|
| 66 |
+
default_factory=ClinicalTrialReward,
|
| 67 |
+
description="Structured reward breakdown for the step.",
|
| 68 |
+
)
|
| 69 |
+
terminal_reason: Optional[str] = Field(default=None, description="Why the episode ended, if done.")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class ClinicalTrialState(State):
|
| 73 |
+
"""Internal environment state exposed through the OpenEnv state endpoint."""
|
| 74 |
+
|
| 75 |
+
current_task_id: Optional[str] = Field(default=None, description="Current task identifier.")
|
| 76 |
+
difficulty: Optional[str] = Field(default=None, description="Difficulty for the current task.")
|
| 77 |
+
title: Optional[str] = Field(default=None, description="Current task title.")
|
| 78 |
+
extracted_fields: Dict[str, str] = Field(default_factory=dict, description="Accepted extracted fields.")
|
| 79 |
+
identified_deviations: List[str] = Field(default_factory=list, description="Accepted deviations.")
|
| 80 |
+
final_decision: Optional[str] = Field(default=None, description="Submitted terminal decision.")
|
| 81 |
+
grading_score: float = Field(default=0.0, ge=0.0, le=1.0, description="Latest grader output.")
|
| 82 |
+
|
| 83 |
+
def __call__(self) -> "ClinicalTrialState":
|
| 84 |
+
"""Support env.state() as a compatibility alias for the OpenEnv state property."""
|
| 85 |
+
return self
|
openenv.yaml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
+
version: 1.0.0
|
| 3 |
+
name: clinical_trial_env
|
| 4 |
+
author: Abhishek-CS221006
|
| 5 |
+
entry_point: env:ClinicalTrialEnv
|
| 6 |
+
tasks:
|
| 7 |
+
- id: easy
|
| 8 |
+
name: Metastatic NSCLC Screening
|
| 9 |
+
difficulty: easy
|
| 10 |
+
- id: medium
|
| 11 |
+
name: Patient Protocol Ranking
|
| 12 |
+
difficulty: medium
|
| 13 |
+
- id: hard
|
| 14 |
+
name: AML Exclusion Detection
|
| 15 |
+
difficulty: hard
|
| 16 |
+
tags:
|
| 17 |
+
- healthcare
|
| 18 |
+
- clinical-trials
|
| 19 |
+
- medical-nlp
|
patient_data.json
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"tasks": {
|
| 3 |
+
"easy": {
|
| 4 |
+
"difficulty": "easy",
|
| 5 |
+
"title": "EGFR-Mutated Metastatic NSCLC Eligibility",
|
| 6 |
+
"instructions": "Review the structured oncology chart and determine whether the patient meets the five binary eligibility criteria for a first-line EGFR inhibitor trial. Use extract_data for clinical facts and submit_decision with eligible or ineligible.",
|
| 7 |
+
"context": {
|
| 8 |
+
"trial_protocol": {
|
| 9 |
+
"phase": "Phase III",
|
| 10 |
+
"disease": "Metastatic non-small cell lung cancer",
|
| 11 |
+
"binary_criteria": [
|
| 12 |
+
"Age >= 18 years",
|
| 13 |
+
"Pathologically confirmed metastatic NSCLC",
|
| 14 |
+
"Activating EGFR mutation present",
|
| 15 |
+
"ECOG performance status 0 or 1",
|
| 16 |
+
"No prior EGFR-targeted therapy"
|
| 17 |
+
]
|
| 18 |
+
},
|
| 19 |
+
"patient": {
|
| 20 |
+
"patient_id": "NSCLC-204",
|
| 21 |
+
"age": 56,
|
| 22 |
+
"sex": "female",
|
| 23 |
+
"diagnosis": "Stage IV lung adenocarcinoma with pleural metastases",
|
| 24 |
+
"molecular_profile": "EGFR L858R positive; ALK negative; ROS1 negative",
|
| 25 |
+
"ecog": 1,
|
| 26 |
+
"prior_therapy": "Carboplatin/pemetrexed deferred; no prior EGFR TKI exposure",
|
| 27 |
+
"recent_mri": "No untreated brain metastases"
|
| 28 |
+
}
|
| 29 |
+
},
|
| 30 |
+
"ground_truth": {
|
| 31 |
+
"extracted_fields": {
|
| 32 |
+
"age": "56",
|
| 33 |
+
"diagnosis": "metastatic nsclc",
|
| 34 |
+
"egfr_mutation": "l858r positive",
|
| 35 |
+
"ecog": "1",
|
| 36 |
+
"prior_egfr_tki": "none"
|
| 37 |
+
},
|
| 38 |
+
"ranking": [],
|
| 39 |
+
"final_decision": "eligible"
|
| 40 |
+
},
|
| 41 |
+
"hidden_exclusions": [],
|
| 42 |
+
"max_steps": 6,
|
| 43 |
+
"grader_name": "deterministic_json_grader"
|
| 44 |
+
},
|
| 45 |
+
"medium": {
|
| 46 |
+
"difficulty": "medium",
|
| 47 |
+
"title": "Rank Patients for HER2-Positive Breast Cancer Trial",
|
| 48 |
+
"instructions": "Rank the three candidates from best to worst fit for a second-line HER2-positive metastatic breast cancer antibody-drug conjugate trial. Extract key biomarkers or performance status if useful, then submit the full ranking with rank_patients.",
|
| 49 |
+
"context": {
|
| 50 |
+
"trial_protocol": {
|
| 51 |
+
"phase": "Phase II",
|
| 52 |
+
"disease": "HER2-positive metastatic breast cancer",
|
| 53 |
+
"fit_score_drivers": [
|
| 54 |
+
"HER2 IHC 3+ or ISH amplified",
|
| 55 |
+
"Prior trastuzumab and taxane exposure required",
|
| 56 |
+
"No active CNS progression",
|
| 57 |
+
"ECOG 0-1 preferred",
|
| 58 |
+
"Adequate left ventricular ejection fraction"
|
| 59 |
+
]
|
| 60 |
+
},
|
| 61 |
+
"patients": [
|
| 62 |
+
{
|
| 63 |
+
"patient_id": "BC-101",
|
| 64 |
+
"age": 48,
|
| 65 |
+
"her2_status": "IHC 3+",
|
| 66 |
+
"prior_therapy": "Taxane, trastuzumab, pertuzumab completed",
|
| 67 |
+
"ecog": 0,
|
| 68 |
+
"brain_mri": "Stable treated cerebellar lesion; no active CNS progression",
|
| 69 |
+
"lvef": "61%"
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"patient_id": "BC-103",
|
| 73 |
+
"age": 62,
|
| 74 |
+
"her2_status": "ISH amplified",
|
| 75 |
+
"prior_therapy": "Taxane and trastuzumab completed; pertuzumab intolerant",
|
| 76 |
+
"ecog": 1,
|
| 77 |
+
"brain_mri": "No CNS disease",
|
| 78 |
+
"lvef": "55%"
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"patient_id": "BC-102",
|
| 82 |
+
"age": 54,
|
| 83 |
+
"her2_status": "IHC 2+ / ISH non-amplified",
|
| 84 |
+
"prior_therapy": "Taxane only; trastuzumab naive",
|
| 85 |
+
"ecog": 2,
|
| 86 |
+
"brain_mri": "New enhancing frontal lesion on last scan",
|
| 87 |
+
"lvef": "49%"
|
| 88 |
+
}
|
| 89 |
+
]
|
| 90 |
+
},
|
| 91 |
+
"ground_truth": {
|
| 92 |
+
"extracted_fields": {
|
| 93 |
+
"BC-101_her2_status": "ihc 3+",
|
| 94 |
+
"BC-101_ecog": "0",
|
| 95 |
+
"BC-103_her2_status": "ish amplified",
|
| 96 |
+
"BC-103_ecog": "1",
|
| 97 |
+
"BC-102_cns_status": "active cns progression",
|
| 98 |
+
"BC-102_trastuzumab_exposure": "none"
|
| 99 |
+
},
|
| 100 |
+
"ranking": ["BC-101", "BC-103", "BC-102"],
|
| 101 |
+
"final_decision": "ranking_submitted"
|
| 102 |
+
},
|
| 103 |
+
"hidden_exclusions": [],
|
| 104 |
+
"max_steps": 6,
|
| 105 |
+
"grader_name": "deterministic_json_grader"
|
| 106 |
+
},
|
| 107 |
+
"hard": {
|
| 108 |
+
"difficulty": "hard",
|
| 109 |
+
"title": "Protocol Deviations in AML Screening Note",
|
| 110 |
+
"instructions": "Read the unstructured hematology note, identify protocol deviations or exclusions, and determine if the patient is screen-failed. Use extract_data for critical facts, flag_deviation for protocol violations, then submit_decision with eligible or ineligible.",
|
| 111 |
+
"context": {
|
| 112 |
+
"trial_protocol": {
|
| 113 |
+
"phase": "Phase Ib",
|
| 114 |
+
"disease": "Relapsed/refractory FLT3-mutated AML",
|
| 115 |
+
"key_exclusions": [
|
| 116 |
+
"Neutropenic fever / active uncontrolled infection",
|
| 117 |
+
"QTc > 480 ms",
|
| 118 |
+
"Strong CYP3A4 inhibitor within 7 days"
|
| 119 |
+
]
|
| 120 |
+
},
|
| 121 |
+
"screening_note": "Mr. J.R. is a 63-year-old man with relapsed AML harboring FLT3-ITD. He presented overnight with neutropenic fever and positive blood cultures. ECG today showed QTc 486 ms. Posaconazole prophylaxis was restarted five days ago because of prior pulmonary aspergillosis. Bone marrow confirms persistent 22% blasts. ECOG 1. Creatinine clearance 68 mL/min."
|
| 122 |
+
},
|
| 123 |
+
"ground_truth": {
|
| 124 |
+
"extracted_fields": {
|
| 125 |
+
"age": "63",
|
| 126 |
+
"biomarker": "flt3-itd",
|
| 127 |
+
"ecg_qtc_ms": "486",
|
| 128 |
+
"infection_status": "neutropenic fever",
|
| 129 |
+
"cyp3a4_inhibitor": "posaconazole within 7 days"
|
| 130 |
+
},
|
| 131 |
+
"ranking": [],
|
| 132 |
+
"final_decision": "ineligible"
|
| 133 |
+
},
|
| 134 |
+
"hidden_exclusions": [
|
| 135 |
+
"neutropenic fever",
|
| 136 |
+
"qtc greater than 480 ms",
|
| 137 |
+
"recent strong cyp3a4 inhibitor"
|
| 138 |
+
],
|
| 139 |
+
"max_steps": 6,
|
| 140 |
+
"grader_name": "deterministic_json_grader"
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
}
|
pyproject.toml
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-clinical_trial_env"
|
| 13 |
+
version = "0.1.0"
|
| 14 |
+
description = "Clinical Trial Env environment for OpenEnv"
|
| 15 |
+
requires-python = ">=3.10"
|
| 16 |
+
dependencies = [
|
| 17 |
+
# Core OpenEnv runtime (provides FastAPI server + HTTP client types)
|
| 18 |
+
# install from github
|
| 19 |
+
# "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
|
| 20 |
+
"openenv-core[core]>=0.2.2",
|
| 21 |
+
# Environment-specific dependencies
|
| 22 |
+
# Add all dependencies needed for your environment here
|
| 23 |
+
# Examples:
|
| 24 |
+
# "numpy>=1.19.0",
|
| 25 |
+
# "torch>=2.0.0",
|
| 26 |
+
# "gymnasium>=0.29.0",
|
| 27 |
+
# "openspiel>=1.0.0",
|
| 28 |
+
# "smolagents>=1.22.0,<2",
|
| 29 |
+
"openai>=1.30.0",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
[project.optional-dependencies]
|
| 33 |
+
dev = [
|
| 34 |
+
"pytest>=8.0.0",
|
| 35 |
+
"pytest-cov>=4.0.0",
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
[project.scripts]
|
| 39 |
+
# Server entry point - enables running via: uv run --project . server
|
| 40 |
+
# or: python -m clinical_trial_env.server.app
|
| 41 |
+
server = "clinical_trial_env.server.app:main"
|
| 42 |
+
|
| 43 |
+
[tool.setuptools]
|
| 44 |
+
include-package-data = true
|
| 45 |
+
packages = ["clinical_trial_env", "clinical_trial_env.server"]
|
| 46 |
+
package-dir = { "clinical_trial_env" = ".", "clinical_trial_env.server" = "server" }
|
server/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Clinical Trial Env environment server components."""
|
| 8 |
+
|
| 9 |
+
from .clinical_trial_env_environment import ClinicalTrialEnvironment
|
| 10 |
+
|
| 11 |
+
__all__ = ["ClinicalTrialEnvironment"]
|
server/app.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
FastAPI application for the Clinical Trial Env Environment.
|
| 9 |
+
|
| 10 |
+
This module creates an HTTP server that exposes the ClinicalTrialEnvironment
|
| 11 |
+
over HTTP and WebSocket endpoints, compatible with EnvClient.
|
| 12 |
+
|
| 13 |
+
Endpoints:
|
| 14 |
+
- POST /reset: Reset the environment
|
| 15 |
+
- POST /step: Execute an action
|
| 16 |
+
- GET /state: Get current environment state
|
| 17 |
+
- GET /schema: Get action/observation schemas
|
| 18 |
+
- WS /ws: WebSocket endpoint for persistent sessions
|
| 19 |
+
|
| 20 |
+
Usage:
|
| 21 |
+
# Development (with auto-reload):
|
| 22 |
+
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
|
| 23 |
+
|
| 24 |
+
# Production:
|
| 25 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
|
| 26 |
+
|
| 27 |
+
# Or run directly:
|
| 28 |
+
python -m server.app
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
from openenv.core.env_server.http_server import create_app
|
| 33 |
+
except Exception as e: # pragma: no cover
|
| 34 |
+
raise ImportError(
|
| 35 |
+
"openenv is required for the web interface. Install dependencies with '\n uv sync\n'"
|
| 36 |
+
) from e
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
from ..models import ClinicalTrialAction, ClinicalTrialObservation
|
| 40 |
+
from .clinical_trial_env_environment import ClinicalTrialEnvironment
|
| 41 |
+
except ImportError:
|
| 42 |
+
from models import ClinicalTrialAction, ClinicalTrialObservation
|
| 43 |
+
from server.clinical_trial_env_environment import ClinicalTrialEnvironment
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# Create the app with web interface and README integration
|
| 47 |
+
app = create_app(
|
| 48 |
+
ClinicalTrialEnvironment,
|
| 49 |
+
ClinicalTrialAction,
|
| 50 |
+
ClinicalTrialObservation,
|
| 51 |
+
env_name="clinical_trial_env",
|
| 52 |
+
max_concurrent_envs=4,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def main(host: str = "0.0.0.0", port: int = 8000):
|
| 57 |
+
"""
|
| 58 |
+
Entry point for direct execution via uv run or python -m.
|
| 59 |
+
|
| 60 |
+
This function enables running the server without Docker:
|
| 61 |
+
uv run --project . server
|
| 62 |
+
uv run --project . server --port 8001
|
| 63 |
+
python -m clinical_trial_env.server.app
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
host: Host address to bind to (default: "0.0.0.0")
|
| 67 |
+
port: Port number to listen on (default: 8000)
|
| 68 |
+
|
| 69 |
+
For production deployments, consider using uvicorn directly with
|
| 70 |
+
multiple workers:
|
| 71 |
+
uvicorn clinical_trial_env.server.app:app --workers 4
|
| 72 |
+
"""
|
| 73 |
+
import uvicorn
|
| 74 |
+
|
| 75 |
+
uvicorn.run(app, host=host, port=port)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
if __name__ == "__main__":
|
| 79 |
+
main()
|
server/clinical_trial_env_environment.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compatibility wrapper for the root clinical trial environment."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
try:
|
| 6 |
+
from ..env import ClinicalTrialEnvironment
|
| 7 |
+
except ImportError:
|
| 8 |
+
from env import ClinicalTrialEnvironment
|
| 9 |
+
|
| 10 |
+
__all__ = ["ClinicalTrialEnvironment"]
|
server/requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv[core]>=0.2.0
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
uvicorn>=0.24.0
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
|