Spaces:
Runtime error
Runtime error
LLM Emotion Adapter for TTS + LLM Empathy Judge + reward rebalance
Browse files- ER_MAP/envs/api_router.py +59 -1
- ER_MAP/envs/empathy_engine.py +7 -80
- ER_MAP/envs/triage_env.py +355 -175
- ER_MAP/tts_engine.py +186 -70
ER_MAP/envs/api_router.py
CHANGED
|
@@ -242,6 +242,62 @@ class AgentRouter:
|
|
| 242 |
|
| 243 |
return parsed
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
# ----- LLM-as-a-Judge Treatment Evaluation -----
|
| 246 |
|
| 247 |
def evaluate_treatment(
|
|
@@ -258,7 +314,9 @@ class AgentRouter:
|
|
| 258 |
Returns:
|
| 259 |
{"score": float 0.0-1.0, "is_lethal": bool, "reasoning": str}
|
| 260 |
"""
|
| 261 |
-
#
|
|
|
|
|
|
|
| 262 |
client = self._clients.get("nurse") or self._clients.get("patient")
|
| 263 |
if client is None:
|
| 264 |
logger.warning("No API client available for LLM Judge. Returning default score.")
|
|
|
|
| 242 |
|
| 243 |
return parsed
|
| 244 |
|
| 245 |
+
# ----- LLM-as-a-Judge Empathy Evaluation -----
|
| 246 |
+
|
| 247 |
+
def evaluate_empathy(self, message: str) -> dict:
|
| 248 |
+
"""
|
| 249 |
+
Use a 70B LLM to grade the Doctor's empathy and communication style.
|
| 250 |
+
Returns dict with scores 0.0-1.0 for: empathy, explanation, dismissive, acknowledgment.
|
| 251 |
+
|
| 252 |
+
Routing: prefers the **patient** client so the Empathy Judge lives on
|
| 253 |
+
the same API key as the Doctor (Key A), leaving the Medical Judge
|
| 254 |
+
on the Nurse client (Key B) β which keeps both rate-limit budgets
|
| 255 |
+
independent.
|
| 256 |
+
"""
|
| 257 |
+
client = self._clients.get("patient") or self._clients.get("nurse")
|
| 258 |
+
default_scores = {"empathy": 0.0, "explanation": 0.0, "dismissive": 0.0, "acknowledgment": 0.0}
|
| 259 |
+
|
| 260 |
+
if client is None or not message:
|
| 261 |
+
return default_scores
|
| 262 |
+
|
| 263 |
+
judge_prompt = (
|
| 264 |
+
"Analyze the following message from a Doctor to a patient/nurse.\n"
|
| 265 |
+
f"Message: \"{message}\"\n\n"
|
| 266 |
+
"Grade the message on a scale of 0.0 to 1.0 for each of these four intents:\n"
|
| 267 |
+
"- empathy: Shows understanding, concern, reassurance, compassion.\n"
|
| 268 |
+
"- explanation: Educates the patient, explains reasoning, outlines plans.\n"
|
| 269 |
+
"- acknowledgment: Actively listens, asks clarifying questions, says 'I see' or 'tell me more'.\n"
|
| 270 |
+
"- dismissive: Curt, ignores concerns, rude, rushing, invalidating.\n\n"
|
| 271 |
+
"Respond ONLY in valid JSON format:\n"
|
| 272 |
+
'{"empathy": <float>, "explanation": <float>, "acknowledgment": <float>, "dismissive": <float>}'
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
try:
|
| 276 |
+
completion = client.chat.completions.create(
|
| 277 |
+
model=self.model,
|
| 278 |
+
messages=[
|
| 279 |
+
{"role": "system", "content": "You are a communication analysis AI. Output ONLY valid JSON."},
|
| 280 |
+
{"role": "user", "content": judge_prompt},
|
| 281 |
+
],
|
| 282 |
+
temperature=0.1,
|
| 283 |
+
max_tokens=128,
|
| 284 |
+
response_format={"type": "json_object"},
|
| 285 |
+
)
|
| 286 |
+
raw_text = completion.choices[0].message.content or ""
|
| 287 |
+
parsed = _extract_json_from_text(raw_text)
|
| 288 |
+
|
| 289 |
+
if parsed:
|
| 290 |
+
return {
|
| 291 |
+
"empathy": max(0.0, min(1.0, float(parsed.get("empathy", 0.0)))),
|
| 292 |
+
"explanation": max(0.0, min(1.0, float(parsed.get("explanation", 0.0)))),
|
| 293 |
+
"acknowledgment": max(0.0, min(1.0, float(parsed.get("acknowledgment", 0.0)))),
|
| 294 |
+
"dismissive": max(0.0, min(1.0, float(parsed.get("dismissive", 0.0)))),
|
| 295 |
+
}
|
| 296 |
+
return default_scores
|
| 297 |
+
except Exception as e:
|
| 298 |
+
logger.error(f"Empathy Judge API error: {e}")
|
| 299 |
+
return default_scores
|
| 300 |
+
|
| 301 |
# ----- LLM-as-a-Judge Treatment Evaluation -----
|
| 302 |
|
| 303 |
def evaluate_treatment(
|
|
|
|
| 314 |
Returns:
|
| 315 |
{"score": float 0.0-1.0, "is_lethal": bool, "reasoning": str}
|
| 316 |
"""
|
| 317 |
+
# Treatment / Medical Judge: prefers nurse client β lives on Key B.
|
| 318 |
+
# Empathy Judge prefers patient client β lives on Key A. This keeps
|
| 319 |
+
# the two judges on independent API key buckets.
|
| 320 |
client = self._clients.get("nurse") or self._clients.get("patient")
|
| 321 |
if client is None:
|
| 322 |
logger.warning("No API client available for LLM Judge. Returning default score.")
|
ER_MAP/envs/empathy_engine.py
CHANGED
|
@@ -14,80 +14,6 @@ import re
|
|
| 14 |
import random
|
| 15 |
from typing import Dict, Tuple, Optional
|
| 16 |
|
| 17 |
-
# ---------------------------------------------------------------------------
|
| 18 |
-
# Intent Classification (heuristic, no LLM call needed)
|
| 19 |
-
# ---------------------------------------------------------------------------
|
| 20 |
-
|
| 21 |
-
# Empathetic phrases -- Doctor shows understanding, concern, reassurance
|
| 22 |
-
EMPATHY_PATTERNS = [
|
| 23 |
-
r"\bi understand\b", r"\bi hear you\b", r"\bthat must be\b",
|
| 24 |
-
r"\bi can see\b", r"\bi know this is\b", r"\byou.?re doing great\b",
|
| 25 |
-
r"\bdon.?t worry\b", r"\bwe.?re going to\b", r"\bwe.?ll take care\b",
|
| 26 |
-
r"\bi.?m here\b", r"\byou.?re safe\b", r"\btake your time\b",
|
| 27 |
-
r"\bthat sounds\b.*\b(scary|difficult|painful|frightening)\b",
|
| 28 |
-
r"\bi.?m sorry\b.*\b(going through|dealing|feeling|hear)\b",
|
| 29 |
-
r"\bhow are you feeling\b", r"\bare you comfortable\b",
|
| 30 |
-
r"\blet me help\b", r"\bwe.?ll work together\b",
|
| 31 |
-
r"\bthat.?s understandable\b", r"\bit.?s okay\b",
|
| 32 |
-
r"\bi want to make sure\b.*\b(comfortable|safe|okay)\b",
|
| 33 |
-
r"\bthank you for\b.*\b(telling|sharing|trusting|coming)\b",
|
| 34 |
-
]
|
| 35 |
-
|
| 36 |
-
# Explanatory phrases -- Doctor educates, explains reasoning
|
| 37 |
-
EXPLAIN_PATTERNS = [
|
| 38 |
-
r"\blet me explain\b", r"\bwhat this means\b", r"\bthe reason\b",
|
| 39 |
-
r"\bbecause\b.*\bneed\b", r"\bthis test will\b", r"\bthis helps us\b",
|
| 40 |
-
r"\bwhat we.?re doing\b", r"\bhere.?s (the|my) plan\b",
|
| 41 |
-
r"\bthe results show\b", r"\bbased on\b.*\bfindings\b",
|
| 42 |
-
r"\bi recommend\b.*\bbecause\b", r"\bthis is important because\b",
|
| 43 |
-
r"\bto rule out\b", r"\bto make sure\b", r"\bso we can\b",
|
| 44 |
-
r"\bthink of it as\b", r"\bin simple terms\b",
|
| 45 |
-
]
|
| 46 |
-
|
| 47 |
-
# Dismissive phrases -- Doctor is curt, ignores patient concerns
|
| 48 |
-
DISMISSIVE_PATTERNS = [
|
| 49 |
-
r"\bjust do\b", r"\bjust take\b", r"\bjust calm\b",
|
| 50 |
-
r"\bthat.?s not important\b", r"\bdoesn.?t matter\b",
|
| 51 |
-
r"\bi don.?t have time\b", r"\bwe.?re busy\b",
|
| 52 |
-
r"\bjust sign\b", r"\bjust let me\b.*\bjob\b",
|
| 53 |
-
r"\bstop (complaining|worrying|asking)\b",
|
| 54 |
-
r"\byou.?re fine\b", r"\bit.?s nothing\b",
|
| 55 |
-
r"\bnext patient\b", r"\bhurry up\b",
|
| 56 |
-
]
|
| 57 |
-
|
| 58 |
-
# Acknowledgment phrases -- Doctor actively listens
|
| 59 |
-
ACKNOWLEDGE_PATTERNS = [
|
| 60 |
-
r"\bi see\b", r"\bgo on\b", r"\btell me more\b",
|
| 61 |
-
r"\bwhat else\b", r"\band then\b", r"\bwhen did\b",
|
| 62 |
-
r"\bhow long\b", r"\bcan you describe\b",
|
| 63 |
-
r"\bwhat happened\b", r"\bwalk me through\b",
|
| 64 |
-
]
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def classify_intent(message: str) -> Dict[str, float]:
|
| 68 |
-
"""
|
| 69 |
-
Classify a Doctor message into intent scores.
|
| 70 |
-
Returns dict with scores for: empathy, explanation, dismissive, acknowledgment.
|
| 71 |
-
All scores are 0.0-1.0. Multiple intents can co-occur.
|
| 72 |
-
"""
|
| 73 |
-
msg_lower = message.lower()
|
| 74 |
-
|
| 75 |
-
def _score(patterns):
|
| 76 |
-
hits = sum(1 for p in patterns if re.search(p, msg_lower))
|
| 77 |
-
# Normalize: 1 match = 0.5, 2+ = 0.8, 3+ = 1.0
|
| 78 |
-
if hits == 0: return 0.0
|
| 79 |
-
if hits == 1: return 0.5
|
| 80 |
-
if hits == 2: return 0.8
|
| 81 |
-
return 1.0
|
| 82 |
-
|
| 83 |
-
return {
|
| 84 |
-
"empathy": _score(EMPATHY_PATTERNS),
|
| 85 |
-
"explanation": _score(EXPLAIN_PATTERNS),
|
| 86 |
-
"dismissive": _score(DISMISSIVE_PATTERNS),
|
| 87 |
-
"acknowledgment": _score(ACKNOWLEDGE_PATTERNS),
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
|
| 91 |
# ---------------------------------------------------------------------------
|
| 92 |
# Patient Trust / Anxiety State Model
|
| 93 |
# ---------------------------------------------------------------------------
|
|
@@ -251,22 +177,23 @@ def compute_empathy_reward(
|
|
| 251 |
reward += 0.02 * intent["explanation"]
|
| 252 |
|
| 253 |
if phase >= 3:
|
| 254 |
-
# Phase 3: Full empathy reward chain
|
|
|
|
| 255 |
emp = intent.get("empathy", 0)
|
| 256 |
dismiss = intent.get("dismissive", 0)
|
| 257 |
-
|
| 258 |
if emp > 0:
|
| 259 |
-
reward += 0.
|
| 260 |
if dismiss > 0:
|
| 261 |
-
reward -= 0.
|
| 262 |
-
|
| 263 |
# Bonus for maintaining high trust
|
| 264 |
if patient_state.trust > 70:
|
| 265 |
reward += 0.02
|
| 266 |
# Penalty for critically low trust
|
| 267 |
if patient_state.trust < 25:
|
| 268 |
reward -= 0.03
|
| 269 |
-
|
| 270 |
return reward
|
| 271 |
|
| 272 |
|
|
|
|
| 14 |
import random
|
| 15 |
from typing import Dict, Tuple, Optional
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
# ---------------------------------------------------------------------------
|
| 18 |
# Patient Trust / Anxiety State Model
|
| 19 |
# ---------------------------------------------------------------------------
|
|
|
|
| 177 |
reward += 0.02 * intent["explanation"]
|
| 178 |
|
| 179 |
if phase >= 3:
|
| 180 |
+
# Phase 3: Full empathy reward chain (magnitudes softened slightly;
|
| 181 |
+
# the env-level per-episode cap is what really blocks farming).
|
| 182 |
emp = intent.get("empathy", 0)
|
| 183 |
dismiss = intent.get("dismissive", 0)
|
| 184 |
+
|
| 185 |
if emp > 0:
|
| 186 |
+
reward += 0.04 * emp # was 0.05
|
| 187 |
if dismiss > 0:
|
| 188 |
+
reward -= 0.06 * dismiss # was 0.08
|
| 189 |
+
|
| 190 |
# Bonus for maintaining high trust
|
| 191 |
if patient_state.trust > 70:
|
| 192 |
reward += 0.02
|
| 193 |
# Penalty for critically low trust
|
| 194 |
if patient_state.trust < 25:
|
| 195 |
reward -= 0.03
|
| 196 |
+
|
| 197 |
return reward
|
| 198 |
|
| 199 |
|
ER_MAP/envs/triage_env.py
CHANGED
|
@@ -8,6 +8,7 @@ internal environment actors driven by LLMs via the AgentRouter.
|
|
| 8 |
|
| 9 |
import json
|
| 10 |
import logging
|
|
|
|
| 11 |
import re
|
| 12 |
from typing import Any, Dict, Optional, Tuple
|
| 13 |
|
|
@@ -22,7 +23,6 @@ from .randomizer import (
|
|
| 22 |
SOAP_HISTORY_DB,
|
| 23 |
)
|
| 24 |
from .empathy_engine import (
|
| 25 |
-
classify_intent,
|
| 26 |
compute_empathy_reward,
|
| 27 |
PatientState,
|
| 28 |
MilestoneTracker,
|
|
@@ -44,6 +44,53 @@ PATIENT_TOOLS = {"speak_to", "leave_hospital"}
|
|
| 44 |
MAX_INTERNAL_EXCHANGES = 3 # per Doctor step, Nurse β Patient loop cap
|
| 45 |
MAX_EPISODE_STEPS = 30 # total Doctor turns before truncation
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
class TriageEnv(gym.Env):
|
| 49 |
"""
|
|
@@ -103,6 +150,11 @@ class TriageEnv(gym.Env):
|
|
| 103 |
self.patient_state: Optional[PatientState] = None
|
| 104 |
self.milestone_tracker: Optional[MilestoneTracker] = None
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
# ==================================================================
|
| 107 |
# reset()
|
| 108 |
# ==================================================================
|
|
@@ -115,8 +167,15 @@ class TriageEnv(gym.Env):
|
|
| 115 |
- Generate ground truth (disease + persona traits).
|
| 116 |
- Initialize Nurse/Patient LLMs with system prompts.
|
| 117 |
- Return the Doctor's initial observation (only sees Nurse experience).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
"""
|
| 119 |
super().reset(seed=seed)
|
|
|
|
|
|
|
| 120 |
|
| 121 |
# 1. Generate ground truth with phase-aware constraints
|
| 122 |
self.phase = (options or {}).get("phase", 1)
|
|
@@ -140,6 +199,24 @@ class TriageEnv(gym.Env):
|
|
| 140 |
self.episode_log = []
|
| 141 |
self.last_patient_status = "CONTINUE"
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
# 4. Initialize SOAP EMR and pre-populate with patient history
|
| 144 |
self.emr = self._create_empty_emr()
|
| 145 |
self._populate_emr_from_history()
|
|
@@ -174,33 +251,69 @@ class TriageEnv(gym.Env):
|
|
| 174 |
# step()
|
| 175 |
# ==================================================================
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
def step(self, action: str) -> Tuple[str, float, bool, bool, Dict[str, Any]]:
|
| 178 |
"""
|
| 179 |
Process one Doctor action, run internal Nurse/Patient exchange loop,
|
| 180 |
compute dense reward, return next observation.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
"""
|
| 182 |
reward = 0.0
|
| 183 |
self.step_count += 1
|
| 184 |
truncated = False
|
| 185 |
info: Dict[str, Any] = {}
|
| 186 |
|
| 187 |
-
# --- Turn penalty
|
|
|
|
| 188 |
is_emergency = self.ground_truth.get("disease", {}).get("is_emergency", False)
|
| 189 |
if is_emergency:
|
| 190 |
-
reward += -0.
|
| 191 |
else:
|
| 192 |
-
reward += -0.01
|
| 193 |
|
| 194 |
# --- Parse Doctor's JSON action ---
|
| 195 |
doctor_action = self._parse_doctor_action(action)
|
| 196 |
if doctor_action is None:
|
| 197 |
-
#
|
| 198 |
-
reward += -0.20
|
| 199 |
obs = json.dumps({
|
| 200 |
"event": "system_error",
|
| 201 |
"message": "Your last action was not valid JSON. Please respond with a properly formatted JSON action.",
|
| 202 |
})
|
| 203 |
self.episode_log.append({"role": "system", "content": "Doctor sent invalid JSON"})
|
|
|
|
| 204 |
return obs, reward, self.done, self._check_truncated(), info
|
| 205 |
|
| 206 |
tool = doctor_action.get("tool", "")
|
|
@@ -208,15 +321,16 @@ class TriageEnv(gym.Env):
|
|
| 208 |
|
| 209 |
# --- Hallucinated tool check ---
|
| 210 |
if tool not in DOCTOR_TOOLS:
|
| 211 |
-
reward += -0.20
|
| 212 |
obs = json.dumps({
|
| 213 |
"event": "system_error",
|
| 214 |
"message": f"Unknown tool '{tool}'. Valid tools: speak_to, order_lab, terminal_discharge.",
|
| 215 |
})
|
|
|
|
| 216 |
return obs, reward, self.done, self._check_truncated(), info
|
| 217 |
|
| 218 |
# --- Valid JSON bonus ---
|
| 219 |
-
reward += 0.05
|
| 220 |
|
| 221 |
self.episode_log.append({"role": "doctor", "action": doctor_action})
|
| 222 |
|
|
@@ -227,27 +341,32 @@ class TriageEnv(gym.Env):
|
|
| 227 |
if tool == "speak_to":
|
| 228 |
obs, step_reward = self._handle_speak_to(doctor_action, target)
|
| 229 |
reward += step_reward
|
| 230 |
-
# Intent-based empathy detection (TTS/reward only, not fed to agents)
|
| 231 |
message = doctor_action.get("message", "")
|
| 232 |
if message and self.patient_state:
|
| 233 |
-
intent =
|
| 234 |
self.patient_state.update(intent)
|
| 235 |
-
|
| 236 |
-
|
| 237 |
if target == "patient" and self.milestone_tracker:
|
| 238 |
-
reward += self.
|
|
|
|
|
|
|
| 239 |
|
| 240 |
elif tool == "order_lab":
|
| 241 |
obs, step_reward = self._handle_order_lab(doctor_action)
|
| 242 |
reward += step_reward
|
| 243 |
if self.milestone_tracker:
|
| 244 |
-
reward += self.
|
|
|
|
|
|
|
| 245 |
|
| 246 |
elif tool == "read_soap":
|
| 247 |
obs, step_reward = self._handle_read_soap(doctor_action)
|
| 248 |
reward += step_reward
|
| 249 |
if self.milestone_tracker:
|
| 250 |
-
reward += self.
|
|
|
|
|
|
|
| 251 |
|
| 252 |
elif tool == "update_soap":
|
| 253 |
obs, step_reward = self._handle_update_soap(doctor_action)
|
|
@@ -257,7 +376,9 @@ class TriageEnv(gym.Env):
|
|
| 257 |
obs, step_reward = self._handle_terminal_discharge(doctor_action)
|
| 258 |
reward += step_reward
|
| 259 |
if self.milestone_tracker:
|
| 260 |
-
reward += self.
|
|
|
|
|
|
|
| 261 |
|
| 262 |
else:
|
| 263 |
obs = json.dumps({"event": "system_error", "message": "Unhandled tool."})
|
|
@@ -265,11 +386,12 @@ class TriageEnv(gym.Env):
|
|
| 265 |
# Check for truncation (max steps)
|
| 266 |
truncated = self._check_truncated()
|
| 267 |
if truncated and not self.done:
|
| 268 |
-
reward += -0.50
|
| 269 |
info["truncation_reason"] = "max_episode_steps_reached"
|
| 270 |
|
| 271 |
info["step_count"] = self.step_count
|
| 272 |
-
info["
|
|
|
|
| 273 |
info["consent_given"] = self.consent_given
|
| 274 |
info["patient_status"] = self.last_patient_status
|
| 275 |
if self.patient_state:
|
|
@@ -335,7 +457,7 @@ class TriageEnv(gym.Env):
|
|
| 335 |
|
| 336 |
# Check for de-escalation success
|
| 337 |
if self.last_patient_status == "AGREE":
|
| 338 |
-
reward += 0.
|
| 339 |
self.consent_given = True
|
| 340 |
|
| 341 |
# Check for AMA LOSS
|
|
@@ -343,7 +465,7 @@ class TriageEnv(gym.Env):
|
|
| 343 |
self.last_patient_status == "LEAVE"
|
| 344 |
or patient_response.get("tool") == "leave_hospital"
|
| 345 |
):
|
| 346 |
-
reward += -0.
|
| 347 |
self.done = True
|
| 348 |
obs = json.dumps({
|
| 349 |
"event": "terminal_ama",
|
|
@@ -367,7 +489,17 @@ class TriageEnv(gym.Env):
|
|
| 367 |
return obs, reward
|
| 368 |
|
| 369 |
def _handle_order_lab(self, doctor_action: Dict[str, Any]) -> Tuple[str, float]:
|
| 370 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
reward = 0.0
|
| 372 |
test_name = doctor_action.get("test_name", "").strip().lower()
|
| 373 |
|
|
@@ -380,7 +512,7 @@ class TriageEnv(gym.Env):
|
|
| 380 |
|
| 381 |
# Redundancy check
|
| 382 |
if test_name in self.ordered_labs:
|
| 383 |
-
reward += -0.
|
| 384 |
obs = json.dumps({
|
| 385 |
"event": "lab_result",
|
| 386 |
"test_name": test_name,
|
|
@@ -394,23 +526,37 @@ class TriageEnv(gym.Env):
|
|
| 394 |
# Look up lab results from the ground truth disease
|
| 395 |
disease_name = self.ground_truth["disease"]["true_disease"]
|
| 396 |
disease_labs = LAB_RESULTS_DB.get(disease_name, {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
|
| 398 |
-
# Fuzzy match: check if test_name substring-matches any key
|
| 399 |
result_text = None
|
| 400 |
for lab_key, lab_value in disease_labs.items():
|
| 401 |
if test_name in lab_key.lower() or lab_key.lower() in test_name:
|
| 402 |
result_text = lab_value
|
| 403 |
break
|
| 404 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
if result_text:
|
| 406 |
-
reward += 0.10 # Successful actionable data extraction
|
| 407 |
obs = json.dumps({
|
| 408 |
"event": "lab_result",
|
| 409 |
"test_name": test_name,
|
| 410 |
"result": result_text,
|
| 411 |
"redundant": False,
|
|
|
|
| 412 |
})
|
| 413 |
-
# Auto-update SOAP Objective with lab result
|
| 414 |
self._emr_append("Objective", "Labs", f"[{test_name.upper()}] {result_text}")
|
| 415 |
else:
|
| 416 |
result_normal = f"Lab '{test_name}' results: within normal limits. No significant findings."
|
|
@@ -419,6 +565,7 @@ class TriageEnv(gym.Env):
|
|
| 419 |
"test_name": test_name,
|
| 420 |
"result": result_normal,
|
| 421 |
"redundant": False,
|
|
|
|
| 422 |
})
|
| 423 |
self._emr_append("Objective", "Labs", f"[{test_name.upper()}] {result_normal}")
|
| 424 |
|
|
@@ -456,7 +603,23 @@ class TriageEnv(gym.Env):
|
|
| 456 |
) -> Tuple[str, float]:
|
| 457 |
"""
|
| 458 |
Handle Doctor using 'update_soap' tool.
|
| 459 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
"""
|
| 461 |
reward = 0.0
|
| 462 |
section = doctor_action.get("section", "").strip()
|
|
@@ -472,20 +635,23 @@ class TriageEnv(gym.Env):
|
|
| 472 |
# Parse dotted notation (e.g., "Subjective.HPI")
|
| 473 |
parts = section.split(".")
|
| 474 |
updated = False
|
|
|
|
| 475 |
|
| 476 |
if len(parts) == 1 and parts[0] in ("Assessment", "Plan"):
|
| 477 |
-
# Direct top-level section update
|
| 478 |
self.emr[parts[0]] = content
|
| 479 |
updated = True
|
| 480 |
-
|
|
|
|
| 481 |
elif len(parts) == 2 and parts[0] == "Subjective" and parts[1] in self.emr.get("Subjective", {}):
|
| 482 |
self.emr["Subjective"][parts[1]] = content
|
| 483 |
updated = True
|
| 484 |
-
|
|
|
|
| 485 |
elif len(parts) == 2 and parts[0] == "Objective" and parts[1] in self.emr.get("Objective", {}):
|
| 486 |
self.emr["Objective"][parts[1]] = content
|
| 487 |
updated = True
|
| 488 |
-
|
|
|
|
| 489 |
else:
|
| 490 |
obs = json.dumps({
|
| 491 |
"event": "system_error",
|
|
@@ -493,12 +659,49 @@ class TriageEnv(gym.Env):
|
|
| 493 |
})
|
| 494 |
return obs, reward
|
| 495 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
if updated:
|
| 497 |
obs = json.dumps({
|
| 498 |
"event": "soap_updated",
|
| 499 |
"section": section,
|
| 500 |
"message": f"SOAP note '{section}' updated successfully.",
|
| 501 |
"soap_summary": self._get_soap_summary(),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
})
|
| 503 |
self.episode_log.append({"role": "doctor", "content": f"Updated SOAP {section}: {content[:100]}"})
|
| 504 |
|
|
@@ -507,55 +710,68 @@ class TriageEnv(gym.Env):
|
|
| 507 |
def _handle_terminal_discharge(
|
| 508 |
self, doctor_action: Dict[str, Any]
|
| 509 |
) -> Tuple[str, float]:
|
| 510 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
reward = 0.0
|
| 512 |
treatment = doctor_action.get("treatment", "").strip().lower()
|
| 513 |
declared_emergency = bool(doctor_action.get("is_emergency", False))
|
| 514 |
is_actual_emergency = self.ground_truth.get("disease", {}).get("is_emergency", False)
|
| 515 |
self.done = True
|
| 516 |
|
| 517 |
-
# --- Emergency Identification
|
| 518 |
if declared_emergency and is_actual_emergency:
|
| 519 |
-
reward += 0.
|
| 520 |
-
logger.info("Emergency correctly identified (+0.50).")
|
| 521 |
elif not declared_emergency and not is_actual_emergency:
|
| 522 |
-
reward += 0.
|
| 523 |
-
logger.info("Non-emergency correctly identified (+0.10).")
|
| 524 |
elif declared_emergency and not is_actual_emergency:
|
| 525 |
-
reward += -0.
|
| 526 |
-
logger.info("False positive emergency identification (-0.30).")
|
| 527 |
elif not declared_emergency and is_actual_emergency:
|
| 528 |
-
reward += -0.
|
| 529 |
-
logger.info("Failed to identify true emergency (-0.50).")
|
| 530 |
|
| 531 |
-
# --- SOAP
|
| 532 |
assessment = self.emr.get("Assessment", "").strip()
|
| 533 |
if assessment:
|
| 534 |
-
reward += 0.20
|
| 535 |
-
logger.info("SOAP bonus: Assessment documented before discharge.")
|
| 536 |
else:
|
| 537 |
-
reward += -0.
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
|
|
|
| 548 |
if not is_emergency:
|
| 549 |
min_steps_map = {1: 4, 2: 6, 3: 8}
|
| 550 |
min_steps = min_steps_map.get(self.phase, 5)
|
| 551 |
if self.step_count < min_steps:
|
| 552 |
shortfall = min_steps - self.step_count
|
| 553 |
-
|
| 554 |
-
reward += penalty
|
| 555 |
-
logger.info(
|
| 556 |
-
f"Early discharge penalty: step {self.step_count} < min {min_steps} "
|
| 557 |
-
f"(phase {self.phase}), penalty={penalty:.2f}"
|
| 558 |
-
)
|
| 559 |
|
| 560 |
# Auto-record the treatment in SOAP Plan
|
| 561 |
self.emr["Plan"] = treatment
|
|
@@ -566,8 +782,16 @@ class TriageEnv(gym.Env):
|
|
| 566 |
]
|
| 567 |
disease_name = self.ground_truth["disease"]["true_disease"]
|
| 568 |
|
| 569 |
-
# ---
|
| 570 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
judge_result = None
|
| 572 |
if self.router:
|
| 573 |
try:
|
|
@@ -577,123 +801,79 @@ class TriageEnv(gym.Env):
|
|
| 577 |
lethal_treatments=self.ground_truth["disease"]["lethal_treatments"],
|
| 578 |
disease_name=disease_name,
|
| 579 |
)
|
| 580 |
-
logger.info(f"LLM Judge result: {judge_result}")
|
| 581 |
except Exception as e:
|
| 582 |
-
logger.error(f"LLM Judge failed
|
|
|
|
| 583 |
|
|
|
|
|
|
|
|
|
|
| 584 |
if judge_result and judge_result.get("score") is not None:
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
"event": "terminal_win",
|
| 605 |
-
"message": f"Correct diagnosis and treatment! {reasoning}",
|
| 606 |
-
"ground_truth": disease_name,
|
| 607 |
-
"prescribed_treatment": treatment,
|
| 608 |
-
"judge_score": score,
|
| 609 |
-
"judge_reasoning": reasoning,
|
| 610 |
-
"soap_note": self.emr,
|
| 611 |
-
})
|
| 612 |
-
elif score >= 0.30:
|
| 613 |
-
partial_reward = -0.40 + (score * 1.4) # scales from 0.02 to 0.65
|
| 614 |
-
reward += partial_reward
|
| 615 |
-
obs = json.dumps({
|
| 616 |
-
"event": "terminal_partial",
|
| 617 |
-
"message": f"Partially correct treatment (Judge: {score:.0%}). {reasoning}",
|
| 618 |
-
"ground_truth": disease_name,
|
| 619 |
-
"correct_treatment": self.ground_truth["disease"]["correct_treatment"],
|
| 620 |
-
"prescribed_treatment": treatment,
|
| 621 |
-
"judge_score": score,
|
| 622 |
-
"judge_reasoning": reasoning,
|
| 623 |
-
"soap_note": self.emr,
|
| 624 |
-
})
|
| 625 |
-
else:
|
| 626 |
-
reward += -1.00
|
| 627 |
-
obs = json.dumps({
|
| 628 |
-
"event": "terminal_incorrect",
|
| 629 |
-
"message": f"Incorrect treatment (Judge: {score:.0%}). {reasoning}",
|
| 630 |
-
"ground_truth": disease_name,
|
| 631 |
-
"correct_treatment": self.ground_truth["disease"]["correct_treatment"],
|
| 632 |
-
"prescribed_treatment": treatment,
|
| 633 |
-
"judge_score": score,
|
| 634 |
-
"judge_reasoning": reasoning,
|
| 635 |
-
"soap_note": self.emr,
|
| 636 |
-
})
|
| 637 |
else:
|
| 638 |
-
#
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
"matched_keywords": sorted(overlap),
|
| 684 |
-
"soap_note": self.emr,
|
| 685 |
-
})
|
| 686 |
-
else:
|
| 687 |
-
reward += -1.00
|
| 688 |
-
obs = json.dumps({
|
| 689 |
-
"event": "terminal_incorrect",
|
| 690 |
-
"message": "Incorrect treatment. Patient outcome: adverse.",
|
| 691 |
-
"ground_truth": disease_name,
|
| 692 |
-
"correct_treatment": self.ground_truth["disease"]["correct_treatment"],
|
| 693 |
-
"prescribed_treatment": treatment,
|
| 694 |
-
"match_ratio": round(overlap_ratio, 2),
|
| 695 |
-
"soap_note": self.emr,
|
| 696 |
-
})
|
| 697 |
|
| 698 |
return obs, reward
|
| 699 |
|
|
|
|
| 8 |
|
| 9 |
import json
|
| 10 |
import logging
|
| 11 |
+
import random
|
| 12 |
import re
|
| 13 |
from typing import Any, Dict, Optional, Tuple
|
| 14 |
|
|
|
|
| 23 |
SOAP_HISTORY_DB,
|
| 24 |
)
|
| 25 |
from .empathy_engine import (
|
|
|
|
| 26 |
compute_empathy_reward,
|
| 27 |
PatientState,
|
| 28 |
MilestoneTracker,
|
|
|
|
| 44 |
MAX_INTERNAL_EXCHANGES = 3 # per Doctor step, Nurse β Patient loop cap
|
| 45 |
MAX_EPISODE_STEPS = 30 # total Doctor turns before truncation
|
| 46 |
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
# Per-episode reward caps (anti-farming; keeps process signal balanced
|
| 49 |
+
# against terminal signal so the model cannot learn to spam empathy or
|
| 50 |
+
# milestones to mask a wrong diagnosis).
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
EMPATHY_REWARD_CAP_POS = 0.30 # max positive empathy reward per episode
|
| 53 |
+
EMPATHY_REWARD_CAP_NEG = -0.40 # max negative empathy reward per episode
|
| 54 |
+
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
# Stop-words used by the cheap process-level keyword verifier (intermediate
|
| 57 |
+
# diagnosis bonus + terminal keyword overlap).
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
_VERIFIER_STOP_WORDS = {
|
| 60 |
+
"for", "if", "or", "and", "with", "to", "of", "the", "a", "an", "in",
|
| 61 |
+
"on", "at", "by", "from", "unable", "po", "signs", "is", "are", "then",
|
| 62 |
+
"above", "below", "due", "via", "per", "as", "be", "no", "not", "any",
|
| 63 |
+
"some", "this", "that", "these", "those", "patient", "patients",
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _keyword_set(text: str) -> set:
|
| 68 |
+
"""Lowercase tokenize and drop stop words."""
|
| 69 |
+
if not text:
|
| 70 |
+
return set()
|
| 71 |
+
tokens = re.findall(r"[a-zA-Z]{3,}", text.lower())
|
| 72 |
+
return {t for t in tokens if t not in _VERIFIER_STOP_WORDS}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _fuzzy_overlap(target_kw: set, candidate_kw: set) -> float:
|
| 76 |
+
"""
|
| 77 |
+
Substring-aware overlap ratio in [0, 1].
|
| 78 |
+
Uses both exact and substring matches (>=4 chars) so that
|
| 79 |
+
'thrombolytic' counts against 'thrombolytics'.
|
| 80 |
+
"""
|
| 81 |
+
if not target_kw:
|
| 82 |
+
return 0.0
|
| 83 |
+
matched = set()
|
| 84 |
+
for t in target_kw:
|
| 85 |
+
if t in candidate_kw:
|
| 86 |
+
matched.add(t)
|
| 87 |
+
continue
|
| 88 |
+
for c in candidate_kw:
|
| 89 |
+
if len(t) >= 4 and (t in c or c in t):
|
| 90 |
+
matched.add(t)
|
| 91 |
+
break
|
| 92 |
+
return len(matched) / max(len(target_kw), 1)
|
| 93 |
+
|
| 94 |
|
| 95 |
class TriageEnv(gym.Env):
|
| 96 |
"""
|
|
|
|
| 150 |
self.patient_state: Optional[PatientState] = None
|
| 151 |
self.milestone_tracker: Optional[MilestoneTracker] = None
|
| 152 |
|
| 153 |
+
# Per-episode reward bookkeeping (filled in reset())
|
| 154 |
+
self.reward_components: Dict[str, float] = {}
|
| 155 |
+
self.intermediate_diagnosis_awarded: bool = False
|
| 156 |
+
self.intermediate_plan_awarded: bool = False
|
| 157 |
+
|
| 158 |
# ==================================================================
|
| 159 |
# reset()
|
| 160 |
# ==================================================================
|
|
|
|
| 167 |
- Generate ground truth (disease + persona traits).
|
| 168 |
- Initialize Nurse/Patient LLMs with system prompts.
|
| 169 |
- Return the Doctor's initial observation (only sees Nurse experience).
|
| 170 |
+
|
| 171 |
+
If `seed` is provided, the global `random` module is also seeded so
|
| 172 |
+
that scenario generation (which uses `random.choice`) is deterministic.
|
| 173 |
+
This is required for GRPO group rollouts where G completions must
|
| 174 |
+
share the same scenario.
|
| 175 |
"""
|
| 176 |
super().reset(seed=seed)
|
| 177 |
+
if seed is not None:
|
| 178 |
+
random.seed(seed)
|
| 179 |
|
| 180 |
# 1. Generate ground truth with phase-aware constraints
|
| 181 |
self.phase = (options or {}).get("phase", 1)
|
|
|
|
| 199 |
self.episode_log = []
|
| 200 |
self.last_patient_status = "CONTINUE"
|
| 201 |
|
| 202 |
+
# 3a. Reset per-component reward tracker (used for logging,
|
| 203 |
+
# anti-farming caps, and GRPO advantage diagnostics).
|
| 204 |
+
self.reward_components = {
|
| 205 |
+
"process": 0.0, # JSON validity, tool legality, basic ops
|
| 206 |
+
"milestones": 0.0, # ordered clinical workflow
|
| 207 |
+
"labs": 0.0, # lab ordering (critical vs generic)
|
| 208 |
+
"empathy": 0.0, # bounded by EMPATHY_REWARD_CAP_*
|
| 209 |
+
"consent": 0.0, # AGREE / AMA outcomes
|
| 210 |
+
"diagnosis": 0.0, # intermediate Assessment keyword bonus
|
| 211 |
+
"plan": 0.0, # intermediate Plan keyword bonus
|
| 212 |
+
"documentation": 0.0, # SOAP filled vs empty at discharge
|
| 213 |
+
"emergency_id": 0.0, # is_emergency correctly classified
|
| 214 |
+
"treatment": 0.0, # terminal judge + keyword combined
|
| 215 |
+
"penalties": 0.0, # turn cost, redundancy, early discharge
|
| 216 |
+
}
|
| 217 |
+
self.intermediate_diagnosis_awarded = False
|
| 218 |
+
self.intermediate_plan_awarded = False
|
| 219 |
+
|
| 220 |
# 4. Initialize SOAP EMR and pre-populate with patient history
|
| 221 |
self.emr = self._create_empty_emr()
|
| 222 |
self._populate_emr_from_history()
|
|
|
|
| 251 |
# step()
|
| 252 |
# ==================================================================
|
| 253 |
|
| 254 |
+
def _add(self, component: str, value: float) -> float:
|
| 255 |
+
"""Accumulate reward into a tracked component bucket."""
|
| 256 |
+
self.reward_components[component] = (
|
| 257 |
+
self.reward_components.get(component, 0.0) + value
|
| 258 |
+
)
|
| 259 |
+
return value
|
| 260 |
+
|
| 261 |
+
def _add_empathy(self, value: float) -> float:
|
| 262 |
+
"""
|
| 263 |
+
Accumulate empathy reward with a per-episode cap on both ends to
|
| 264 |
+
block reward farming (e.g. spam-empathy-then-misdiagnose).
|
| 265 |
+
Returns the actually applied value (may be 0 if cap reached).
|
| 266 |
+
"""
|
| 267 |
+
current = self.reward_components.get("empathy", 0.0)
|
| 268 |
+
if value > 0 and current >= EMPATHY_REWARD_CAP_POS:
|
| 269 |
+
return 0.0
|
| 270 |
+
if value < 0 and current <= EMPATHY_REWARD_CAP_NEG:
|
| 271 |
+
return 0.0
|
| 272 |
+
# Clip the increment so we never overshoot the cap
|
| 273 |
+
if value > 0:
|
| 274 |
+
value = min(value, EMPATHY_REWARD_CAP_POS - current)
|
| 275 |
+
else:
|
| 276 |
+
value = max(value, EMPATHY_REWARD_CAP_NEG - current)
|
| 277 |
+
self.reward_components["empathy"] = current + value
|
| 278 |
+
return value
|
| 279 |
+
|
| 280 |
def step(self, action: str) -> Tuple[str, float, bool, bool, Dict[str, Any]]:
|
| 281 |
"""
|
| 282 |
Process one Doctor action, run internal Nurse/Patient exchange loop,
|
| 283 |
compute dense reward, return next observation.
|
| 284 |
+
|
| 285 |
+
Reward design (rebalanced for process > terminal):
|
| 286 |
+
process (per step): ~ +0.05 valid JSON, +0.07 milestones, +0.20 critical lab,
|
| 287 |
+
+0.08 update_soap, +0.20-0.30 intermediate diagnosis
|
| 288 |
+
terminal (one-shot): smoothed treatment judge in [-0.30, +0.50],
|
| 289 |
+
emergency id Β±0.30, SOAP doc Β±0.20
|
| 290 |
+
empathy (per step): capped at +0.30 / -0.40 across the full episode
|
| 291 |
+
|
| 292 |
+
Total max process β +1.5, total max terminal β +1.0 β process-dominant.
|
| 293 |
"""
|
| 294 |
reward = 0.0
|
| 295 |
self.step_count += 1
|
| 296 |
truncated = False
|
| 297 |
info: Dict[str, Any] = {}
|
| 298 |
|
| 299 |
+
# --- Turn penalty (softened so that early decisive actions are not
|
| 300 |
+
# wiped out by accumulated turn cost in long episodes). ---
|
| 301 |
is_emergency = self.ground_truth.get("disease", {}).get("is_emergency", False)
|
| 302 |
if is_emergency:
|
| 303 |
+
reward += self._add("penalties", -0.10) # was -0.15
|
| 304 |
else:
|
| 305 |
+
reward += self._add("penalties", -0.01)
|
| 306 |
|
| 307 |
# --- Parse Doctor's JSON action ---
|
| 308 |
doctor_action = self._parse_doctor_action(action)
|
| 309 |
if doctor_action is None:
|
| 310 |
+
reward += self._add("penalties", -0.15) # was -0.20
|
|
|
|
| 311 |
obs = json.dumps({
|
| 312 |
"event": "system_error",
|
| 313 |
"message": "Your last action was not valid JSON. Please respond with a properly formatted JSON action.",
|
| 314 |
})
|
| 315 |
self.episode_log.append({"role": "system", "content": "Doctor sent invalid JSON"})
|
| 316 |
+
info["reward_components"] = dict(self.reward_components)
|
| 317 |
return obs, reward, self.done, self._check_truncated(), info
|
| 318 |
|
| 319 |
tool = doctor_action.get("tool", "")
|
|
|
|
| 321 |
|
| 322 |
# --- Hallucinated tool check ---
|
| 323 |
if tool not in DOCTOR_TOOLS:
|
| 324 |
+
reward += self._add("penalties", -0.15) # was -0.20
|
| 325 |
obs = json.dumps({
|
| 326 |
"event": "system_error",
|
| 327 |
"message": f"Unknown tool '{tool}'. Valid tools: speak_to, order_lab, terminal_discharge.",
|
| 328 |
})
|
| 329 |
+
info["reward_components"] = dict(self.reward_components)
|
| 330 |
return obs, reward, self.done, self._check_truncated(), info
|
| 331 |
|
| 332 |
# --- Valid JSON bonus ---
|
| 333 |
+
reward += self._add("process", 0.05)
|
| 334 |
|
| 335 |
self.episode_log.append({"role": "doctor", "action": doctor_action})
|
| 336 |
|
|
|
|
| 341 |
if tool == "speak_to":
|
| 342 |
obs, step_reward = self._handle_speak_to(doctor_action, target)
|
| 343 |
reward += step_reward
|
|
|
|
| 344 |
message = doctor_action.get("message", "")
|
| 345 |
if message and self.patient_state:
|
| 346 |
+
intent = self.router.evaluate_empathy(message)
|
| 347 |
self.patient_state.update(intent)
|
| 348 |
+
emp_r = compute_empathy_reward(intent, self.patient_state, self.phase)
|
| 349 |
+
reward += self._add_empathy(emp_r)
|
| 350 |
if target == "patient" and self.milestone_tracker:
|
| 351 |
+
reward += self._add(
|
| 352 |
+
"milestones", self.milestone_tracker.mark("PATIENT_CONTACT")
|
| 353 |
+
)
|
| 354 |
|
| 355 |
elif tool == "order_lab":
|
| 356 |
obs, step_reward = self._handle_order_lab(doctor_action)
|
| 357 |
reward += step_reward
|
| 358 |
if self.milestone_tracker:
|
| 359 |
+
reward += self._add(
|
| 360 |
+
"milestones", self.milestone_tracker.mark("LABS")
|
| 361 |
+
)
|
| 362 |
|
| 363 |
elif tool == "read_soap":
|
| 364 |
obs, step_reward = self._handle_read_soap(doctor_action)
|
| 365 |
reward += step_reward
|
| 366 |
if self.milestone_tracker:
|
| 367 |
+
reward += self._add(
|
| 368 |
+
"milestones", self.milestone_tracker.mark("READ_SOAP")
|
| 369 |
+
)
|
| 370 |
|
| 371 |
elif tool == "update_soap":
|
| 372 |
obs, step_reward = self._handle_update_soap(doctor_action)
|
|
|
|
| 376 |
obs, step_reward = self._handle_terminal_discharge(doctor_action)
|
| 377 |
reward += step_reward
|
| 378 |
if self.milestone_tracker:
|
| 379 |
+
reward += self._add(
|
| 380 |
+
"milestones", self.milestone_tracker.mark("DISCHARGE")
|
| 381 |
+
)
|
| 382 |
|
| 383 |
else:
|
| 384 |
obs = json.dumps({"event": "system_error", "message": "Unhandled tool."})
|
|
|
|
| 386 |
# Check for truncation (max steps)
|
| 387 |
truncated = self._check_truncated()
|
| 388 |
if truncated and not self.done:
|
| 389 |
+
reward += self._add("penalties", -0.30) # was -0.50
|
| 390 |
info["truncation_reason"] = "max_episode_steps_reached"
|
| 391 |
|
| 392 |
info["step_count"] = self.step_count
|
| 393 |
+
info["step_reward"] = round(reward, 4)
|
| 394 |
+
info["reward_components"] = dict(self.reward_components)
|
| 395 |
info["consent_given"] = self.consent_given
|
| 396 |
info["patient_status"] = self.last_patient_status
|
| 397 |
if self.patient_state:
|
|
|
|
| 457 |
|
| 458 |
# Check for de-escalation success
|
| 459 |
if self.last_patient_status == "AGREE":
|
| 460 |
+
reward += self._add("consent", 0.25) # was 0.30
|
| 461 |
self.consent_given = True
|
| 462 |
|
| 463 |
# Check for AMA LOSS
|
|
|
|
| 465 |
self.last_patient_status == "LEAVE"
|
| 466 |
or patient_response.get("tool") == "leave_hospital"
|
| 467 |
):
|
| 468 |
+
reward += self._add("consent", -0.50) # was -0.75
|
| 469 |
self.done = True
|
| 470 |
obs = json.dumps({
|
| 471 |
"event": "terminal_ama",
|
|
|
|
| 489 |
return obs, reward
|
| 490 |
|
| 491 |
def _handle_order_lab(self, doctor_action: Dict[str, Any]) -> Tuple[str, float]:
|
| 492 |
+
"""
|
| 493 |
+
Handle Doctor using 'order_lab' tool.
|
| 494 |
+
|
| 495 |
+
Reward design:
|
| 496 |
+
redundant order: -0.20 (was -0.25)
|
| 497 |
+
critical lab (in disease.critical_labs): +0.20
|
| 498 |
+
generic informative lab (matches disease lab DB): +0.08
|
| 499 |
+
non-informative lab (no match): +0.02
|
| 500 |
+
Critical-lab bonus is the key process-distributed signal of correct
|
| 501 |
+
differential reasoning β without it, lab ordering felt random.
|
| 502 |
+
"""
|
| 503 |
reward = 0.0
|
| 504 |
test_name = doctor_action.get("test_name", "").strip().lower()
|
| 505 |
|
|
|
|
| 512 |
|
| 513 |
# Redundancy check
|
| 514 |
if test_name in self.ordered_labs:
|
| 515 |
+
reward += self._add("penalties", -0.20)
|
| 516 |
obs = json.dumps({
|
| 517 |
"event": "lab_result",
|
| 518 |
"test_name": test_name,
|
|
|
|
| 526 |
# Look up lab results from the ground truth disease
|
| 527 |
disease_name = self.ground_truth["disease"]["true_disease"]
|
| 528 |
disease_labs = LAB_RESULTS_DB.get(disease_name, {})
|
| 529 |
+
critical_labs = [
|
| 530 |
+
c.lower() for c in self.ground_truth["disease"].get("critical_labs", [])
|
| 531 |
+
]
|
| 532 |
+
|
| 533 |
+
# Is this a critical (diagnosis-clinching) lab?
|
| 534 |
+
is_critical = any(
|
| 535 |
+
test_name in c or c in test_name for c in critical_labs if len(c) >= 3
|
| 536 |
+
)
|
| 537 |
|
| 538 |
+
# Fuzzy match: check if test_name substring-matches any key in the lab DB
|
| 539 |
result_text = None
|
| 540 |
for lab_key, lab_value in disease_labs.items():
|
| 541 |
if test_name in lab_key.lower() or lab_key.lower() in test_name:
|
| 542 |
result_text = lab_value
|
| 543 |
break
|
| 544 |
|
| 545 |
+
if is_critical:
|
| 546 |
+
reward += self._add("labs", 0.20)
|
| 547 |
+
elif result_text:
|
| 548 |
+
reward += self._add("labs", 0.08)
|
| 549 |
+
else:
|
| 550 |
+
reward += self._add("labs", 0.02)
|
| 551 |
+
|
| 552 |
if result_text:
|
|
|
|
| 553 |
obs = json.dumps({
|
| 554 |
"event": "lab_result",
|
| 555 |
"test_name": test_name,
|
| 556 |
"result": result_text,
|
| 557 |
"redundant": False,
|
| 558 |
+
"is_critical_lab": is_critical,
|
| 559 |
})
|
|
|
|
| 560 |
self._emr_append("Objective", "Labs", f"[{test_name.upper()}] {result_text}")
|
| 561 |
else:
|
| 562 |
result_normal = f"Lab '{test_name}' results: within normal limits. No significant findings."
|
|
|
|
| 565 |
"test_name": test_name,
|
| 566 |
"result": result_normal,
|
| 567 |
"redundant": False,
|
| 568 |
+
"is_critical_lab": is_critical,
|
| 569 |
})
|
| 570 |
self._emr_append("Objective", "Labs", f"[{test_name.upper()}] {result_normal}")
|
| 571 |
|
|
|
|
| 603 |
) -> Tuple[str, float]:
|
| 604 |
"""
|
| 605 |
Handle Doctor using 'update_soap' tool.
|
| 606 |
+
|
| 607 |
+
Reward design (KEY rebalance):
|
| 608 |
+
- Base documentation reward: +0.08 (was +0.05).
|
| 609 |
+
- Intermediate diagnosis bonus (one-shot per episode):
|
| 610 |
+
+0.20 if the Assessment text fuzzy-overlaps the true_disease
|
| 611 |
+
name and/or true_symptoms by >= 30%
|
| 612 |
+
+0.30 if overlap >= 60% (strong, clinically reasoned)
|
| 613 |
+
- Intermediate plan bonus (one-shot per episode):
|
| 614 |
+
+0.15 if the Plan text fuzzy-overlaps the correct_treatment
|
| 615 |
+
keyword set by >= 30%
|
| 616 |
+
+0.25 if overlap >= 60%
|
| 617 |
+
|
| 618 |
+
These bonuses pull the diagnosis-correctness signal *out of the
|
| 619 |
+
terminal step* and distribute it across the trajectory, which is
|
| 620 |
+
critical for stable GRPO learning on long horizons. They are
|
| 621 |
+
one-shot per episode so they cannot be farmed by repeatedly
|
| 622 |
+
re-writing the same Assessment.
|
| 623 |
"""
|
| 624 |
reward = 0.0
|
| 625 |
section = doctor_action.get("section", "").strip()
|
|
|
|
| 635 |
# Parse dotted notation (e.g., "Subjective.HPI")
|
| 636 |
parts = section.split(".")
|
| 637 |
updated = False
|
| 638 |
+
section_kind = None # "Assessment" | "Plan" | "Subjective" | "Objective"
|
| 639 |
|
| 640 |
if len(parts) == 1 and parts[0] in ("Assessment", "Plan"):
|
|
|
|
| 641 |
self.emr[parts[0]] = content
|
| 642 |
updated = True
|
| 643 |
+
section_kind = parts[0]
|
| 644 |
+
reward += self._add("documentation", 0.08)
|
| 645 |
elif len(parts) == 2 and parts[0] == "Subjective" and parts[1] in self.emr.get("Subjective", {}):
|
| 646 |
self.emr["Subjective"][parts[1]] = content
|
| 647 |
updated = True
|
| 648 |
+
section_kind = "Subjective"
|
| 649 |
+
reward += self._add("documentation", 0.08)
|
| 650 |
elif len(parts) == 2 and parts[0] == "Objective" and parts[1] in self.emr.get("Objective", {}):
|
| 651 |
self.emr["Objective"][parts[1]] = content
|
| 652 |
updated = True
|
| 653 |
+
section_kind = "Objective"
|
| 654 |
+
reward += self._add("documentation", 0.08)
|
| 655 |
else:
|
| 656 |
obs = json.dumps({
|
| 657 |
"event": "system_error",
|
|
|
|
| 659 |
})
|
| 660 |
return obs, reward
|
| 661 |
|
| 662 |
+
# ----- Intermediate diagnosis bonus (Assessment) -----
|
| 663 |
+
diag_overlap = 0.0
|
| 664 |
+
plan_overlap = 0.0
|
| 665 |
+
if section_kind == "Assessment" and not self.intermediate_diagnosis_awarded:
|
| 666 |
+
disease = self.ground_truth.get("disease", {})
|
| 667 |
+
target_text = (
|
| 668 |
+
disease.get("true_disease", "") + " " +
|
| 669 |
+
" ".join(disease.get("true_symptoms", []))
|
| 670 |
+
)
|
| 671 |
+
target_kw = _keyword_set(target_text)
|
| 672 |
+
content_kw = _keyword_set(content)
|
| 673 |
+
diag_overlap = _fuzzy_overlap(target_kw, content_kw)
|
| 674 |
+
if diag_overlap >= 0.60:
|
| 675 |
+
reward += self._add("diagnosis", 0.30)
|
| 676 |
+
self.intermediate_diagnosis_awarded = True
|
| 677 |
+
elif diag_overlap >= 0.30:
|
| 678 |
+
reward += self._add("diagnosis", 0.20)
|
| 679 |
+
self.intermediate_diagnosis_awarded = True
|
| 680 |
+
|
| 681 |
+
# ----- Intermediate plan bonus (Plan) -----
|
| 682 |
+
if section_kind == "Plan" and not self.intermediate_plan_awarded:
|
| 683 |
+
target_kw = _keyword_set(
|
| 684 |
+
self.ground_truth.get("disease", {}).get("correct_treatment", "")
|
| 685 |
+
)
|
| 686 |
+
content_kw = _keyword_set(content)
|
| 687 |
+
plan_overlap = _fuzzy_overlap(target_kw, content_kw)
|
| 688 |
+
if plan_overlap >= 0.60:
|
| 689 |
+
reward += self._add("plan", 0.25)
|
| 690 |
+
self.intermediate_plan_awarded = True
|
| 691 |
+
elif plan_overlap >= 0.30:
|
| 692 |
+
reward += self._add("plan", 0.15)
|
| 693 |
+
self.intermediate_plan_awarded = True
|
| 694 |
+
|
| 695 |
if updated:
|
| 696 |
obs = json.dumps({
|
| 697 |
"event": "soap_updated",
|
| 698 |
"section": section,
|
| 699 |
"message": f"SOAP note '{section}' updated successfully.",
|
| 700 |
"soap_summary": self._get_soap_summary(),
|
| 701 |
+
"intermediate_signals": {
|
| 702 |
+
"diagnosis_overlap": round(diag_overlap, 2),
|
| 703 |
+
"plan_overlap": round(plan_overlap, 2),
|
| 704 |
+
},
|
| 705 |
})
|
| 706 |
self.episode_log.append({"role": "doctor", "content": f"Updated SOAP {section}: {content[:100]}"})
|
| 707 |
|
|
|
|
| 710 |
def _handle_terminal_discharge(
|
| 711 |
self, doctor_action: Dict[str, Any]
|
| 712 |
) -> Tuple[str, float]:
|
| 713 |
+
"""
|
| 714 |
+
Handle Doctor using 'terminal_discharge' tool. Ends the episode.
|
| 715 |
+
|
| 716 |
+
REWARD REBALANCE (terminal-light, process-heavy):
|
| 717 |
+
1. Emergency identification: correct Β±0.30, neg correct +0.05,
|
| 718 |
+
false-pos -0.20, missed-true -0.30
|
| 719 |
+
(was Β±0.50 / +0.10 / -0.30 / -0.50)
|
| 720 |
+
2. SOAP Assessment documented: +0.20 / empty: -0.30 (was -0.50)
|
| 721 |
+
3. Practiced blind (no read_soap, non-emergency): -0.30 (was -0.50)
|
| 722 |
+
4. Early discharge: -0.10 * shortfall (was -0.15)
|
| 723 |
+
5. Treatment outcome (the BIG change):
|
| 724 |
+
Independent dual-verifier: LLM judge + keyword overlap.
|
| 725 |
+
Final score = 0.6 * judge_score + 0.4 * keyword_overlap
|
| 726 |
+
(when both available; falls back gracefully)
|
| 727 |
+
Lethal-treatment hard penalty: -0.80 (was -1.50). The dual
|
| 728 |
+
check requires *both* signals to agree before declaring
|
| 729 |
+
lethality unilaterally.
|
| 730 |
+
Treatment terminal reward is now SMOOTH:
|
| 731 |
+
base = -0.30 + final_score * 0.90 -> [-0.30, +0.60]
|
| 732 |
+
if lethal: base += -0.80
|
| 733 |
+
This eliminates the +1.0/-1.0 cliff, distributing partial
|
| 734 |
+
credit linearly so the policy gradient is well-shaped.
|
| 735 |
+
"""
|
| 736 |
reward = 0.0
|
| 737 |
treatment = doctor_action.get("treatment", "").strip().lower()
|
| 738 |
declared_emergency = bool(doctor_action.get("is_emergency", False))
|
| 739 |
is_actual_emergency = self.ground_truth.get("disease", {}).get("is_emergency", False)
|
| 740 |
self.done = True
|
| 741 |
|
| 742 |
+
# --- 1. Emergency Identification (reduced magnitudes) ---
|
| 743 |
if declared_emergency and is_actual_emergency:
|
| 744 |
+
reward += self._add("emergency_id", 0.30)
|
|
|
|
| 745 |
elif not declared_emergency and not is_actual_emergency:
|
| 746 |
+
reward += self._add("emergency_id", 0.05)
|
|
|
|
| 747 |
elif declared_emergency and not is_actual_emergency:
|
| 748 |
+
reward += self._add("emergency_id", -0.20)
|
|
|
|
| 749 |
elif not declared_emergency and is_actual_emergency:
|
| 750 |
+
reward += self._add("emergency_id", -0.30)
|
|
|
|
| 751 |
|
| 752 |
+
# --- 2. SOAP Assessment documented ---
|
| 753 |
assessment = self.emr.get("Assessment", "").strip()
|
| 754 |
if assessment:
|
| 755 |
+
reward += self._add("documentation", 0.20)
|
|
|
|
| 756 |
else:
|
| 757 |
+
reward += self._add("documentation", -0.30)
|
| 758 |
+
|
| 759 |
+
# --- 3. Read patient history before treating (non-emergency) ---
|
| 760 |
+
is_emergency = is_actual_emergency
|
| 761 |
+
if (
|
| 762 |
+
self.milestone_tracker
|
| 763 |
+
and not self.milestone_tracker.achieved.get("READ_SOAP", False)
|
| 764 |
+
and not is_emergency
|
| 765 |
+
):
|
| 766 |
+
reward += self._add("documentation", -0.30)
|
| 767 |
+
|
| 768 |
+
# --- 4. Early discharge penalty (softened) ---
|
| 769 |
if not is_emergency:
|
| 770 |
min_steps_map = {1: 4, 2: 6, 3: 8}
|
| 771 |
min_steps = min_steps_map.get(self.phase, 5)
|
| 772 |
if self.step_count < min_steps:
|
| 773 |
shortfall = min_steps - self.step_count
|
| 774 |
+
reward += self._add("penalties", -0.10 * shortfall)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 775 |
|
| 776 |
# Auto-record the treatment in SOAP Plan
|
| 777 |
self.emr["Plan"] = treatment
|
|
|
|
| 782 |
]
|
| 783 |
disease_name = self.ground_truth["disease"]["true_disease"]
|
| 784 |
|
| 785 |
+
# --- 5. INDEPENDENT DUAL VERIFIER ---
|
| 786 |
+
# (a) Cheap keyword overlap (always available)
|
| 787 |
+
correct_kw = _keyword_set(correct_treatment)
|
| 788 |
+
treatment_kw = _keyword_set(treatment)
|
| 789 |
+
keyword_overlap = _fuzzy_overlap(correct_kw, treatment_kw)
|
| 790 |
+
keyword_lethal = any(
|
| 791 |
+
lethal_kw and lethal_kw in treatment for lethal_kw in lethal_treatments
|
| 792 |
+
)
|
| 793 |
+
|
| 794 |
+
# (b) LLM-as-a-Judge (70B) β semantic grading
|
| 795 |
judge_result = None
|
| 796 |
if self.router:
|
| 797 |
try:
|
|
|
|
| 801 |
lethal_treatments=self.ground_truth["disease"]["lethal_treatments"],
|
| 802 |
disease_name=disease_name,
|
| 803 |
)
|
|
|
|
| 804 |
except Exception as e:
|
| 805 |
+
logger.error(f"LLM Judge failed: {e}")
|
| 806 |
+
judge_result = None
|
| 807 |
|
| 808 |
+
judge_score: Optional[float] = None
|
| 809 |
+
judge_lethal: bool = False
|
| 810 |
+
judge_reasoning: str = ""
|
| 811 |
if judge_result and judge_result.get("score") is not None:
|
| 812 |
+
judge_score = float(judge_result["score"])
|
| 813 |
+
judge_lethal = bool(judge_result.get("is_lethal", False))
|
| 814 |
+
judge_reasoning = str(judge_result.get("reasoning", ""))
|
| 815 |
+
|
| 816 |
+
# ----- Combine the two independent signals -----
|
| 817 |
+
# If both available: weighted blend (judge gets 60% as the semantic
|
| 818 |
+
# signal, keyword gets 40% as the grounding signal). This satisfies
|
| 819 |
+
# the Β§7 / Β§8 guidance: 'multiple independent reward functions' so
|
| 820 |
+
# the model cannot exploit either one alone.
|
| 821 |
+
if judge_score is not None:
|
| 822 |
+
final_score = 0.6 * judge_score + 0.4 * keyword_overlap
|
| 823 |
+
# Lethal triggers only when BOTH signals agree, OR one signal
|
| 824 |
+
# is overwhelming (judge_lethal AND keyword_lethal, OR a single
|
| 825 |
+
# very strong signal). This blocks judge-hallucinated lethality.
|
| 826 |
+
is_lethal = (judge_lethal and keyword_lethal) or (
|
| 827 |
+
judge_lethal and judge_score < 0.20
|
| 828 |
+
) or (
|
| 829 |
+
keyword_lethal and judge_score < 0.40
|
| 830 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 831 |
else:
|
| 832 |
+
# Judge unavailable: fall back to keyword-only with a wider band
|
| 833 |
+
final_score = keyword_overlap
|
| 834 |
+
is_lethal = keyword_lethal
|
| 835 |
+
|
| 836 |
+
# ----- Smooth treatment reward (no cliffs) -----
|
| 837 |
+
# Linear in [-0.30, +0.60] across final_score in [0, 1].
|
| 838 |
+
smooth_treatment = -0.30 + final_score * 0.90
|
| 839 |
+
if is_lethal:
|
| 840 |
+
smooth_treatment += -0.80 # additional lethal penalty (was -1.50 hard)
|
| 841 |
+
reward += self._add("treatment", smooth_treatment)
|
| 842 |
+
|
| 843 |
+
# ----- Build observation -----
|
| 844 |
+
# Choose a label for telemetry / curriculum scheduler:
|
| 845 |
+
# WIN if final_score >= 0.65, FATAL if lethal, INCORRECT if <0.30,
|
| 846 |
+
# PARTIAL otherwise.
|
| 847 |
+
if is_lethal:
|
| 848 |
+
event = "terminal_fatal"
|
| 849 |
+
message = "CRITICAL ERROR: Lethal treatment administered."
|
| 850 |
+
elif final_score >= 0.65:
|
| 851 |
+
event = "terminal_win"
|
| 852 |
+
message = "Correct diagnosis and treatment! Patient stabilized."
|
| 853 |
+
elif final_score >= 0.30:
|
| 854 |
+
event = "terminal_partial"
|
| 855 |
+
message = f"Partially correct treatment ({final_score:.0%})."
|
| 856 |
+
else:
|
| 857 |
+
event = "terminal_incorrect"
|
| 858 |
+
message = "Incorrect treatment. Patient outcome: adverse."
|
| 859 |
+
|
| 860 |
+
obs = json.dumps({
|
| 861 |
+
"event": event,
|
| 862 |
+
"message": message,
|
| 863 |
+
"ground_truth": disease_name,
|
| 864 |
+
"correct_treatment": self.ground_truth["disease"]["correct_treatment"],
|
| 865 |
+
"prescribed_treatment": treatment,
|
| 866 |
+
"verifier": {
|
| 867 |
+
"judge_score": judge_score,
|
| 868 |
+
"judge_lethal": judge_lethal,
|
| 869 |
+
"judge_reasoning": judge_reasoning,
|
| 870 |
+
"keyword_overlap": round(keyword_overlap, 3),
|
| 871 |
+
"keyword_lethal": keyword_lethal,
|
| 872 |
+
"final_score": round(final_score, 3),
|
| 873 |
+
"is_lethal_combined": is_lethal,
|
| 874 |
+
},
|
| 875 |
+
"soap_note": self.emr,
|
| 876 |
+
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 877 |
|
| 878 |
return obs, reward
|
| 879 |
|
ER_MAP/tts_engine.py
CHANGED
|
@@ -80,78 +80,166 @@ EDGE_VOICE_MAP = {
|
|
| 80 |
}
|
| 81 |
|
| 82 |
# ---------------------------------------------------------------------------
|
| 83 |
-
# Emotional Text Transforms
|
|
|
|
|
|
|
| 84 |
# ---------------------------------------------------------------------------
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
-
def _anxious_transform(text):
|
| 98 |
-
"""Make text sound panicked β stuttering, filler words, rushing."""
|
| 99 |
-
words = text.split()
|
| 100 |
-
result = []
|
| 101 |
-
for i, word in enumerate(words):
|
| 102 |
-
if i < 3 and random.random() < 0.3 and len(word) > 2:
|
| 103 |
-
result.append(word[0] + '-' + word)
|
| 104 |
-
elif random.random() < 0.15:
|
| 105 |
-
filler = random.choice(['um,', 'uh,', 'oh god,', 'please,'])
|
| 106 |
-
result.append(filler)
|
| 107 |
-
result.append(word)
|
| 108 |
-
else:
|
| 109 |
-
result.append(word)
|
| 110 |
-
text = ' '.join(result)
|
| 111 |
-
if not text.endswith('!') and not text.endswith('?'):
|
| 112 |
-
text += '... please!'
|
| 113 |
-
return text
|
| 114 |
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
words = text.split()
|
| 119 |
-
result = []
|
| 120 |
-
for i, word in enumerate(words):
|
| 121 |
-
if random.random() < 0.12:
|
| 122 |
-
filler = random.choice(['uh...', 'wait...', 'I mean...', 'what was I...'])
|
| 123 |
-
result.append(filler)
|
| 124 |
-
result.append(word)
|
| 125 |
-
text = ' '.join(result)
|
| 126 |
-
if random.random() < 0.5:
|
| 127 |
-
text = 'I... ' + text[0].lower() + text[1:]
|
| 128 |
-
return text
|
| 129 |
|
|
|
|
|
|
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
return text
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
# ---------------------------------------------------------------------------
|
|
@@ -306,18 +394,26 @@ class TTSEngine:
|
|
| 306 |
"""
|
| 307 |
Emotion-induced neural TTS engine for ER-MAP agents.
|
| 308 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
Supports ElevenLabs (premium, ultra-realistic) with automatic
|
| 310 |
Edge-TTS fallback (free, unlimited). Each agent gets a unique
|
| 311 |
voice mapped to their persona traits.
|
| 312 |
"""
|
| 313 |
|
| 314 |
-
def __init__(self, elevenlabs_api_key: Optional[str] = None):
|
| 315 |
self.api_key = elevenlabs_api_key or os.environ.get("ELEVENLABS_API_KEY", "")
|
| 316 |
self.use_elevenlabs = False
|
| 317 |
self._eleven_client = None
|
| 318 |
self._pygame = None
|
| 319 |
self._has_pygame = False
|
| 320 |
-
|
|
|
|
| 321 |
# Initialize ElevenLabs
|
| 322 |
if self.api_key:
|
| 323 |
try:
|
|
@@ -331,6 +427,18 @@ class TTSEngine:
|
|
| 331 |
if not self.use_elevenlabs:
|
| 332 |
logger.info("TTS Engine: Edge-TTS (free fallback)")
|
| 333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
# Initialize pygame for audio playback
|
| 335 |
try:
|
| 336 |
import pygame
|
|
@@ -363,18 +471,26 @@ class TTSEngine:
|
|
| 363 |
return None
|
| 364 |
|
| 365 |
voice_key = get_voice_key(agent, ground_truth)
|
| 366 |
-
|
| 367 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
|
| 369 |
try:
|
| 370 |
if self.use_elevenlabs:
|
| 371 |
-
# ElevenLabs
|
|
|
|
| 372 |
text_el = _inject_speech_markers(text, voice_key)
|
| 373 |
return self._generate_elevenlabs(text_el, voice_key)
|
| 374 |
else:
|
| 375 |
-
# Edge-TTS does NOT support bracketed tags β
|
| 376 |
-
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
| 378 |
except Exception as e:
|
| 379 |
logger.error(f"TTS generation failed ({voice_key}): {e}")
|
| 380 |
print(f" [TTS ERROR] voice_key={voice_key} agent={agent}: {e}", flush=True)
|
|
|
|
| 80 |
}
|
| 81 |
|
| 82 |
# ---------------------------------------------------------------------------
|
| 83 |
+
# Emotional Text Transforms
|
| 84 |
+
# Tier 1: LLM-powered rewrite (preferred β natural, varied, persona-aware)
|
| 85 |
+
# Tier 2: Simple random transforms (fallback if no API client)
|
| 86 |
# ---------------------------------------------------------------------------
|
| 87 |
|
| 88 |
+
# ---- Persona instructions for the LLM emotion adapter ----
|
| 89 |
+
_PERSONA_INSTRUCTIONS = {
|
| 90 |
+
"patient_hostile_aggressive": (
|
| 91 |
+
"You are an angry, hostile patient in an ER. "
|
| 92 |
+
"Rewrite this text to sound aggressive, impatient, confrontational. "
|
| 93 |
+
"Add sharp exclamations, interrupting interjections (Look!, Listen!, I said!), "
|
| 94 |
+
"short frustrated sentences. Replace periods with exclamation marks where natural. "
|
| 95 |
+
"Include ElevenLabs tags: [frustrated], [sigh], and use 'β' for sharp pauses. "
|
| 96 |
+
"Make every rewrite UNIQUE β never repeat the same pattern twice. "
|
| 97 |
+
"Vary the intensity: sometimes seething quiet rage, sometimes explosive anger."
|
| 98 |
+
),
|
| 99 |
+
"patient_anxious_panicked": (
|
| 100 |
+
"You are a terrified, panicking patient in an ER. "
|
| 101 |
+
"Rewrite this text to sound breathless, scared, and rushed. "
|
| 102 |
+
"Add stuttering (w-what, I-I can't), filler words (um, oh god, please), "
|
| 103 |
+
"trailing off (using ...), and voice breaks. "
|
| 104 |
+
"Include ElevenLabs tags: [nervous], [gasps], [stammers], [short pause]. "
|
| 105 |
+
"Vary the panic level: sometimes hyperventilating terror, sometimes "
|
| 106 |
+
"quiet trembling fear, sometimes tearful pleading. Never repeat the same opener."
|
| 107 |
+
),
|
| 108 |
+
"patient_calm_stoic": (
|
| 109 |
+
"You are a calm, stoic patient in an ER. "
|
| 110 |
+
"Rewrite this text with measured, composed speech. "
|
| 111 |
+
"Add thoughtful pauses (...), understated reactions. "
|
| 112 |
+
"Include ElevenLabs tags: [calm], [short pause]. "
|
| 113 |
+
"Keep the tone steady and slightly detached, but natural β not robotic."
|
| 114 |
+
),
|
| 115 |
+
"patient_disorganized_confused": (
|
| 116 |
+
"You are a confused, disoriented patient in an ER. "
|
| 117 |
+
"Rewrite this text to sound scattered and lost. "
|
| 118 |
+
"Add mid-sentence restarts (wait... what was I...), long pauses, "
|
| 119 |
+
"jumbled word order, and trailing thoughts. "
|
| 120 |
+
"Include ElevenLabs tags: [stammers], [long pause], [short pause]. "
|
| 121 |
+
"Vary confusion style: sometimes foggy, sometimes tangential rambling, "
|
| 122 |
+
"sometimes childlike simplicity. Never use the same filler twice in a row."
|
| 123 |
+
),
|
| 124 |
+
"nurse_rookie": (
|
| 125 |
+
"You are a nervous rookie nurse in an ER. "
|
| 126 |
+
"Rewrite this text with hedging language (I think, it seems like, um), "
|
| 127 |
+
"slight uncertainty, and over-explanation. "
|
| 128 |
+
"Include ElevenLabs tags: [clears throat], [nervous]. "
|
| 129 |
+
"Sometimes confident on facts but unsure on interpretation."
|
| 130 |
+
),
|
| 131 |
+
"nurse_standard": (
|
| 132 |
+
"You are a professional ER nurse. "
|
| 133 |
+
"Rewrite this text to sound crisp and efficient. "
|
| 134 |
+
"Minimal emotional color β just professional delivery. "
|
| 135 |
+
"You may add brief [short pause] between medical facts."
|
| 136 |
+
),
|
| 137 |
+
"nurse_veteran": (
|
| 138 |
+
"You are a veteran ER nurse who has seen everything. "
|
| 139 |
+
"Rewrite this text with calm authority, maybe slight weariness. "
|
| 140 |
+
"Occasional [sigh] before delivering routine information. "
|
| 141 |
+
"Direct, no-nonsense phrasing."
|
| 142 |
+
),
|
| 143 |
+
"doctor": (
|
| 144 |
+
"You are a calm, authoritative ER doctor. "
|
| 145 |
+
"Rewrite this text with measured, professional delivery. "
|
| 146 |
+
"Add [calm] and [short pause] tags for thoughtful pacing. "
|
| 147 |
+
"Warm but clinical."
|
| 148 |
+
),
|
| 149 |
+
}
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
+
def emotionalize_for_tts(text: str, voice_key: str, groq_client=None, model: str = "llama-3.3-70b-versatile") -> str:
|
| 153 |
+
"""
|
| 154 |
+
LLM-powered emotion adapter for ElevenLabs TTS.
|
| 155 |
|
| 156 |
+
Takes clean, clinical LLM agent text and rewrites it into emotionally
|
| 157 |
+
expressive natural speech WITH ElevenLabs audio tags.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
+
This function is ONLY called in the TTS pipeline β the emotionalized
|
| 160 |
+
text never reaches the RL agents, so agent behavior is unaffected.
|
| 161 |
|
| 162 |
+
Args:
|
| 163 |
+
text: Clean agent message text
|
| 164 |
+
voice_key: Persona voice key (e.g. "patient_anxious_panicked")
|
| 165 |
+
groq_client: Optional Groq API client. Falls back to regex transforms if None.
|
| 166 |
+
model: Model to use for rewriting (default: 70B)
|
|
|
|
| 167 |
|
| 168 |
+
Returns:
|
| 169 |
+
Emotionally rewritten text with ElevenLabs audio tags.
|
| 170 |
+
"""
|
| 171 |
+
if not text or len(text.strip()) < 5:
|
| 172 |
+
return text
|
| 173 |
|
| 174 |
+
persona_instruction = _PERSONA_INSTRUCTIONS.get(voice_key)
|
| 175 |
+
if not persona_instruction or groq_client is None:
|
| 176 |
+
# Fallback to simple regex transforms
|
| 177 |
+
return _fallback_emotion_transform(text, voice_key)
|
| 178 |
+
|
| 179 |
+
prompt = (
|
| 180 |
+
f"{persona_instruction}\n\n"
|
| 181 |
+
f"ORIGINAL TEXT:\n\"{text}\"\n\n"
|
| 182 |
+
f"RULES:\n"
|
| 183 |
+
f"- Output ONLY the rewritten speech text. No quotes, no labels, no explanations.\n"
|
| 184 |
+
f"- Keep the medical content and meaning EXACTLY the same.\n"
|
| 185 |
+
f"- Only change HOW it is said, not WHAT is said.\n"
|
| 186 |
+
f"- Add ElevenLabs audio tags like [sigh], [gasps], [nervous], [calm], etc.\n"
|
| 187 |
+
f"- Use '...' for trailing off and 'β' for sharp pauses.\n"
|
| 188 |
+
f"- Keep the output under {len(text) + 80} characters.\n"
|
| 189 |
+
f"- Make it sound like a REAL person talking, not a script."
|
| 190 |
+
)
|
| 191 |
|
| 192 |
+
try:
|
| 193 |
+
completion = groq_client.chat.completions.create(
|
| 194 |
+
model=model,
|
| 195 |
+
messages=[
|
| 196 |
+
{"role": "system", "content": "You rewrite clinical text into emotionally expressive natural speech for text-to-speech synthesis. Output ONLY the rewritten text."},
|
| 197 |
+
{"role": "user", "content": prompt},
|
| 198 |
+
],
|
| 199 |
+
temperature=0.9, # High temp for variety β prevents repetition
|
| 200 |
+
max_tokens=256,
|
| 201 |
+
)
|
| 202 |
+
result = (completion.choices[0].message.content or "").strip()
|
| 203 |
+
# Strip any accidental quotes the model wraps around the output
|
| 204 |
+
if result.startswith('"') and result.endswith('"'):
|
| 205 |
+
result = result[1:-1]
|
| 206 |
+
if result and len(result) > 5:
|
| 207 |
+
return result
|
| 208 |
+
return _fallback_emotion_transform(text, voice_key)
|
| 209 |
+
except Exception as e:
|
| 210 |
+
logger.warning(f"LLM emotion adapter failed ({voice_key}): {e}")
|
| 211 |
+
return _fallback_emotion_transform(text, voice_key)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _fallback_emotion_transform(text: str, voice_key: str) -> str:
|
| 215 |
+
"""Simple regex-based fallback when LLM adapter is unavailable."""
|
| 216 |
+
if voice_key == "patient_hostile_aggressive":
|
| 217 |
+
text = text.replace('. ', '! ')
|
| 218 |
+
if not text.endswith('!') and not text.endswith('?'):
|
| 219 |
+
text = text.rstrip('.') + '!'
|
| 220 |
+
interjections = ["Look, ", "Listen! ", "I said, ", "For God's sake, "]
|
| 221 |
+
if random.random() < 0.4 and len(text) > 20:
|
| 222 |
+
text = random.choice(interjections) + text[0].lower() + text[1:]
|
| 223 |
+
elif voice_key == "patient_anxious_panicked":
|
| 224 |
+
words = text.split()
|
| 225 |
+
result = []
|
| 226 |
+
for i, word in enumerate(words):
|
| 227 |
+
if i < 3 and random.random() < 0.3 and len(word) > 2:
|
| 228 |
+
result.append(word[0] + '-' + word)
|
| 229 |
+
elif random.random() < 0.15:
|
| 230 |
+
result.append(random.choice(['um,', 'uh,', 'oh god,']))
|
| 231 |
+
result.append(word)
|
| 232 |
+
else:
|
| 233 |
+
result.append(word)
|
| 234 |
+
text = ' '.join(result)
|
| 235 |
+
elif voice_key == "patient_disorganized_confused":
|
| 236 |
+
if random.random() < 0.5:
|
| 237 |
+
text = 'I... ' + text[0].lower() + text[1:]
|
| 238 |
+
elif voice_key == "nurse_rookie":
|
| 239 |
+
hedges = ['I think ', 'It looks like ', 'Um, ']
|
| 240 |
+
if random.random() < 0.4:
|
| 241 |
+
text = random.choice(hedges) + text[0].lower() + text[1:]
|
| 242 |
+
return text
|
| 243 |
|
| 244 |
|
| 245 |
# ---------------------------------------------------------------------------
|
|
|
|
| 394 |
"""
|
| 395 |
Emotion-induced neural TTS engine for ER-MAP agents.
|
| 396 |
|
| 397 |
+
Two-layer emotion system:
|
| 398 |
+
Layer 1: LLM Emotion Adapter (70B) β rewrites clean text into
|
| 399 |
+
emotionally expressive natural speech with ElevenLabs tags.
|
| 400 |
+
Only used for TTS; RL agents never see the rewritten text.
|
| 401 |
+
Layer 2: ElevenLabs voice settings β stability, style, speed tuned
|
| 402 |
+
per persona for vocal quality.
|
| 403 |
+
|
| 404 |
Supports ElevenLabs (premium, ultra-realistic) with automatic
|
| 405 |
Edge-TTS fallback (free, unlimited). Each agent gets a unique
|
| 406 |
voice mapped to their persona traits.
|
| 407 |
"""
|
| 408 |
|
| 409 |
+
def __init__(self, elevenlabs_api_key: Optional[str] = None, groq_api_key: Optional[str] = None):
|
| 410 |
self.api_key = elevenlabs_api_key or os.environ.get("ELEVENLABS_API_KEY", "")
|
| 411 |
self.use_elevenlabs = False
|
| 412 |
self._eleven_client = None
|
| 413 |
self._pygame = None
|
| 414 |
self._has_pygame = False
|
| 415 |
+
self._groq_client = None
|
| 416 |
+
self._groq_model = "llama-3.3-70b-versatile"
|
| 417 |
# Initialize ElevenLabs
|
| 418 |
if self.api_key:
|
| 419 |
try:
|
|
|
|
| 427 |
if not self.use_elevenlabs:
|
| 428 |
logger.info("TTS Engine: Edge-TTS (free fallback)")
|
| 429 |
|
| 430 |
+
# Initialize Groq client for LLM Emotion Adapter
|
| 431 |
+
_groq_key = groq_api_key or os.environ.get("GROQ_NURSE_API_KEY") or os.environ.get("GROQ_API_KEY", "")
|
| 432 |
+
if _groq_key:
|
| 433 |
+
try:
|
| 434 |
+
from groq import Groq
|
| 435 |
+
self._groq_client = Groq(api_key=_groq_key)
|
| 436 |
+
logger.info("TTS Emotion Adapter: LLM-powered (70B)")
|
| 437 |
+
except ImportError:
|
| 438 |
+
logger.warning("groq package not installed. Using regex emotion fallback.")
|
| 439 |
+
else:
|
| 440 |
+
logger.info("TTS Emotion Adapter: regex fallback (no GROQ key)")
|
| 441 |
+
|
| 442 |
# Initialize pygame for audio playback
|
| 443 |
try:
|
| 444 |
import pygame
|
|
|
|
| 471 |
return None
|
| 472 |
|
| 473 |
voice_key = get_voice_key(agent, ground_truth)
|
| 474 |
+
|
| 475 |
+
# Layer 1: LLM Emotion Adapter β rewrites clean text into
|
| 476 |
+
# emotionally expressive natural speech. Falls back to regex
|
| 477 |
+
# transforms if no Groq client is available.
|
| 478 |
+
text = emotionalize_for_tts(text, voice_key, self._groq_client, self._groq_model)
|
| 479 |
+
logger.info(f"TTS [{voice_key}]: {text[:120]}")
|
| 480 |
|
| 481 |
try:
|
| 482 |
if self.use_elevenlabs:
|
| 483 |
+
# Layer 2: ElevenLabs audio tags ([sigh], [nervous])
|
| 484 |
+
# added on top of the LLM-rewritten text
|
| 485 |
text_el = _inject_speech_markers(text, voice_key)
|
| 486 |
return self._generate_elevenlabs(text_el, voice_key)
|
| 487 |
else:
|
| 488 |
+
# Edge-TTS does NOT support bracketed tags β strip them
|
| 489 |
+
import re as _re
|
| 490 |
+
clean_for_edge = _re.sub(r'\[.*?\]', '', text).strip()
|
| 491 |
+
if len(clean_for_edge) < 3:
|
| 492 |
+
clean_for_edge = text
|
| 493 |
+
return self._generate_edge(clean_for_edge, voice_key)
|
| 494 |
except Exception as e:
|
| 495 |
logger.error(f"TTS generation failed ({voice_key}): {e}")
|
| 496 |
print(f" [TTS ERROR] voice_key={voice_key} agent={agent}: {e}", flush=True)
|