Uddiii commited on
Commit
28e9d71
Β·
1 Parent(s): c34827e

LLM Emotion Adapter for TTS + LLM Empathy Judge + reward rebalance

Browse files
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
- # Pick any available client (prefer nurse client to spread rate limits)
 
 
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.05 * emp
260
  if dismiss > 0:
261
- reward -= 0.08 * dismiss
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.15 # Heavy penalty for wasting time in emergencies
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
- # Invalid JSON
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 = classify_intent(message)
234
  self.patient_state.update(intent)
235
- reward += compute_empathy_reward(intent, self.patient_state, self.phase)
236
- # Milestone: patient contact
237
  if target == "patient" and self.milestone_tracker:
238
- reward += self.milestone_tracker.mark("PATIENT_CONTACT")
 
 
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.milestone_tracker.mark("LABS")
 
 
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.milestone_tracker.mark("READ_SOAP")
 
 
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.milestone_tracker.mark("DISCHARGE")
 
 
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["reward_breakdown"] = reward
 
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.30 # Successful Doctor-led de-escalation
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.75 # AMA β€” patient left, but NOT a clinical error
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
- """Handle Doctor using 'order_lab' tool."""
 
 
 
 
 
 
 
 
 
 
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.25 # Heavy penalty for redundant lab usage
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
- Doctor can update: Assessment, Plan, or append to Subjective.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- reward += 0.05 # Reward for maintaining documentation
 
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
- reward += 0.05
 
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
- reward += 0.05
 
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
- """Handle Doctor using 'terminal_discharge' tool. This ends the episode."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Reward ---
518
  if declared_emergency and is_actual_emergency:
519
- reward += 0.50
520
- logger.info("Emergency correctly identified (+0.50).")
521
  elif not declared_emergency and not is_actual_emergency:
522
- reward += 0.10
523
- logger.info("Non-emergency correctly identified (+0.10).")
524
  elif declared_emergency and not is_actual_emergency:
525
- reward += -0.30
526
- logger.info("False positive emergency identification (-0.30).")
527
  elif not declared_emergency and is_actual_emergency:
528
- reward += -0.50
529
- logger.info("Failed to identify true emergency (-0.50).")
530
 
531
- # --- SOAP reward shaping: penalize empty Assessment, reward filled ---
532
  assessment = self.emr.get("Assessment", "").strip()
533
  if assessment:
534
- reward += 0.20 # Documented clinical reasoning before discharge
535
- logger.info("SOAP bonus: Assessment documented before discharge.")
536
  else:
537
- reward += -0.50 # Heavy penalty for discharging without documenting reasoning
538
- logger.info("SOAP penalty: No Assessment documented before discharge.")
539
-
540
- # --- SOAP reward shaping: penalize ignoring patient history ---
541
- is_emergency = self.ground_truth.get("disease", {}).get("is_emergency", False)
542
- if self.milestone_tracker and not self.milestone_tracker.achieved.get("READ_SOAP", False) and not is_emergency:
543
- reward += -0.50 # Heavy penalty for practicing medicine blind
544
- logger.info("SOAP penalty: Discharged without reading patient history (read_soap).")
545
-
546
- # --- Early discharge penalty: phase-aware ---
547
- # Phase 1: min 4 steps, Phase 2: min 6 steps, Phase 3: min 8 steps
 
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
- penalty = -0.15 * shortfall # -0.15 per missing step
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
- # --- LLM-as-a-Judge Evaluation ---
570
- # Use a 70B model to semantically grade the treatment plan
 
 
 
 
 
 
 
 
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, falling back to keyword matching: {e}")
 
583
 
 
 
 
584
  if judge_result and judge_result.get("score") is not None:
585
- # --- LLM Judge path ---
586
- score = judge_result["score"]
587
- is_lethal = judge_result.get("is_lethal", False)
588
- reasoning = judge_result.get("reasoning", "")
589
-
590
- if is_lethal:
591
- reward += -1.50
592
- obs = json.dumps({
593
- "event": "terminal_fatal",
594
- "message": f"CRITICAL ERROR: Lethal treatment administered. {reasoning}",
595
- "ground_truth": disease_name,
596
- "prescribed_treatment": treatment,
597
- "judge_score": score,
598
- "judge_reasoning": reasoning,
599
- "soap_note": self.emr,
600
- })
601
- elif score >= 0.75:
602
- reward += 1.00
603
- obs = json.dumps({
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
- # --- Fallback: Keyword matching (if LLM Judge unavailable) ---
639
- stop_words = {"for", "if", "or", "and", "with", "to", "of", "the", "a", "an", "in", "on", "at", "by", "from", "unable", "po", "signs", "is", "are", "then", "above", "below"}
640
- correct_keywords = set(re.findall(r"\w+", correct_treatment)) - stop_words
641
- treatment_keywords = set(re.findall(r"\w+", treatment)) - stop_words
642
-
643
- overlap = correct_keywords & treatment_keywords
644
- for c_kw in correct_keywords - overlap:
645
- for t_kw in treatment_keywords:
646
- if len(c_kw) >= 4 and (c_kw in t_kw or t_kw in c_kw):
647
- overlap.add(c_kw)
648
- break
649
-
650
- overlap_ratio = len(overlap) / max(len(correct_keywords), 1)
651
-
652
- is_lethal = any(lethal_kw in treatment for lethal_kw in lethal_treatments)
653
-
654
- if is_lethal:
655
- reward += -1.50
656
- obs = json.dumps({
657
- "event": "terminal_fatal",
658
- "message": "CRITICAL ERROR: Lethal treatment administered. Patient death.",
659
- "ground_truth": disease_name,
660
- "prescribed_treatment": treatment,
661
- "soap_note": self.emr,
662
- })
663
- elif overlap_ratio >= 0.70:
664
- reward += 1.00
665
- obs = json.dumps({
666
- "event": "terminal_win",
667
- "message": "Correct diagnosis and treatment! Patient stabilized.",
668
- "ground_truth": disease_name,
669
- "prescribed_treatment": treatment,
670
- "match_ratio": round(overlap_ratio, 2),
671
- "soap_note": self.emr,
672
- })
673
- elif overlap_ratio >= 0.20:
674
- partial_reward = -0.40 + (overlap_ratio * 1.2)
675
- reward += partial_reward
676
- obs = json.dumps({
677
- "event": "terminal_partial",
678
- "message": f"Partially correct treatment ({overlap_ratio:.0%} match). Key interventions missing.",
679
- "ground_truth": disease_name,
680
- "correct_treatment": self.ground_truth["disease"]["correct_treatment"],
681
- "prescribed_treatment": treatment,
682
- "match_ratio": round(overlap_ratio, 2),
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 β€” inject natural speech cues per persona
 
 
84
  # ---------------------------------------------------------------------------
85
 
86
- def _hostile_transform(text):
87
- """Make text sound aggressive β€” exclamations, aggressive interjections."""
88
- text = text.replace('. ', '! ')
89
- if not text.endswith('!') and not text.endswith('?'):
90
- text = text.rstrip('.') + '!'
91
- interjections = ["Look, ", "Listen! ", "I said, ", "For God's sake, "]
92
- if random.random() < 0.4 and len(text) > 20:
93
- text = random.choice(interjections) + text[0].lower() + text[1:]
94
- return text
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
- def _confused_transform(text):
117
- """Make text sound disorganized β€” pauses, restarts, uncertainty."""
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
- def _rookie_transform(text):
132
- """Make text sound uncertain β€” hedging language."""
133
- hedges = ['I think ', 'It looks like ', 'Um, ', 'So, ']
134
- if random.random() < 0.4 and not text.startswith(('I think', 'It looks', 'Um')):
135
- text = random.choice(hedges) + text[0].lower() + text[1:]
136
- return text
137
 
 
 
 
 
 
138
 
139
- EMOTION_TRANSFORMS = {
140
- "patient_hostile_aggressive": _hostile_transform,
141
- "patient_anxious_panicked": _anxious_transform,
142
- "patient_calm_stoic": lambda t: t,
143
- "patient_disorganized_confused": _confused_transform,
144
- "nurse_rookie": _rookie_transform,
145
- "nurse_standard": lambda t: t,
146
- "nurse_veteran": lambda t: t,
147
- "doctor": lambda t: t,
148
- }
149
-
 
 
 
 
 
 
150
 
151
- def apply_emotion_transform(text, voice_key):
152
- """Apply persona-appropriate emotional text transformation."""
153
- transform = EMOTION_TRANSFORMS.get(voice_key, lambda t: t)
154
- return transform(text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- text = apply_emotion_transform(text, voice_key)
367
- logger.info(f"TTS [{voice_key}]: {text[:100]}")
 
 
 
 
368
 
369
  try:
370
  if self.use_elevenlabs:
371
- # ElevenLabs v3 supports [sigh], [nervous] etc.
 
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 β€” use clean text
376
- # Only keep ellipses and em-dashes for pacing
377
- return self._generate_edge(text, voice_key)
 
 
 
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)