Abhishek-CS221006 commited on
Commit
eea8d2e
·
verified ·
1 Parent(s): 1aeb61b

Upload 14 files

Browse files
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
+