File size: 17,026 Bytes
ee117a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d9134e3
 
 
 
ee117a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ef096d9
ee117a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
"""
server/environment.py β€” Core medical diagnostic environment logic.

This is the BRAIN of the Medical Diagnostic Environment. It:
1. Manages patient cases and episode state
2. Processes agent actions (questions, tests, diagnoses)
3. Calculates rewards based on diagnostic quality
4. Provides trajectory-based reward signals (not sparse)

Pure Python - no HTTP or WebSocket code here.
All logic is deterministic and reproducible.
"""

import random
import uuid
from typing import Dict, List, Optional
from openenv.core.env_server import Environment

import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from models import DiagnosticAction, PatientObservation, ClinicalState
from server.medical_data import (
    PATIENT_CASES,
    calculate_question_reward,
    calculate_test_reward,
    calculate_diagnosis_accuracy,
    get_patient_response,
    normalize_test_name,
)


class MedicalDiagnosticEnvironment(Environment):
    """
    Medical Diagnostic Environment for RL Training.
    
    Simulates doctor-patient interaction where an LLM agent must:
    1. Ask relevant clinical questions
    2. Order appropriate diagnostic tests
    3. Make accurate diagnoses
    
    The environment provides rich reward signals throughout the trajectory:
    - +0.05 per relevant question asked
    - +0.10 per informative test ordered
    - +1.0 for correct final diagnosis
    - Penalizes inefficient or irrelevant actions
    
    This is NOT a sparse reward environment - the agent sees meaningful progress
    at each step, which is crucial for learning.
    """
    
    SUPPORTS_CONCURRENT_SESSIONS = True  # Allow multiple parallel training sessions
    
    # Episode configuration
    MAX_STEPS = 15  # Maximum actions per episode
    DIFFICULTY_LEVELS = ["easy", "medium", "hard"]
    
    def __init__(self):
        """Initialize environment state."""
        super().__init__()
        
        # Episode state variables
        self._episode_id: str = ""
        self._case_id: str = ""
        self._difficulty: str = ""
        self._step_count: int = 0
        self._total_reward: float = 0.0
        
        # Patient interaction tracking
        self._questions_asked: List[str] = []
        self._tests_ordered: List[str] = []
        self._test_results: Dict = {}
        self._diagnosis_submitted: Optional[str] = None
        self._final_accuracy: float = 0.0
        
        # Episode status
        self._done: bool = False
        self._episode_reward_breakdown: Dict = {
            "question_rewards": 0.0,
            "test_rewards": 0.0,
            "diagnosis_reward": 0.0,
            "efficiency_penalty": 0.0,
        }
        
        # Initialize a default episode so stateless HTTP /step can operate
        # on a fresh environment when the server creates a new instance.
        self.reset(difficulty="easy")

    @property
    def current_case_id(self) -> str:
        """Current patient case identifier."""
        return self._case_id

    @property
    def current_difficulty(self) -> str:
        """Current episode difficulty level."""
        return self._difficulty

    # ─────────────────────────────────────────────────────────────────────
    # Core API Methods
    # ─────────────────────────────────────────────────────────────────────

    def reset(self, difficulty: str = "easy", **kwargs) -> PatientObservation:
        """
        Reset the environment for a new diagnostic episode.
        
        Args:
            difficulty: "easy", "medium", or "hard" (controls case selection)
        
        Returns:
            Initial PatientObservation for the agent to read
        """
        # Initialize episode
        self._episode_id = str(uuid.uuid4())
        self._difficulty = difficulty if difficulty in self.DIFFICULTY_LEVELS else "easy"
        self._case_id = self._select_case_by_difficulty(self._difficulty)
        self._step_count = 0
        self._total_reward = 0.0
        
        # Reset tracking
        self._questions_asked = []
        self._tests_ordered = []
        self._test_results = {}
        self._diagnosis_submitted = None
        self._final_accuracy = 0.0
        self._done = False
        self._episode_reward_breakdown = {
            "question_rewards": 0.0,
            "test_rewards": 0.0,
            "diagnosis_reward": 0.0,
            "efficiency_penalty": 0.0,
        }
        
        # Get case information
        case = PATIENT_CASES[self._case_id]
        
        # Create initial observation
        initial_message = (
            f"Patient presents with: {case['presentation']}\n"
            f"Age: {case['age']}, Gender: {case['gender']}\n"
            f"You have up to {self.MAX_STEPS} steps to diagnose this patient.\n"
            f"Please start by asking questions or ordering tests."
        )
        
        return PatientObservation(
            done=False,
            reward=0.0,
            message=initial_message,
            patient_response=None,
            test_result=None,
            questions_asked=[],
            tests_completed=[],
            patient_data_revealed={
                "age": case["age"],
                "gender": case["gender"],
                "presentation": case["presentation"],
            },
            steps_taken=0,
            max_steps=self.MAX_STEPS,
        )

    def step(self, action: DiagnosticAction, **kwargs) -> PatientObservation:
        """
        Process one diagnostic action (question, test, or diagnosis).
        
        Returns immediate reward and next observation.
        """
        if self._done:
            return self._create_done_observation(
                message="Episode already ended. Call reset() to start a new case."
            )
        
        self._step_count += 1
        step_reward = 0.0
        message = ""
        patient_response = None
        test_result = None
        
        # ── Process action based on type ──
        if action.action_type == "ask_question":
            step_reward, message, patient_response = self._handle_question(action.question)
            
        elif action.action_type == "order_test":
            step_reward, message, test_result = self._handle_test(action.test_name)
            
        elif action.action_type == "submit_diagnosis":
            step_reward, message = self._handle_diagnosis(action.diagnosis)
            self._done = True
            
        else:
            message = f"Unknown action type: {action.action_type}"
            step_reward = -0.05
        
        # Accumulate rewards
        self._total_reward += step_reward
        
        # Check if episode should end
        if self._step_count >= self.MAX_STEPS and not self._done:
            message += f"\nMax steps reached. Episode ending."
            self._done = True
        
        # Get current case for patient data revelation
        case = PATIENT_CASES[self._case_id]
        
        return PatientObservation(
            done=self._done,
            reward=step_reward,
            message=message,
            patient_response=patient_response,
            test_result=test_result,
            questions_asked=self._questions_asked.copy(),
            tests_completed=self._tests_ordered.copy(),
            patient_data_revealed=self._build_patient_data_view(case),
            steps_taken=self._step_count,
            max_steps=self.MAX_STEPS,
        )

    @property
    def state(self) -> ClinicalState:
        """
        Return complete internal state (includes hidden information).
        Used for debugging only - NEVER send to agent.
        """
        case = PATIENT_CASES.get(self._case_id, {})
        
        return ClinicalState(
            episode_id=self._episode_id,
            step_count=self._step_count,
            true_diagnosis=case.get("true_diagnosis", ""),
            patient_case=self._case_id,
            patient_id=self._case_id,
            patient_details=case,
            difficulty=self._difficulty,
            questions_asked=self._questions_asked.copy(),
            tests_completed=self._tests_ordered.copy(),
            final_diagnosis_submitted=self._diagnosis_submitted,
            final_accuracy=self._final_accuracy,
        )

    # ─────────────────────────────────────────────────────────────────────
    # Action Processing
    # ─────────────────────────────────────────────────────────────────────

    def _handle_question(self, question: Optional[str]) -> tuple:
        """
        Process a question about the patient.
        
        Returns:
        (reward, message, patient_response)
        """
        if not question or not isinstance(question, str) or not question.strip():
            message = "No valid question was provided. Please ask a clinical question."
            return -0.05, message, None

        # Calculate reward for asking this question
        reward = calculate_question_reward(self._case_id, question)
        
        # Record question
        self._questions_asked.append(question)
        self._episode_reward_breakdown["question_rewards"] += reward
        
        # Get patient response
        response = get_patient_response(self._case_id, question)
        
        message = f"Patient response: {response}"
        if reward == 0.00:
            message += " (Question may not be directly relevant)"
        elif reward == 0.01:
            message += " (Somewhat relevant question)"
        else:
            message += " (Good clinical question!)"
        
        return reward, message, response

    def _handle_test(self, test_name: Optional[str]) -> tuple:
        """
        Process a test order.
        
        Returns:
        (reward, message, test_result_dict)
        """
        if not test_name or not isinstance(test_name, str) or not test_name.strip():
            message = "No valid test name was provided. Please order a valid diagnostic test."
            return -0.05, message, None

        # Calculate reward for ordering this test
        reward = calculate_test_reward(self._case_id, test_name)
        
        # Get case data
        case = PATIENT_CASES[self._case_id]
        
        # Try to find matching test result
        test_result_data = None
        matched_test_key = None
        
        test_lower = normalize_test_name(test_name)
        for test_key, result in case.get("test_results", {}).items():
            if test_key.lower() == test_lower or test_key.lower() in test_lower or test_lower in test_key.lower():
                test_result_data = result
                matched_test_key = test_key
                break
        
        if test_result_data is None:
            message = f"Test '{test_name}' not available for this patient or unavailable in this setting."
            reward = -0.02
            return reward, message, None
        
        # Record test
        self._tests_ordered.append(matched_test_key)
        self._test_results[matched_test_key] = test_result_data
        self._episode_reward_breakdown["test_rewards"] += reward
        
        # Format test result for agent
        test_result_dict = {
            "test_name": matched_test_key,
            "result": str(test_result_data),
            "interpretation": test_result_data.get("interpretation", test_result_data.get("finding", ""))
        }
        
        message = f"Test result received for {matched_test_key}:\n{test_result_dict['interpretation']}"
        if reward == 0.10:
            message += " (Excellent diagnostic test!)"
        elif reward == 0.05:
            message += " (Useful supporting test)"
        else:
            message += " (Test ordered but may be less relevant)"
        
        return reward, message, test_result_dict

    def _handle_diagnosis(self, diagnosis: str) -> tuple:
        """
        Process final diagnosis submission.
        
        Returns:
        (reward, message)
        """
        # Calculate diagnostic accuracy
        accuracy = calculate_diagnosis_accuracy(self._case_id, diagnosis)
        self._final_accuracy = accuracy
        self._diagnosis_submitted = diagnosis
        
        # Create diagnosis reward (not just accuracy, but also process quality)
        case = PATIENT_CASES[self._case_id]
        true_diagnosis = case["true_diagnosis"]
        
        # Change the if/elif chain to use >= comparisons:
        if accuracy >= 0.95:
            reward = 1.0
            message = f"Correct diagnosis: {diagnosis}"
        elif accuracy >= 0.7:
            reward = accuracy
            message = f"Acceptable diagnosis: {diagnosis}. True: {true_diagnosis}"
        elif accuracy >= 0.3:
            reward = accuracy
            message = f"Partially correct. True: {true_diagnosis}"
        else:
            reward = 0.0
            message = f"Incorrect. True: {true_diagnosis}"
        
        self._episode_reward_breakdown["diagnosis_reward"] = reward
        
        # Add efficiency feedback
        if self._step_count > self.MAX_STEPS * 0.8:
            penalty = 0.1 * (self._step_count / self.MAX_STEPS - 0.8)
            self._episode_reward_breakdown["efficiency_penalty"] = penalty
            message += f"\n(Efficiency penalty: -{penalty:.2f} for taking many steps)"
        
        return reward, message

    # ─────────────────────────────────────────────────────────────────────
    # Helper Methods
    # ─────────────────────────────────────────────────────────────────────

    def _select_case_by_difficulty(self, difficulty: str) -> str:
        matching_keys = [k for k, v in PATIENT_CASES.items() if v["difficulty"] == difficulty]
        if not matching_keys:
            matching_keys = list(PATIENT_CASES.keys())
        return random.choice(matching_keys)

    def _build_patient_data_view(self, case: Dict) -> Dict:
        """
        Build what the agent has learned about the patient so far.
        Only includes information revealed through questions/tests.
        """
        revealed = {
            "age": case.get("age"),
            "gender": case.get("gender"),
            "presentation": case.get("presentation"),
        }
        
        # Add findings based on questions asked
        findings = case.get("hidden_findings", {})
        for question in self._questions_asked:
            q_lower = question.lower()
            for finding, value in findings.items():
                if finding.lower().replace("_", " ") in q_lower:
                    revealed[f"finding_{finding}"] = value
        
        # Add test results
        if self._test_results:
            revealed["test_results"] = self._test_results
        
        return revealed

    def _create_done_observation(self, message: str) -> PatientObservation:
        """Create a terminal observation."""
        return PatientObservation(
            done=True,
            reward=0.0,
            message=message,
            patient_response=None,
            test_result=None,
            questions_asked=self._questions_asked.copy(),
            tests_completed=self._tests_ordered.copy(),
            patient_data_revealed={},
            steps_taken=self._step_count,
            max_steps=self.MAX_STEPS,
        )

    def get_episode_summary(self) -> Dict:
        """
        Return a summary of the episode for logging/evaluation.
        """
        case = PATIENT_CASES.get(self._case_id, {})
        return {
            "episode_id": self._episode_id,
            "case_id": self._case_id,
            "difficulty": self._difficulty,
            "true_diagnosis": case.get("true_diagnosis", ""),
            "submitted_diagnosis": self._diagnosis_submitted,
            "accuracy": self._final_accuracy,
            "diagnostic_accuracy": self._final_accuracy,
            "total_reward": self._total_reward,
            "steps": self._step_count,
            "steps_taken": self._step_count,
            "max_steps": self.MAX_STEPS,
            "questions_asked": len(self._questions_asked),
            "tests_ordered": len(self._tests_ordered),
            "reward_breakdown": self._episode_reward_breakdown,
        }