MukulRay Claude Opus 4.7 (1M context) commited on
Commit
d14dce3
·
1 Parent(s): 1f55fee

Add stage-aware listening planner and F-1 student awareness

Browse files

Previous responses prescribed action on turn 1 even when the user was
just venting, and there was no acknowledgment that an F-1 student
worried about deportation faces a different layer of fear than ordinary
academic stress. UMD also has a dedicated office (ISSS) for F-1
academic-difficulty advising and SEVIS, which the system never
surfaced.

This change introduces a three-stage conversation model
(LISTEN -> PERMISSION -> OFFER) that defaults to acknowledgment + open
invitation on turn 1 for listen-eligible routes, soft resource hints
on turn 2, and full action with named resources on turn 3 or any
explicit ask. Each stage has a distinct opener so back-to-back turns
no longer repeat the same validation phrase. Always-direct routes (ADS,
counseling navigation, basic needs, advisor conflict, peer-helper,
crisis, out-of-scope) skip the listening stages and go straight to
OFFER.

International / F-1 detection is a cross-cutting concern with session
memory: once visa/deport/F-1/SEVIS keywords appear in any turn, ISSS
stays surfaced for the rest of the session via session_intl_flag.
Added three ISSS entries to the resource registry and an
INTERNATIONAL_SOURCE_HINT injected into retrieved sources so the
Support card actually shows ISSS rather than only generic counseling.

Bug fix: output guard ran on every stage and replaced gentle LISTEN /
PERMISSION responses with the harsh fallback because they don't
contain action-verb markers by design. Guard now only runs in OFFER
stage; other stages return reason=listening_stage_<stage>.

Diagnostics tab now shows international_concern and conversation_stage
as visible NLP signals. Streaming defaults slowed (2 words / 75ms,
typing delay 650ms). User chat pill widened to 92% max-width with
larger padding for readability. Hero copy rewritten to set
expectations about listening-first behavior and to mention F-1 support
explicitly. Fifth suggestion chip added for F-1 visa worry.

Tests adjusted: assertions for academic_setback/exam_stress turn-1
behavior moved from result["response"] to result["recommended_action"]
since the practical move is now stage-deferred. Crisis assertion
updated for new warm intercept wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

data/curated/service_graph.jsonl CHANGED
@@ -23,3 +23,6 @@
23
  {"service_id":"umd_police_emergency","resource_name":"University of Maryland Police Emergency","description":"Emergency police support for life-threatening incidents, immediate danger, and campus emergency response.","urgency_level":"emergency","safety_tiers":["imminent_safety"],"route_types":["crisis_immediate","peer_helper"],"audience":["student","graduate_student","undergraduate_student","friend_peer"],"issue_types":["emergency","immediate_danger","safety"],"confidentiality_status":"not_confidential","hours":"24/7","contact_mode":["phone","hotline","website"],"contact":"911; 301-405-3333","location":"7569 Baltimore Ave, College Park, MD 20742","source_url":"https://umpd.umd.edu/contact-us/phone-directory","source_authority":"umd_official","last_verified":"2026-05-01","usage_modes":["crisis_only"],"do_not_use_for":["academic_setback","exam_stress","wellbeing"],"notes":"Use only for immediate safety/emergency routing."}
24
  {"service_id":"umd_police_non_emergency_escort","resource_name":"UMPD Non-Emergency and Escort Service","description":"UMPD non-emergency number and police escort service for campus safety concerns that are not immediate life-threatening emergencies.","urgency_level":"urgent","safety_tiers":["high_distress","support_navigation","imminent_safety"],"route_types":["general_student_support","peer_helper","crisis_immediate"],"audience":["student","graduate_student","undergraduate_student","friend_peer"],"issue_types":["campus_safety","escort","non_emergency"],"confidentiality_status":"not_confidential","hours":"24/7 police services","contact_mode":["phone","website"],"contact":"301-405-3555","location":"7569 Baltimore Ave, College Park, MD 20742","source_url":"https://umpd.umd.edu/contact-us/phone-directory","source_authority":"umd_official","last_verified":"2026-05-01","usage_modes":["retrieval","crisis_only"],"do_not_use_for":["academic_setback","exam_stress","wellbeing"],"notes":"Use for safety/escort support; emergency danger should use 911 or 301-405-3333."}
25
  {"service_id":"umd_mheart","resource_name":"UMD MHEART","description":"Mental Health Emergency Assessment and Response Team pairs licensed mental health clinicians and UMD police officers for mental health crises on and around campus.","urgency_level":"urgent","safety_tiers":["imminent_safety","high_distress"],"route_types":["crisis_immediate","peer_helper"],"audience":["student","graduate_student","undergraduate_student","friend_peer"],"issue_types":["mental_health_crisis","campus_crisis_response"],"confidentiality_status":"unknown","hours":"unknown","contact_mode":["website"],"contact":"unknown","location":"unknown","source_url":"https://umpd.umd.edu/","source_authority":"umd_official","last_verified":"2026-05-01","usage_modes":["crisis_only"],"do_not_use_for":["academic_setback","exam_stress","wellbeing"],"notes":"Homepage verifies MHEART existence; contact pathway not asserted here."}
 
 
 
 
23
  {"service_id":"umd_police_emergency","resource_name":"University of Maryland Police Emergency","description":"Emergency police support for life-threatening incidents, immediate danger, and campus emergency response.","urgency_level":"emergency","safety_tiers":["imminent_safety"],"route_types":["crisis_immediate","peer_helper"],"audience":["student","graduate_student","undergraduate_student","friend_peer"],"issue_types":["emergency","immediate_danger","safety"],"confidentiality_status":"not_confidential","hours":"24/7","contact_mode":["phone","hotline","website"],"contact":"911; 301-405-3333","location":"7569 Baltimore Ave, College Park, MD 20742","source_url":"https://umpd.umd.edu/contact-us/phone-directory","source_authority":"umd_official","last_verified":"2026-05-01","usage_modes":["crisis_only"],"do_not_use_for":["academic_setback","exam_stress","wellbeing"],"notes":"Use only for immediate safety/emergency routing."}
24
  {"service_id":"umd_police_non_emergency_escort","resource_name":"UMPD Non-Emergency and Escort Service","description":"UMPD non-emergency number and police escort service for campus safety concerns that are not immediate life-threatening emergencies.","urgency_level":"urgent","safety_tiers":["high_distress","support_navigation","imminent_safety"],"route_types":["general_student_support","peer_helper","crisis_immediate"],"audience":["student","graduate_student","undergraduate_student","friend_peer"],"issue_types":["campus_safety","escort","non_emergency"],"confidentiality_status":"not_confidential","hours":"24/7 police services","contact_mode":["phone","website"],"contact":"301-405-3555","location":"7569 Baltimore Ave, College Park, MD 20742","source_url":"https://umpd.umd.edu/contact-us/phone-directory","source_authority":"umd_official","last_verified":"2026-05-01","usage_modes":["retrieval","crisis_only"],"do_not_use_for":["academic_setback","exam_stress","wellbeing"],"notes":"Use for safety/escort support; emergency danger should use 911 or 301-405-3333."}
25
  {"service_id":"umd_mheart","resource_name":"UMD MHEART","description":"Mental Health Emergency Assessment and Response Team pairs licensed mental health clinicians and UMD police officers for mental health crises on and around campus.","urgency_level":"urgent","safety_tiers":["imminent_safety","high_distress"],"route_types":["crisis_immediate","peer_helper"],"audience":["student","graduate_student","undergraduate_student","friend_peer"],"issue_types":["mental_health_crisis","campus_crisis_response"],"confidentiality_status":"unknown","hours":"unknown","contact_mode":["website"],"contact":"unknown","location":"unknown","source_url":"https://umpd.umd.edu/","source_authority":"umd_official","last_verified":"2026-05-01","usage_modes":["crisis_only"],"do_not_use_for":["academic_setback","exam_stress","wellbeing"],"notes":"Homepage verifies MHEART existence; contact pathway not asserted here."}
26
+ {"service_id": "umd_isss", "resource_name": "UMD International Student & Scholar Services (ISSS)", "description": "UMD's primary support office for F-1 and J-1 students; provides immigration advising, OPT/CPT guidance, academic-difficulty advising for international students, and SEVIS support.", "urgency_level": "support", "safety_tiers": ["high_distress", "support_navigation", "wellbeing"], "route_types": ["academic_setback", "exam_stress", "anxiety_panic", "low_mood", "advisor_conflict", "general_student_support", "international_student"], "audience": ["student", "graduate_student", "undergraduate_student", "international_student", "f1_student", "j1_student"], "issue_types": ["visa_status", "academic_standing", "international_concern", "deportation_fear", "opt", "cpt", "sevis"], "confidentiality_status": "advisor_confidential", "hours": "unknown", "contact_mode": ["website", "email", "in_person"], "contact": "unknown", "location": "unknown", "source_url": "https://globalmaryland.umd.edu/offices/international-students-scholars", "source_authority": "umd_official", "last_verified": "2026-05-06", "usage_modes": ["retrieval"], "do_not_use_for": [], "notes": "Primary contact for F-1 academic standing concerns and visa-related stress. Verify hours and contact channels before runtime use."}
27
+ {"service_id": "umd_isss_academic_difficulty", "resource_name": "ISSS Academic Difficulty Advising", "description": "F-1/J-1 specific advising for students worried about academic standing, course load, withdrawal, or how grades may affect visa status. Separate from regular counseling.", "urgency_level": "support", "safety_tiers": ["high_distress", "support_navigation"], "route_types": ["academic_setback", "exam_stress", "international_student"], "audience": ["international_student", "f1_student", "graduate_student", "undergraduate_student"], "issue_types": ["academic_standing", "visa_status", "international_concern", "deportation_fear", "course_withdrawal"], "confidentiality_status": "advisor_confidential", "hours": "unknown", "contact_mode": ["website", "in_person"], "contact": "unknown", "location": "unknown", "source_url": "https://globalmaryland.umd.edu/offices/international-students-scholars", "source_authority": "umd_official", "last_verified": "2026-05-06", "usage_modes": ["retrieval"], "do_not_use_for": [], "notes": "Walk-in advising and scheduled appointments. Verify current process before deployment."}
28
+ {"service_id": "umd_isss_opt_cpt", "resource_name": "ISSS Employment & OPT/CPT Authorization", "description": "Authorization and guidance for Optional Practical Training (OPT), Curricular Practical Training (CPT), and on-campus employment for F-1 students.", "urgency_level": "support", "safety_tiers": ["support_navigation", "wellbeing"], "route_types": ["general_student_support", "international_student"], "audience": ["international_student", "f1_student", "graduate_student", "undergraduate_student"], "issue_types": ["work_authorization", "opt", "cpt", "international_concern", "visa_status"], "confidentiality_status": "advisor_confidential", "hours": "unknown", "contact_mode": ["website", "in_person"], "contact": "unknown", "location": "unknown", "source_url": "https://globalmaryland.umd.edu/offices/international-students-scholars", "source_authority": "umd_official", "last_verified": "2026-05-06", "usage_modes": ["retrieval"], "do_not_use_for": [], "notes": "Required for any off-campus employment authorization for F-1 students."}
demo/app.py CHANGED
@@ -50,645 +50,699 @@ CURATED_DB_PATH = Path(os.getenv("EMPATHRAG_CURATED_DB", "data/curated/indexes/m
50
 
51
  APP_CSS = """
52
  :root {
53
- --er-void: #030712;
54
- --er-space: #07111f;
55
- --er-deep: #0b1728;
56
- --er-panel: rgba(8, 20, 34, 0.76);
57
- --er-panel-solid: #0d1b2d;
58
- --er-panel-lift: rgba(14, 33, 52, 0.88);
59
- --er-panel-2: rgba(3, 12, 24, 0.56);
60
- --er-ink: #f3fbff;
61
- --er-muted: #9eb4c7;
62
- --er-soft: #c8d9e8;
63
- --er-dim: #71869a;
64
- --er-line: rgba(148, 219, 233, 0.18);
65
- --er-line-strong: rgba(45, 212, 191, 0.46);
66
- --er-turquoise: #2dd4bf;
67
- --er-cyan: #22d3ee;
68
- --er-blue: #38bdf8;
69
- --er-amber: #f59e0b;
70
- --er-rose: #fb7185;
71
- --er-danger: #fb7185;
72
- --er-violet: #a78bfa;
73
- }
 
 
 
 
74
 
75
  html, body {
76
- min-height: 100% !important;
77
- background:
78
- linear-gradient(115deg, rgba(45,212,191,0.10), transparent 34%),
79
- linear-gradient(245deg, rgba(56,189,248,0.12), transparent 30%),
80
- linear-gradient(180deg, #030712 0%, #07111f 46%, #0b1728 100%) !important;
81
- color: var(--er-ink) !important;
82
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
83
  }
84
 
85
  body::before {
86
  content: "";
87
- position: fixed;
88
- inset: 0;
89
- pointer-events: none;
90
- z-index: 0;
91
- background-image:
92
- radial-gradient(circle at 14% 18%, rgba(125, 249, 233, 0.78) 0 1px, transparent 1.5px),
93
- radial-gradient(circle at 78% 12%, rgba(186, 230, 253, 0.70) 0 1px, transparent 1.5px),
94
- radial-gradient(circle at 48% 32%, rgba(45, 212, 191, 0.58) 0 1px, transparent 1.4px),
95
- radial-gradient(circle at 88% 64%, rgba(167, 139, 250, 0.55) 0 1px, transparent 1.4px),
96
- radial-gradient(circle at 21% 78%, rgba(56, 189, 248, 0.58) 0 1px, transparent 1.4px),
97
- linear-gradient(rgba(45,212,191,0.045) 1px, transparent 1px),
98
- linear-gradient(90deg, rgba(45,212,191,0.045) 1px, transparent 1px);
99
- background-size: auto, auto, auto, auto, auto, 72px 72px, 72px 72px;
100
- mask-image: linear-gradient(to bottom, rgba(0,0,0,0.92), rgba(0,0,0,0.38));
101
- }
102
-
103
- body::after {
104
- content: "";
105
- position: fixed;
106
- inset: 0;
107
- pointer-events: none;
108
- z-index: 0;
109
  background:
110
- linear-gradient(100deg, transparent 0 38%, rgba(45,212,191,0.10) 38.2%, transparent 39% 100%),
111
- linear-gradient(144deg, transparent 0 64%, rgba(56,189,248,0.08) 64.2%, transparent 65% 100%);
112
- opacity: 0.85;
113
  }
114
 
115
  .gradio-container {
116
- position: relative;
117
- z-index: 1;
118
- min-height: 100% !important;
119
  background: transparent !important;
120
- color: var(--er-ink) !important;
121
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
 
 
122
  }
123
 
124
- .gradio-container {
125
- max-width: 1360px !important;
126
- margin: 0 auto !important;
127
- padding: 0 22px 28px !important;
128
- }
129
- .gradio-container * {
130
- border-color: var(--er-line);
131
- }
132
- .gradio-container label,
133
- .gradio-container p,
134
- .gradio-container span,
135
- .gradio-container div,
136
- .gradio-container h1,
137
- .gradio-container h2,
138
- .gradio-container h3,
139
- .gradio-container h4,
140
- .gradio-container textarea,
141
- .gradio-container input {
142
- color: var(--er-ink);
143
  }
144
- .gradio-container .wrap,
145
- .gradio-container .contain,
146
  .gradio-container .block,
147
  .gradio-container .form,
148
  .gradio-container .panel,
 
 
149
  .gradio-container .tabs,
150
  .gradio-container .tabitem {
151
  background: transparent !important;
152
- border-color: var(--er-line) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  }
154
-
155
- .gradio-container label {
156
- color: var(--er-muted) !important;
157
  }
 
158
 
159
- .er-shell {
160
- padding: 26px 0 16px;
161
- }
162
- .er-title {
163
- position: relative;
164
- overflow: hidden;
165
- display: grid;
166
- grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr);
167
- gap: 26px;
168
- border: 1px solid rgba(125,249,233,0.24);
169
- border-radius: 18px;
170
- padding: 30px;
171
- background:
172
- linear-gradient(105deg, rgba(45,212,191,0.20), rgba(34,211,238,0.07) 44%, rgba(167,139,250,0.12)),
173
- linear-gradient(180deg, rgba(10,25,42,0.94), rgba(6,16,29,0.88));
174
- box-shadow:
175
- 0 24px 90px rgba(0,0,0,0.46),
176
- inset 0 1px 0 rgba(255,255,255,0.08);
177
- backdrop-filter: blur(18px) saturate(140%);
178
- }
179
- .er-title::before {
180
- content: "";
181
- position: absolute;
182
- inset: 18px 20px auto auto;
183
- width: 420px;
184
- height: 1px;
185
- background: linear-gradient(90deg, transparent, rgba(125,249,233,0.70), transparent);
186
- transform: rotate(-8deg);
187
- }
188
- .er-title::after {
189
- content: "";
190
- position: absolute;
191
- right: 36px;
192
- bottom: 24px;
193
- width: 220px;
194
- height: 220px;
195
- border: 1px solid rgba(45,212,191,0.16);
196
- border-radius: 50%;
197
- opacity: 0.55;
198
- }
199
- .er-title h1 {
200
- position: relative;
201
- font-size: clamp(52px, 8vw, 104px);
202
- line-height: 0.86;
203
- margin: 0;
204
- letter-spacing: 0;
205
- font-weight: 820;
206
- color: var(--er-ink);
207
- text-shadow: 0 0 42px rgba(45,212,191,0.22);
208
- }
209
- .er-kicker {
210
- position: relative;
211
- align-self: end;
212
- color: var(--er-soft);
213
  font-size: 14px;
214
- line-height: 1.55;
215
- max-width: 520px;
216
- border-left: 1px solid rgba(45,212,191,0.38);
217
- padding-left: 18px;
218
- }
219
- .er-badges {
220
- position: relative;
221
- display: flex;
222
- flex-wrap: wrap;
223
- gap: 8px;
224
- margin-top: 18px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  }
226
- .er-badge {
227
- border: 1px solid rgba(148,219,233,0.20);
228
- border-radius: 999px;
229
- padding: 6px 10px;
230
- background: rgba(3,7,18,0.54);
231
- color: var(--er-soft);
232
- font-size: 12px;
233
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
234
- }
235
- .er-badge:first-child {
236
- border-color: var(--er-line-strong);
237
- color: #b8fff2;
238
- background: rgba(13,148,136,0.22);
239
- }
240
- .er-mission {
241
- margin-top: 14px;
242
- display: grid;
243
- grid-template-columns: repeat(3, minmax(0, 1fr));
244
- gap: 10px;
245
- }
246
- .er-metric {
247
- border: 1px solid rgba(148,219,233,0.16);
248
- border-radius: 14px;
249
- padding: 12px;
250
- background: rgba(4,13,25,0.54);
251
- }
252
- .er-metric strong {
253
- display: block;
254
- color: #b8fff2;
255
- font-size: 15px;
256
- }
257
- .er-metric span {
258
- display: block;
259
- color: var(--er-muted);
260
- font-size: 11px;
261
- margin-top: 3px;
262
  }
263
- .er-workspace {
264
- border: 1px solid rgba(148,219,233,0.18);
265
- border-radius: 18px;
266
- padding: 18px;
267
- background:
268
- radial-gradient(circle at 12% 0%, rgba(45,212,191,0.12), transparent 30%),
269
- radial-gradient(circle at 88% 24%, rgba(56,189,248,0.10), transparent 34%),
270
- linear-gradient(180deg, rgba(15,23,42,0.62), rgba(3,7,18,0.42));
271
- box-shadow: 0 30px 95px rgba(0,0,0,0.38);
272
- backdrop-filter: blur(12px);
273
- }
274
- .er-workspace::before {
275
- content: "LIVE SUPPORT ROUTER";
276
- display: block;
277
- color: #99f6e4;
278
- letter-spacing: 0.13em;
279
- font-size: 11px;
280
- margin-bottom: 12px;
 
 
 
 
 
 
 
 
 
 
 
281
  }
282
- .er-side {
 
 
 
 
 
283
  position: sticky;
284
- top: 10px;
285
- }
286
- .er-card {
287
- border: 1px solid rgba(148,219,233,0.18);
 
288
  border-radius: 16px;
289
- background: var(--er-panel);
290
- padding: 14px;
291
- box-shadow:
292
- 0 18px 55px rgba(0,0,0,0.26),
293
- inset 0 1px 0 rgba(255,255,255,0.04);
294
- backdrop-filter: blur(16px) saturate(135%);
295
- color: var(--er-ink);
296
- margin-bottom: 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  .er-mini-title {
299
  font-size: 11px;
300
- color: #a7fff1;
301
  letter-spacing: 0.08em;
302
  text-transform: uppercase;
303
- margin-bottom: 8px;
 
304
  }
305
  .er-empty {
306
- color: var(--er-muted);
307
  font-size: 13px;
308
- padding: 10px 2px;
309
- }
310
- .er-status-grid {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  display: grid;
312
  grid-template-columns: 1fr 1fr;
313
  gap: 8px;
314
  }
315
- .er-status {
316
- border: 1px solid rgba(148,219,233,0.16);
317
- border-radius: 12px;
318
- padding: 10px;
319
- background: var(--er-panel-2);
 
320
  }
321
- .er-status span {
322
- display: block;
323
- color: var(--er-muted);
324
- font-size: 11px;
325
- margin-bottom: 3px;
 
 
326
  }
327
- .er-status strong {
328
- font-size: 13px;
329
- color: var(--er-ink);
330
  }
331
- .er-source {
332
- border: 1px solid rgba(148,219,233,0.14);
333
- border-radius: 14px;
334
- padding: 11px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  margin-top: 10px;
336
- background:
337
- linear-gradient(135deg, rgba(45,212,191,0.08), rgba(14,33,52,0.56));
338
  }
339
- .er-source-title {
340
- font-weight: 680;
341
- font-size: 13px;
342
- margin-bottom: 3px;
343
- color: var(--er-ink);
 
 
 
 
344
  }
345
- .er-source-meta {
346
- color: var(--er-muted);
347
- font-size: 12px;
348
- line-height: 1.35;
 
 
 
 
 
 
 
 
 
349
  }
350
- .er-chip-row {
351
- display: flex;
352
- flex-wrap: wrap;
 
 
 
353
  gap: 5px;
354
- margin-top: 7px;
 
 
355
  }
356
- .er-chip {
357
- border: 1px solid rgba(148,219,233,0.18);
358
- border-radius: 999px;
359
- padding: 4px 8px;
360
- font-size: 11px;
361
- color: var(--er-soft);
362
- background: rgba(3,7,18,0.42);
363
- }
364
- .er-chip-risk {
365
- color: #fcd34d;
366
- border-color: rgba(245,158,11,0.34);
367
- background: rgba(245,158,11,0.14);
368
- }
369
- .er-chip-crisis {
370
- color: #fecdd3;
371
- border-color: rgba(251,113,133,0.38);
372
- background: rgba(251,113,133,0.14);
373
- }
374
- .er-link {
375
- color: #67e8f9;
376
- font-weight: 620;
377
- text-decoration: none;
378
- }
379
- .er-link:hover {
380
- text-decoration: underline;
381
- }
382
- .er-prompt-row button {
383
- min-height: 44px !important;
384
- border-radius: 14px !important;
385
- font-size: 12px !important;
386
- background:
387
- linear-gradient(180deg, rgba(30,64,92,0.72), rgba(8,20,34,0.84)) !important;
388
- color: var(--er-ink) !important;
389
- border: 1px solid rgba(148,219,233,0.20) !important;
390
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
391
  }
392
- .er-prompt-row button:hover {
393
- border-color: var(--er-line-strong) !important;
394
- background:
395
- linear-gradient(180deg, rgba(20,184,166,0.22), rgba(8,20,34,0.88)) !important;
 
396
  }
397
- .er-send button {
398
- min-height: 46px !important;
399
- border-radius: 14px !important;
400
- }
401
- textarea, input {
402
- border-radius: 14px !important;
403
- background: rgba(3,7,18,0.68) !important;
404
- color: var(--er-ink) !important;
405
- border: 1px solid rgba(148,219,233,0.20) !important;
406
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
407
  }
408
- textarea::placeholder, input::placeholder {
409
- color: #74869c !important;
 
 
410
  }
411
- button.primary, .primary {
412
- background: linear-gradient(135deg, #0d9488, #0891b2 54%, #2563eb) !important;
413
- color: #ecfeff !important;
414
- border: 1px solid rgba(103,232,249,0.42) !important;
415
- box-shadow: 0 18px 44px rgba(14,165,233,0.26);
416
  }
417
- button.secondary {
418
- background: rgba(30,41,59,0.88) !important;
419
- color: var(--er-ink) !important;
 
420
  }
421
- .gradio-container .chatbot {
422
- background:
423
- linear-gradient(180deg, rgba(3,7,18,0.52), rgba(8,20,34,0.70)) !important;
424
- border: 1px solid rgba(148,219,233,0.18) !important;
425
- border-radius: 18px !important;
426
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 24px 70px rgba(0,0,0,0.20);
427
- min-height: 430px !important;
428
- }
429
- .gradio-container .message,
430
- .gradio-container .bubble-wrap .message,
431
- .gradio-container .user,
432
- .gradio-container .bot {
433
- color: var(--er-ink) !important;
434
- }
435
- .gradio-container .message.user {
436
- background: linear-gradient(135deg, rgba(13,148,136,0.30), rgba(14,116,144,0.22)) !important;
437
- border: 1px solid rgba(45,212,191,0.22) !important;
438
- }
439
- .gradio-container .message.bot {
440
- background: rgba(15,23,42,0.92) !important;
441
- border: 1px solid rgba(148,219,233,0.16) !important;
442
- }
443
- .bubble-wrap .message {
444
- border-radius: 16px !important;
445
- }
446
- .er-terminal-note {
447
- color: #a7fff1;
448
- border: 1px solid rgba(45,212,191,0.18);
449
- border-radius: 14px;
450
- padding: 10px 12px;
451
- background: rgba(3,7,18,0.44);
452
- font-size: 12px;
453
- margin-top: 10px;
454
- margin-bottom: 12px;
455
  }
456
- .er-live-rail {
457
- display: grid;
458
- grid-template-columns: repeat(5, minmax(0, 1fr));
459
- gap: 8px;
460
- margin-top: 18px;
461
  position: relative;
462
  }
463
- .er-live-step {
464
- border: 1px solid rgba(148,219,233,0.18);
465
- border-radius: 14px;
466
- padding: 10px;
467
- background: rgba(3,7,18,0.46);
468
- min-height: 68px;
469
- }
470
- .er-live-step span {
471
- display: block;
472
- color: var(--er-dim);
473
- font-size: 10px;
474
- letter-spacing: 0.08em;
475
- text-transform: uppercase;
476
- margin-bottom: 5px;
477
- }
478
- .er-live-step strong {
479
- display: block;
480
- color: var(--er-ink);
481
- font-size: 12px;
482
- line-height: 1.25;
483
- }
484
- .er-live-step.active {
485
- border-color: rgba(45,212,191,0.48);
486
- background: linear-gradient(135deg, rgba(13,148,136,0.26), rgba(3,7,18,0.50));
487
- box-shadow: 0 0 28px rgba(45,212,191,0.10), inset 0 1px 0 rgba(255,255,255,0.06);
488
- }
489
- .er-state-strip {
490
- display: grid;
491
- grid-template-columns: repeat(4, minmax(0, 1fr));
492
- gap: 10px;
493
- margin-bottom: 14px;
494
- }
495
- .er-state-pill {
496
- border: 1px solid rgba(148,219,233,0.16);
497
- border-radius: 14px;
498
- padding: 10px 12px;
499
- background: rgba(3,7,18,0.46);
500
- }
501
- .er-state-pill span {
502
- display: block;
503
- color: var(--er-dim);
504
- font-size: 10px;
505
- letter-spacing: 0.08em;
506
- text-transform: uppercase;
507
- margin-bottom: 4px;
508
- }
509
- .er-state-pill strong {
510
- color: var(--er-ink);
511
- font-size: 13px;
512
  }
513
- .er-crisis-banner {
514
- border: 1px solid rgba(251,113,133,0.40);
515
- border-radius: 14px;
516
- padding: 12px;
517
- margin-bottom: 10px;
518
- background:
519
- linear-gradient(135deg, rgba(251,113,133,0.16), rgba(15,23,42,0.82));
520
  }
521
- .er-crisis-banner strong {
522
- display: block;
523
- color: #fecdd3;
524
- font-size: 14px;
525
- margin-bottom: 4px;
 
526
  }
527
- .er-crisis-banner span {
528
- color: var(--er-soft);
529
- font-size: 12px;
 
530
  }
531
- .er-route {
532
- border: 1px solid rgba(45,212,191,0.28);
533
- border-radius: 14px;
534
- padding: 12px;
535
- margin-top: 10px;
536
- background:
537
- linear-gradient(135deg, rgba(20,184,166,0.16), rgba(14,33,52,0.72));
538
  }
539
- .er-route strong {
540
- display: block;
541
- color: #a7fff1;
542
- font-size: 13px;
543
- margin-bottom: 4px;
 
 
 
544
  }
545
- .er-route span {
546
- display: block;
547
- color: var(--er-soft);
548
- font-size: 12px;
549
- line-height: 1.45;
 
550
  }
551
- .er-action-card {
552
- border: 1px solid rgba(45,212,191,0.42);
553
- border-radius: 16px;
554
- padding: 13px;
555
- margin-top: 10px;
556
- background:
557
- linear-gradient(135deg, rgba(20,184,166,0.18), rgba(8,47,73,0.48)),
558
- rgba(3,7,18,0.42);
559
- box-shadow: 0 16px 44px rgba(13,148,136,0.12);
560
- }
561
- .er-action-card span {
562
- display: block;
563
- color: #99f6e4;
564
- font-size: 10px;
565
- letter-spacing: 0.12em;
566
- text-transform: uppercase;
567
- margin-bottom: 5px;
568
  }
569
- .er-action-card strong {
570
- display: block;
571
- color: var(--er-ink);
572
- font-size: 14px;
573
- line-height: 1.4;
574
  }
575
- .er-decision-grid {
576
- display: grid;
577
- gap: 8px;
 
578
  }
579
- .er-decision-step {
580
- border: 1px solid rgba(148,219,233,0.16);
581
- border-radius: 13px;
582
- padding: 10px;
583
- background: rgba(3,7,18,0.48);
584
  }
585
- .er-decision-step span {
586
- display: block;
587
- color: var(--er-muted);
588
- font-size: 10px;
589
- letter-spacing: 0.08em;
590
  text-transform: uppercase;
591
- margin-bottom: 3px;
592
- }
593
- .er-decision-step strong {
594
- display: block;
595
- color: var(--er-ink);
596
- font-size: 13px;
597
  }
598
- .er-decision-step small {
599
- display: block;
600
- color: var(--er-muted);
 
 
 
601
  font-size: 11px;
602
- line-height: 1.35;
603
- margin-top: 4px;
604
- }
605
- .er-meter {
606
- height: 6px;
607
  border-radius: 999px;
608
- background: rgba(15,23,42,0.88);
609
- border: 1px solid rgba(148,219,233,0.12);
610
- overflow: hidden;
611
- margin-top: 7px;
612
- }
613
- .er-meter-fill {
614
- height: 100%;
615
- border-radius: inherit;
616
- background: linear-gradient(90deg, #14b8a6, #22d3ee);
617
- box-shadow: 0 0 18px rgba(34,211,238,0.30);
618
  }
619
- .er-decision-step.ok {
620
- border-color: rgba(45,212,191,0.34);
621
- }
622
- .er-decision-step.warn {
623
- border-color: rgba(245,158,11,0.42);
624
- }
625
- .er-decision-step.stop {
626
- border-color: rgba(251,113,133,0.46);
627
- background: linear-gradient(135deg, rgba(251,113,133,0.14), rgba(3,7,18,0.52));
628
- }
629
- .er-why {
630
- margin-top: 8px;
631
- color: #a7fff1;
632
- font-size: 11px;
633
- line-height: 1.35;
634
- }
635
- .er-demo-arc {
636
- display: grid;
637
- grid-template-columns: repeat(5, minmax(0, 1fr));
638
- gap: 8px;
639
- margin: 6px 0 12px;
640
- }
641
- .er-demo-card {
642
- border: 1px solid rgba(148,219,233,0.16);
643
- border-radius: 14px;
644
- padding: 10px;
645
- min-height: 78px;
646
- background:
647
- linear-gradient(145deg, rgba(45,212,191,0.08), rgba(3,7,18,0.52));
648
- }
649
- .er-demo-card span {
650
- display: block;
651
- color: #99f6e4;
652
- font-size: 10px;
653
- letter-spacing: 0.10em;
654
- text-transform: uppercase;
655
- margin-bottom: 4px;
656
- }
657
- .er-demo-card strong {
658
- display: block;
659
- color: var(--er-ink);
660
- font-size: 12px;
661
- line-height: 1.25;
662
- }
663
- .er-demo-card small {
664
- display: block;
665
- color: var(--er-muted);
666
- font-size: 11px;
667
- line-height: 1.25;
668
- margin-top: 4px;
669
- }
670
- .footer, .built-with, .api-docs, footer {
671
- display: none !important;
672
- }
673
- @media (max-width: 900px) {
674
- .er-title {
675
- grid-template-columns: 1fr;
676
- }
677
- .er-side {
678
- position: static;
679
- }
680
- .er-mission {
681
- grid-template-columns: 1fr;
682
- }
683
- .er-state-strip {
684
- grid-template-columns: 1fr 1fr;
685
- }
686
- .er-live-rail {
687
- grid-template-columns: 1fr;
688
- }
689
- .er-demo-arc {
690
- grid-template-columns: 1fr;
691
- }
692
  }
693
  """
694
 
@@ -1330,35 +1384,19 @@ def log_turn(session_id, turn, user_message, result):
1330
  def format_emotion_timeline(history, trajectory) -> str:
1331
  if not history:
1332
  return (
1333
- "<div class='er-card'><div class='er-mini-title'>Emotion Timeline</div>"
1334
- "<div class='er-empty'>Waiting for the first turn.</div></div>"
1335
- )
1336
-
1337
- trajectory_badge_colors = {
1338
- "stable": "#64748b",
1339
- "stable_positive": "#047857",
1340
- "stable_negative": "#b42318",
1341
- "escalating": "#b42318",
1342
- "de_escalating": "#0f766e",
1343
- "volatile": "#b45309",
1344
- }
1345
-
1346
- traj_color = trajectory_badge_colors.get(trajectory, "#64748b")
1347
- html = "<div class='er-card'><div class='er-mini-title'>Emotion Timeline</div>"
1348
- html += (
1349
- f"<div style='margin-bottom:10px;padding:7px 10px;background:{traj_color};"
1350
- "color:white;border-radius:8px;font-size:12px;font-weight:650;'>"
1351
- f"Session: {escape(str(trajectory))}</div>"
1352
- )
1353
- html += "<div style='display:flex;flex-wrap:wrap;gap:6px;'>"
1354
- for item in history:
1355
- label = escape(str(item["label_name"]))
1356
- turn = escape(str(item["turn"]))
1357
- color = escape(str(item["color"]))
1358
- html += (
1359
- f"<span style='padding:5px 8px;background:{color};color:white;"
1360
- f"border-radius:999px;font-size:11px;'>T{turn}: {label}</span>"
1361
  )
 
 
 
 
 
 
 
 
 
 
1362
  html += "</div></div>"
1363
  return html
1364
 
@@ -1366,196 +1404,184 @@ def format_emotion_timeline(history, trajectory) -> str:
1366
  def format_ig_panel(is_crisis, confidence, ig_tokens, loading, explanation_reason="") -> str:
1367
  if not is_crisis:
1368
  return (
1369
- "<div class='er-card'><div class='er-mini-title'>Safety Guardrail</div>"
1370
- "<div class='er-empty'>No crisis intercept on this turn.</div></div>"
1371
  )
1372
-
1373
- if loading:
1374
- return (
1375
- "<div class='er-card' style='border-color:rgba(180,35,24,0.26);'>"
1376
- "<div class='er-mini-title'>Safety Guardrail</div>"
1377
- f"<div style='font-weight:700;color:var(--er-danger);margin-bottom:4px;'>"
1378
- f"Crisis signal detected - {confidence:.1%}</div>"
1379
- "<div class='er-empty'>Computing token attributions...</div></div>"
1380
- )
1381
-
1382
- conf_pct = int(confidence * 100)
1383
- html = "<div class='er-card' style='border-color:rgba(180,35,24,0.26);'>"
1384
- html += "<div class='er-mini-title'>Safety Guardrail</div>"
1385
  html += (
1386
- f"<div style='font-weight:700;color:var(--er-danger);margin-bottom:8px;'>"
1387
- f"Crisis Confidence: {confidence:.1%}</div>"
1388
  )
1389
- html += (
1390
- "<div style='background:rgba(3,7,18,0.72);height:8px;border-radius:999px;overflow:hidden;margin-bottom:10px;'>"
1391
- f"<div style='background:var(--er-danger);height:100%;width:{conf_pct}%;'></div></div>"
1392
- )
1393
-
1394
- if ig_tokens:
1395
- valid_tokens = [(tok, score) for tok, score in ig_tokens if tok.strip()]
1396
- if valid_tokens:
1397
- max_score = max(score for _, score in valid_tokens)
1398
- html += (
1399
- "<div style='font-size:11px;color:#fecdd3;margin-bottom:4px;font-weight:650;'>"
1400
- "Top Crisis Signals</div>"
1401
- )
1402
- html += "<div style='display:flex;flex-wrap:wrap;gap:4px;'>"
1403
- for tok, score in valid_tokens[:10]:
1404
- opacity = score / max_score if max_score > 0 else 0.5
1405
- bg_color = f"rgba(180,35,24,{opacity:.2f})"
1406
- html += (
1407
- f"<span style='padding:3px 7px;background:{bg_color};"
1408
- f"border:1px solid #b42318;border-radius:999px;font-size:10px;'>"
1409
- f"{escape(tok)}</span>"
1410
- )
1411
  html += "</div>"
1412
  elif explanation_reason:
1413
  html += (
1414
- "<div class='er-source-meta' style='margin-top:8px;'>"
1415
- f"Explanation source: {escape(str(explanation_reason))}</div>"
1416
  )
1417
-
1418
  html += "</div>"
1419
  return html
1420
 
1421
 
1422
  def format_decision_trace(result=None) -> str:
 
1423
  if not result:
1424
  return (
1425
- "<div class='er-card'><div class='er-mini-title'>Support Map</div>"
1426
- "<div class='er-empty'>Run a prompt to see the support path, safety check, and next action.</div></div>"
1427
  )
1428
-
1429
  route_label = str(result.get("route_label", "unknown"))
1430
  safety_tier = str(result.get("safety_tier", "unknown"))
1431
- route_text = escape(_pretty_route(route_label))
1432
- tier_text = escape(_pretty_tier(safety_tier))
1433
  should_intercept = bool(result.get("crisis") or result.get("should_intercept"))
1434
- retrieval_mode = escape(str(result.get("retrieval_mode", "unknown")))
1435
  recommended_action = escape(str(result.get("recommended_action", "")))
1436
- precheck = result.get("safety_precheck", {}) or {}
1437
- precheck_reason = escape(_pretty_reason(str(precheck.get("reason", "not_recorded"))))
1438
- precheck_level = escape(_pretty_precheck(str(precheck.get("level", "unknown")), should_intercept))
1439
- classifier = result.get("classifier_confidence", {}) or {}
1440
- classifier_kind = "learned router" if classifier.get("used_ml") else "safety fallback"
1441
- route_conf = float(classifier.get("route", 0.0) or 0.0)
1442
- tier_conf = float(classifier.get("tier", 0.0) or 0.0)
1443
- output_guard = result.get("output_guard", {}) or {}
1444
- guard_reason = escape(_pretty_reason(str(output_guard.get("reason", "not_checked"))))
1445
- guard_flags = output_guard.get("flags", []) or []
1446
- source_count = len(result.get("retrieved_sources", []) or [])
1447
- stop_class = "stop" if should_intercept else "ok"
1448
- guard_class = "warn" if guard_flags else "ok"
1449
- latency = result.get("latency_ms", {}) or {}
1450
- total_latency = float(latency.get("total_ms", 0.0) or 0.0)
1451
- route_width = max(4, min(100, int(route_conf * 100)))
1452
- tier_width = max(4, min(100, int(tier_conf * 100)))
1453
 
1454
- return (
1455
- "<div class='er-card'>"
1456
- "<div class='er-mini-title'>Support Map</div>"
1457
- "<div class='er-decision-grid'>"
1458
- f"<div class='er-decision-step {stop_class}'><span>Safety check</span><strong>{precheck_level}</strong><small>{precheck_reason}</small></div>"
1459
- f"<div class='er-decision-step ok'><span>Support path</span><strong>{route_text}</strong><small>{tier_text} · {classifier_kind}</small><div class='er-meter'><div class='er-meter-fill' style='width:{route_width}%'></div></div></div>"
1460
- f"<div class='er-decision-step ok'><span>Grounding</span><strong>{source_count} resource cards</strong><small>{_pretty_retrieval_mode(retrieval_mode)}</small></div>"
1461
- f"<div class='er-decision-step {guard_class}'><span>Response check</span><strong>{guard_reason}</strong><small>{', '.join(map(str, guard_flags)) if guard_flags else 'ready to show'}</small></div>"
1462
- f"<div class='er-decision-step ok'><span>Speed</span><strong>{total_latency:.1f} ms</strong><small>local MVP path</small></div>"
1463
- "</div>"
1464
- f"<div class='er-action-card'><span>Next helpful move</span><strong>{recommended_action or 'Waiting for route decision.'}</strong></div>"
1465
- "</div>"
1466
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1467
 
1468
 
1469
  def format_retrieval_panel(result=None) -> str:
 
1470
  if not result:
1471
  return (
1472
- "<div class='er-card'><div class='er-mini-title'>Grounded Resources</div>"
1473
- "<div class='er-empty'>Resources will appear here after the support path is chosen.</div></div>"
1474
  )
1475
-
1476
- safety_level = escape(_pretty_tier(str(result.get("safety_level", "unknown"))))
1477
- safety_reason = escape(_pretty_reason(str(result.get("safety_reason", ""))))
1478
- safety_tier = escape(_pretty_tier(str(result.get("safety_tier", "unknown"))))
1479
- escalation_reason = escape(str(result.get("escalation_reason", "")))
1480
- corpus = escape(str(result.get("retrieval_corpus", "unknown")))
1481
- route_label = escape(_pretty_route(str(result.get("route_label", "student-support"))))
1482
- recommended_action = escape(str(result.get("recommended_action", "")))
1483
  output_guard = result.get("output_guard", {}) or {}
1484
- output_guard_reason = escape(_pretty_reason(str(output_guard.get("reason", "not_checked"))))
 
1485
  safety_precheck = result.get("safety_precheck", {}) or {}
1486
- precheck_reason = escape(_pretty_reason(str(safety_precheck.get("reason", "not_recorded"))))
1487
- precheck_level = escape(_pretty_precheck(str(safety_precheck.get("level", "unknown")), bool(result.get("crisis"))))
1488
- safety_explanation = result.get("safety_explanation", {}) or {}
1489
- explanation_reason = escape(str(safety_explanation.get("reason", "not_checked")))
1490
- classifier_confidence = result.get("classifier_confidence", {}) or {}
1491
- route_conf = float(classifier_confidence.get("route", 0.0) or 0.0)
1492
- tier_conf = float(classifier_confidence.get("tier", 0.0) or 0.0)
1493
- classifier_label = "learned" if classifier_confidence.get("used_ml") else "fallback"
1494
- retrieval_mode = escape(str(result.get("retrieval_mode", "registry_filtered_faiss_plus_router")))
1495
- html = (
1496
- "<div class='er-card'>"
1497
- "<div class='er-mini-title'>Grounded Resources</div>"
1498
- "<div class='er-status-grid'>"
1499
- f"<div class='er-status'><span>Corpus</span><strong>{corpus}</strong></div>"
1500
- f"<div class='er-status'><span>Tier</span><strong>{safety_tier}</strong></div>"
1501
- f"<div class='er-status'><span>Safety</span><strong>{safety_level}</strong></div>"
1502
- f"<div class='er-status'><span>Response</span><strong>{output_guard_reason}</strong></div>"
1503
- f"<div class='er-status'><span>Classifier</span><strong>{classifier_label} {route_conf:.2f}/{tier_conf:.2f}</strong></div>"
1504
- f"<div class='er-status'><span>Retrieval</span><strong>{_pretty_retrieval_mode(retrieval_mode)}</strong></div>"
1505
- f"<div class='er-status'><span>Safety check</span><strong>{precheck_level}</strong></div>"
1506
- f"<div class='er-status'><span>Model guard</span><strong>{_pretty_reason(explanation_reason)}</strong></div>"
1507
- "</div>"
1508
- f"<div class='er-source-meta' style='margin-top:8px;'>Why this path: {safety_reason}; safety check: {precheck_reason}</div>"
1509
- "<div class='er-route'>"
1510
- f"<strong>{route_label}</strong>"
1511
- f"<span>{recommended_action}</span>"
1512
- "</div>"
1513
  )
1514
- if escalation_reason:
1515
- html += f"<div class='er-source-meta' style='margin-top:8px;'>Escalation: {escalation_reason}</div>"
1516
-
1517
- if safety_level in {"crisis", "emergency"}:
1518
- html += (
1519
- "<div class='er-crisis-banner'>"
1520
- "<strong>Normal generation intercepted</strong>"
1521
- "<span>Crisis resources are shown as source cards; the chat response uses the safety template.</span>"
1522
- "</div>"
1523
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1524
 
1525
- sources = result.get("retrieved_sources", [])
1526
- if not sources:
1527
- html += "<div class='er-empty'>No sources retrieved for this turn.</div></div>"
1528
- return html
1529
-
1530
- for source in sources[:5]:
1531
- title = escape(str(source.get("title", "") or "Untitled source"))
1532
- source_name = escape(str(source.get("source_name", "") or "Unknown source"))
1533
- topic = escape(str(source.get("topic", "") or ""))
1534
- risk = escape(str(source.get("risk_level", "") or ""))
1535
- usage = escape(str(source.get("usage_mode", "") or ""))
1536
- source_type = escape(str(source.get("source_type", "") or ""))
1537
- why = escape(str(source.get("why_retrieved", "") or "matched prompt intent"))
1538
- url = escape(str(source.get("url", "") or ""))
1539
- risk_class = "er-chip-crisis" if "crisis" in risk else "er-chip-risk" if risk else ""
1540
- html += (
1541
- "<div class='er-source'>"
1542
- f"<div class='er-source-title'>{title}</div>"
1543
- f"<div class='er-source-meta'>{source_name}</div>"
1544
- "<div class='er-chip-row'>"
1545
- f"<span class='er-chip'>{topic}</span>"
1546
- f"<span class='er-chip {risk_class}'>{risk}</span>"
1547
- f"<span class='er-chip'>{usage}</span>"
1548
- f"<span class='er-chip'>{source_type}</span>"
1549
- "</div>"
1550
- f"<div class='er-why'>Why shown: {_pretty_reason(why)}</div>"
1551
- )
1552
- if url:
1553
- html += f"<div style='margin-top:7px;'><a class='er-link' href='{url}' target='_blank'>Open source</a></div>"
1554
- html += "</div>"
1555
  html += "</div>"
1556
  return html
1557
 
1558
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1559
  def respond(message, chat_history, session_state, audience_mode):
1560
  if not session_state:
1561
  session_state = new_session_state()
@@ -1576,6 +1602,23 @@ def respond(message, chat_history, session_state, audience_mode):
1576
  )
1577
  return
1578
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1579
  with pipeline_lock:
1580
  active_pipeline = get_pipeline()
1581
  if hasattr(active_pipeline, "tracker"):
@@ -1599,7 +1642,7 @@ def respond(message, chat_history, session_state, audience_mode):
1599
  session_state["tracker_history"] = session_state.get("tracker_history", []) + [result["emotion"]]
1600
  session_state["conv_history"] = session_state.get("conv_history", [])
1601
 
1602
- chat_history.append((message, result["response"]))
1603
  emotion_history.append(
1604
  {
1605
  "turn": len(emotion_history) + 1,
@@ -1607,63 +1650,55 @@ def respond(message, chat_history, session_state, audience_mode):
1607
  "color": LABEL_COLORS[result["emotion_name"]],
1608
  }
1609
  )
1610
-
1611
  log_turn(session_id, len(emotion_history), message, result)
1612
  timeline_html = format_emotion_timeline(emotion_history, result["trajectory"])
 
 
1613
 
1614
- if result["crisis"]:
1615
- safety_explanation = result.get("safety_explanation", {}) or {}
1616
- explanation_available = bool(safety_explanation.get("available"))
1617
- ig_tokens = safety_explanation.get("ig_tokens") or []
1618
- explanation_reason = safety_explanation.get("reason", "")
1619
- if not hasattr(get_pipeline(), "guardrail") and not explanation_available:
1620
- yield (
1621
- chat_history,
1622
- format_decision_trace(result),
1623
- timeline_html,
1624
- result["trajectory"],
1625
- format_ig_panel(True, result["crisis_confidence"], ig_tokens, loading=False, explanation_reason=explanation_reason),
1626
- format_retrieval_panel(result),
1627
- session_id,
1628
- session_state,
1629
- )
1630
- return
1631
  yield (
1632
  chat_history,
1633
- format_decision_trace(result),
1634
  timeline_html,
1635
  result["trajectory"],
1636
- format_ig_panel(True, result["crisis_confidence"], ig_tokens, loading=True),
1637
- format_retrieval_panel(result),
1638
  session_id,
1639
  session_state,
1640
  )
1641
 
 
 
1642
  with pipeline_lock:
1643
  active_pipeline = get_pipeline()
1644
  if hasattr(active_pipeline, "guardrail"):
1645
- _, confidence, ig_tokens = active_pipeline.guardrail.check(message, threshold=0.5, skip_ig=False)
 
 
1646
  else:
1647
  confidence, ig_tokens = result["crisis_confidence"], []
1648
-
1649
  yield (
1650
  chat_history,
1651
- format_decision_trace(result),
1652
  timeline_html,
1653
  result["trajectory"],
1654
  format_ig_panel(True, confidence, ig_tokens, loading=False),
1655
- format_retrieval_panel(result),
1656
- session_id,
1657
- session_state,
1658
- )
1659
- else:
1660
- yield (
1661
- chat_history,
1662
- format_decision_trace(result),
1663
- timeline_html,
1664
- result["trajectory"],
1665
- format_ig_panel(False, 0.0, [], False),
1666
- format_retrieval_panel(result),
1667
  session_id,
1668
  session_state,
1669
  )
@@ -1756,121 +1791,128 @@ def _pretty_retrieval_mode(mode: str) -> str:
1756
  return mode.replace("_", " ")
1757
 
1758
 
1759
- theme = gr.themes.Soft(
1760
  primary_hue="teal",
1761
- secondary_hue="amber",
1762
- neutral_hue="stone",
1763
- radius_size="sm",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1764
  )
1765
 
1766
- with gr.Blocks(theme=theme, title="EmpathRAG Core", css=APP_CSS) as demo:
 
1767
  initial_state = new_session_state()
1768
  session_state = gr.State(value=initial_state)
 
1769
 
1770
- gr.HTML(
1771
- f"""
1772
- <div class="er-shell">
1773
- <div class="er-title">
1774
- <div>
1775
- <h1>EmpathRAG Core</h1>
1776
- <div class="er-badges">
1777
- <span class="er-badge">Guarded conversational RAG</span>
1778
- <span class="er-badge">{escape(RETRIEVAL_CORPUS)}</span>
1779
- <span class="er-badge">logging off by default</span>
1780
- </div>
1781
- <div class="er-mission">
1782
- <div class="er-metric"><strong>177</strong><span>curated support chunks</span></div>
1783
- <div class="er-metric"><strong>gated</strong><span>retrieval by usage mode</span></div>
1784
- <div class="er-metric"><strong>fail-closed</strong><span>safety-first pipeline</span></div>
1785
- </div>
1786
- <div class="er-live-rail">
1787
- <div class="er-live-step active"><span>Stage 1</span><strong>Lexical safety precheck</strong></div>
1788
- <div class="er-live-step"><span>Stage 2</span><strong>Hybrid route / tier router</strong></div>
1789
- <div class="er-live-step"><span>Stage 3</span><strong>Resource registry filter</strong></div>
1790
- <div class="er-live-step"><span>Stage 4</span><strong>Output guard</strong></div>
1791
- <div class="er-live-step"><span>Stage 5</span><strong>Visible next action</strong></div>
1792
- </div>
1793
- </div>
1794
- <div class="er-kicker">
1795
- Guarded conversational RAG for emotional and student-support navigation.
1796
- This prototype is not therapy, diagnosis, or emergency care.
1797
  </div>
1798
- </div>
1799
- </div>
1800
- """
1801
- )
1802
-
1803
- session_id_box = gr.Textbox(
1804
- label="Session ID",
1805
- interactive=False,
1806
- value=initial_state["session_id"],
1807
- )
1808
- audience_mode_box = gr.Radio(
1809
- choices=[("Student", "student"), ("Helping a friend", "helping_friend")],
1810
- value="student",
1811
- label="Support mode",
1812
- interactive=True,
1813
- )
1814
- gr.HTML(
1815
- "<div class='er-terminal-note'>Use <strong>Helping a friend</strong> when the prompt is about a roommate, labmate, teammate, or someone else. The response will emphasize escalation and not handling safety risk alone.</div>"
1816
- )
1817
 
1818
- gr.HTML(
1819
- f"""
1820
- <div class="er-state-strip">
1821
- <div class="er-state-pill"><span>Backend</span><strong>hybrid_ml</strong></div>
1822
- <div class="er-state-pill"><span>Corpus</span><strong>{escape(RETRIEVAL_CORPUS)}</strong></div>
1823
- <div class="er-state-pill"><span>Retrieval</span><strong>registry-filtered</strong></div>
1824
- <div class="er-state-pill"><span>Logging</span><strong>{"on" if LOG_TURNS else "off"}</strong></div>
1825
- </div>
1826
  """
 
 
 
 
 
 
 
1827
  )
1828
 
1829
- with gr.Row(elem_classes=["er-workspace"]):
1830
- with gr.Column(scale=2):
1831
- chatbot = gr.Chatbot(label="Conversation", height=500, bubble_full_width=False)
1832
- note = (
1833
- "MVP mode is active: local routing, grounded resources, and safety checks are running together."
1834
- if DEMO_BACKEND != "real"
1835
- else "Full local model stack is active; first response may prewarm models."
1836
- )
1837
- gr.HTML(f"<div class='er-terminal-note'>{escape(note)}</div>")
1838
- gr.HTML(
1839
- """
1840
- <div class="er-demo-arc">
1841
- <div class="er-demo-card"><span>1</span><strong>Usefulness</strong><small>Turns worry into one next move</small></div>
1842
- <div class="er-demo-card"><span>2</span><strong>Continuity</strong><small>Tracks escalation across turns</small></div>
1843
- <div class="er-demo-card"><span>3</span><strong>Peer mode</strong><small>Guides helping someone else</small></div>
1844
- <div class="er-demo-card"><span>4</span><strong>Boundaries</strong><small>Refuses medical/legal pretending</small></div>
1845
- <div class="er-demo-card"><span>5</span><strong>Calibration</strong><small>Does not panic on idioms</small></div>
1846
- </div>
1847
- """
1848
- )
1849
- with gr.Row(elem_classes=["er-prompt-row"]):
1850
- prompt_counseling = gr.Button("Start counseling")
1851
- prompt_ads = gr.Button("ADS accommodations")
1852
- prompt_ombuds = gr.Button("Advisor conflict")
1853
- prompt_grounding = gr.Button("Grounding help")
1854
- prompt_crisis = gr.Button("Crisis redirect")
1855
- with gr.Row(elem_classes=["er-prompt-row"]):
1856
- prompt_friend = gr.Button("Helping a friend")
1857
- prompt_scope = gr.Button("Out of scope")
1858
- msg_box = gr.Textbox(
1859
- placeholder="Type a student-support prompt...",
1860
- label="",
1861
- autofocus=True,
1862
- )
1863
- with gr.Row(elem_classes=["er-send"]):
1864
- send_btn = gr.Button("Send", variant="primary")
1865
- reset_btn = gr.Button("Reset Session")
1866
 
1867
- with gr.Column(scale=1, elem_classes=["er-side"]):
1868
- decision_out = gr.HTML(value=format_decision_trace())
1869
- timeline_out = gr.HTML(value=format_emotion_timeline([], "stable"))
1870
- trajectory_out = gr.Textbox(label="Trajectory", value="stable", interactive=False)
1871
- crisis_out = gr.HTML(value=format_ig_panel(False, 0.0, [], False))
1872
- retrieval_out = gr.HTML(value=format_retrieval_panel())
 
 
 
 
 
1873
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1874
  submit_outputs = [
1875
  chatbot,
1876
  decision_out,
@@ -1880,52 +1922,70 @@ with gr.Blocks(theme=theme, title="EmpathRAG Core", css=APP_CSS) as demo:
1880
  retrieval_out,
1881
  session_id_box,
1882
  session_state,
 
 
1883
  ]
1884
 
 
 
 
 
 
 
1885
  msg_box.submit(
1886
- respond,
1887
  inputs=[msg_box, chatbot, session_state, audience_mode_box],
1888
  outputs=submit_outputs,
1889
  ).then(lambda: "", outputs=msg_box)
1890
 
1891
  send_btn.click(
1892
- respond,
1893
  inputs=[msg_box, chatbot, session_state, audience_mode_box],
1894
  outputs=submit_outputs,
1895
  ).then(lambda: "", outputs=msg_box)
1896
 
1897
- reset_btn.click(reset_session_handler, outputs=submit_outputs)
 
 
 
 
1898
 
1899
- prompt_counseling.click(
 
 
 
 
 
 
 
 
 
 
1900
  lambda: set_prompt("I think I need counseling at UMD, but I do not know how to start."),
1901
  outputs=msg_box,
1902
  )
1903
- prompt_ads.click(
1904
- lambda: set_prompt("I need disability accommodations for my graduate assistantship work at UMD."),
1905
  outputs=msg_box,
1906
  )
1907
- prompt_ombuds.click(
1908
  lambda: set_prompt("My advisor keeps dismissing my concerns and I need someone neutral to talk to."),
1909
  outputs=msg_box,
1910
  )
1911
- prompt_grounding.click(
1912
  lambda: set_prompt("I am panicking before my exam. Can you help me with a grounding exercise?"),
1913
  outputs=msg_box,
1914
  )
1915
- prompt_crisis.click(
1916
- lambda: set_prompt("I do not think I can stay safe tonight."),
1917
- outputs=msg_box,
1918
- )
1919
- prompt_friend.click(
1920
- lambda: set_prompt("My roommate said goodbye and locked their door, and I am scared they will not answer."),
1921
- outputs=msg_box,
1922
- )
1923
- prompt_scope.click(
1924
- lambda: set_prompt("Can you prescribe anxiety medication or write a legal complaint for me?"),
1925
  outputs=msg_box,
1926
  )
1927
 
1928
 
 
1929
  if __name__ == "__main__":
1930
  os.makedirs("eval", exist_ok=True)
1931
  demo.launch(share=SHARE_DEMO)
 
50
 
51
  APP_CSS = """
52
  :root {
53
+ --bg: #0a0c10;
54
+ --bg-soft: #0d1017;
55
+ --surface: #11151c;
56
+ --surface-2: #161c25;
57
+ --surface-3: #1d2531;
58
+ --border: rgba(255,255,255,0.06);
59
+ --border-mid: rgba(255,255,255,0.10);
60
+ --border-strong: rgba(255,255,255,0.16);
61
+ --accent: #5eead4;
62
+ --accent-dim: #2dd4bf;
63
+ --accent-soft: rgba(94,234,212,0.10);
64
+ --accent-line: rgba(94,234,212,0.22);
65
+ --accent-glow: rgba(94,234,212,0.20);
66
+ --text: #e7ecf2;
67
+ --text-muted: #8a93a3;
68
+ --text-dim: #5a6373;
69
+ --warm: #f5b669;
70
+ --warm-soft: rgba(245,182,105,0.10);
71
+ --danger: #f87171;
72
+ --radius-sm: 8px;
73
+ --radius: 12px;
74
+ --radius-lg: 16px;
75
+ }
76
+
77
+ * { box-sizing: border-box; }
78
 
79
  html, body {
80
+ background: var(--bg) !important;
81
+ color: var(--text) !important;
82
+ font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
83
+ -webkit-font-smoothing: antialiased;
84
+ -moz-osx-font-smoothing: grayscale;
85
+ letter-spacing: -0.005em;
 
86
  }
87
 
88
  body::before {
89
  content: "";
90
+ position: fixed; inset: 0;
91
+ pointer-events: none; z-index: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  background:
93
+ radial-gradient(900px 480px at 50% -20%, rgba(94,234,212,0.07), transparent 70%),
94
+ radial-gradient(600px 340px at 90% 110%, rgba(94,234,212,0.04), transparent 70%);
 
95
  }
96
 
97
  .gradio-container {
98
+ position: relative; z-index: 1;
 
 
99
  background: transparent !important;
100
+ max-width: 880px !important;
101
+ margin: 0 auto !important;
102
+ padding: 0 24px 48px !important;
103
+ color: var(--text) !important;
104
  }
105
 
106
+ .gradio-container * { border-color: var(--border); }
107
+ .gradio-container label, .gradio-container .label-wrap {
108
+ color: var(--text-muted) !important;
109
+ font-size: 12px !important;
110
+ font-weight: 500 !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  }
 
 
112
  .gradio-container .block,
113
  .gradio-container .form,
114
  .gradio-container .panel,
115
+ .gradio-container .wrap,
116
+ .gradio-container .contain,
117
  .gradio-container .tabs,
118
  .gradio-container .tabitem {
119
  background: transparent !important;
120
+ border: none !important;
121
+ box-shadow: none !important;
122
+ }
123
+
124
+ /* TOP BAR */
125
+ .er-topbar {
126
+ display: flex !important;
127
+ align-items: center !important;
128
+ justify-content: space-between !important;
129
+ gap: 16px !important;
130
+ padding: 22px 0 18px !important;
131
+ margin: 0 0 8px !important;
132
+ border-bottom: 1px solid var(--border) !important;
133
+ flex-wrap: nowrap !important;
134
+ }
135
+ .er-topbar > * { flex: none !important; }
136
+ .er-topbar > .er-mode-wrap { flex: 1 1 auto !important; display: flex; justify-content: center; }
137
+
138
+ .er-brand {
139
+ display: flex; align-items: center; gap: 10px;
140
+ font-weight: 600; font-size: 15.5px; letter-spacing: -0.01em;
141
+ color: var(--text);
142
+ }
143
+ .er-brand-dot {
144
+ width: 8px; height: 8px; border-radius: 50%;
145
+ background: var(--accent);
146
+ box-shadow: 0 0 14px var(--accent-glow);
147
+ animation: er-pulse 2.4s ease-in-out infinite;
148
+ }
149
+ .er-brand-meta {
150
+ color: var(--text-dim); font-size: 12.5px; font-weight: 400; margin-left: 4px;
151
+ }
152
+ @keyframes er-pulse {
153
+ 0%, 100% { opacity: 1; box-shadow: 0 0 14px var(--accent-glow); }
154
+ 50% { opacity: 0.6; box-shadow: 0 0 6px var(--accent-glow); }
155
+ }
156
+
157
+ /* SEGMENTED MODE TOGGLE (Radio) */
158
+ .gradio-container .er-mode-wrap { padding: 0 !important; }
159
+ .gradio-container .er-mode-wrap > .wrap,
160
+ .gradio-container .er-mode-wrap > .form { background: transparent !important; }
161
+ .gradio-container .er-mode-wrap fieldset,
162
+ .gradio-container .er-mode-wrap .wrap-inner {
163
+ display: inline-flex !important;
164
+ background: var(--surface) !important;
165
+ border: 1px solid var(--border) !important;
166
+ border-radius: 999px !important;
167
+ padding: 3px !important;
168
+ gap: 0 !important;
169
+ }
170
+ .gradio-container .er-mode-wrap label {
171
+ padding: 7px 16px !important;
172
+ border-radius: 999px !important;
173
+ font-size: 12.5px !important;
174
+ font-weight: 500 !important;
175
+ cursor: pointer;
176
+ transition: color 180ms ease, background 180ms ease;
177
+ color: var(--text-muted) !important;
178
+ background: transparent !important;
179
+ border: none !important;
180
+ margin: 0 !important;
181
+ display: inline-flex !important;
182
+ align-items: center;
183
  }
184
+ .gradio-container .er-mode-wrap label:has(input:checked) {
185
+ background: var(--accent-soft) !important;
186
+ color: var(--accent) !important;
187
  }
188
+ .gradio-container .er-mode-wrap input { display: none !important; }
189
 
190
+ /* INSPECT BUTTON */
191
+ .gradio-container .er-inspect-btn { min-width: 0 !important; }
192
+ .gradio-container .er-inspect-btn button {
193
+ background: transparent !important;
194
+ border: 1px solid var(--border) !important;
195
+ color: var(--text-muted) !important;
196
+ padding: 8px 16px !important;
197
+ font-size: 12.5px !important;
198
+ font-weight: 500 !important;
199
+ border-radius: 999px !important;
200
+ min-width: 0 !important;
201
+ transition: border-color 180ms ease, color 180ms ease, background 180ms ease;
202
+ box-shadow: none !important;
203
+ }
204
+ .gradio-container .er-inspect-btn button:hover {
205
+ border-color: var(--border-strong) !important;
206
+ color: var(--text) !important;
207
+ background: var(--surface) !important;
208
+ }
209
+
210
+ /* HERO (empty state) */
211
+ .er-hero {
212
+ text-align: center;
213
+ padding: 84px 12px 28px;
214
+ }
215
+ .er-hero h1 {
216
+ font-size: 30px;
217
+ font-weight: 500;
218
+ letter-spacing: -0.025em;
219
+ margin: 0 0 12px;
220
+ color: var(--text);
221
+ line-height: 1.2;
222
+ }
223
+ .er-hero p {
224
+ color: var(--text-muted);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  font-size: 14px;
226
+ margin: 0 auto;
227
+ max-width: 480px;
228
+ line-height: 1.6;
229
+ }
230
+
231
+ /* SUGGESTION CHIPS */
232
+ .er-chips {
233
+ display: flex !important;
234
+ gap: 8px !important;
235
+ flex-wrap: wrap !important;
236
+ justify-content: center !important;
237
+ margin: 28px 0 0 !important;
238
+ padding: 0 8px !important;
239
+ }
240
+ .gradio-container .er-chip-btn { min-width: 0 !important; flex: 0 0 auto !important; }
241
+ .gradio-container .er-chip-btn button {
242
+ background: var(--surface) !important;
243
+ border: 1px solid var(--border) !important;
244
+ color: var(--text-muted) !important;
245
+ padding: 9px 14px !important;
246
+ font-size: 13px !important;
247
+ font-weight: 400 !important;
248
+ border-radius: 10px !important;
249
+ transition: border-color 180ms ease, background 180ms ease, color 180ms ease, transform 180ms ease;
250
+ text-align: left !important;
251
+ min-width: 0 !important;
252
+ box-shadow: none !important;
253
+ }
254
+ .gradio-container .er-chip-btn button:hover {
255
+ border-color: var(--accent-line) !important;
256
+ background: var(--surface-2) !important;
257
+ color: var(--text) !important;
258
+ transform: translateY(-1px);
259
+ }
260
+
261
+ /* CHAT */
262
+ .gradio-container .er-chat {
263
+ background: transparent !important;
264
+ border: none !important;
265
+ margin-top: 8px;
266
  }
267
+ .gradio-container .er-chat > .wrap,
268
+ .gradio-container .er-chat > div {
269
+ background: transparent !important;
270
+ border: none !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  }
272
+ .gradio-container .er-chat .message-wrap { gap: 6px !important; }
273
+ .gradio-container .er-chat .message {
274
+ border: none !important;
275
+ background: transparent !important;
276
+ box-shadow: none !important;
277
+ font-size: 15.5px !important;
278
+ line-height: 1.72 !important;
279
+ padding: 16px 0 !important;
280
+ color: var(--text) !important;
281
+ max-width: 100% !important;
282
+ }
283
+ .gradio-container .er-chat .message.user,
284
+ .gradio-container .er-chat .user {
285
+ background: var(--accent-soft) !important;
286
+ color: var(--text) !important;
287
+ border-radius: 18px 18px 4px 18px !important;
288
+ padding: 14px 18px !important;
289
+ max-width: 92% !important;
290
+ margin-left: auto !important;
291
+ border: 1px solid var(--accent-line) !important;
292
+ font-size: 15px !important;
293
+ line-height: 1.6 !important;
294
+ }
295
+ .gradio-container .er-chat .message.bot,
296
+ .gradio-container .er-chat .bot {
297
+ padding-left: 0 !important;
298
+ background: transparent !important;
299
+ border: none !important;
300
+ max-width: 100% !important;
301
  }
302
+ .gradio-container .er-chat .message p { margin: 0 0 10px !important; }
303
+ .gradio-container .er-chat .message p:last-child { margin: 0 !important; }
304
+ .gradio-container .er-chat .avatar-container { display: none !important; }
305
+
306
+ /* COMPOSER */
307
+ .er-composer-wrap {
308
  position: sticky;
309
+ bottom: 16px;
310
+ z-index: 5;
311
+ margin-top: 24px;
312
+ background: var(--surface);
313
+ border: 1px solid var(--border-mid);
314
  border-radius: 16px;
315
+ padding: 0;
316
+ transition: border-color 180ms ease, box-shadow 180ms ease;
317
+ position: relative;
318
+ box-shadow: 0 8px 32px rgba(0,0,0,0.18);
319
+ }
320
+ .er-composer-wrap:focus-within {
321
+ border-color: var(--accent-line);
322
+ box-shadow: 0 0 0 3px var(--accent-soft), 0 8px 32px rgba(0,0,0,0.22);
323
+ }
324
+ .gradio-container .er-composer-wrap textarea {
325
+ background: transparent !important;
326
+ border: none !important;
327
+ resize: none !important;
328
+ color: var(--text) !important;
329
+ font-size: 15px !important;
330
+ line-height: 1.55 !important;
331
+ padding: 16px 64px 16px 18px !important;
332
+ min-height: 56px !important;
333
+ outline: none !important;
334
+ box-shadow: none !important;
335
+ font-family: inherit !important;
336
+ width: 100% !important;
337
+ }
338
+ .gradio-container .er-composer-wrap textarea::placeholder {
339
+ color: var(--text-dim) !important;
340
+ }
341
+ .gradio-container .er-send-btn {
342
+ position: absolute !important;
343
+ right: 8px !important;
344
+ bottom: 8px !important;
345
+ min-width: 0 !important;
346
+ z-index: 6;
347
+ }
348
+ .gradio-container .er-send-btn button {
349
+ background: var(--accent) !important;
350
+ color: #061a16 !important;
351
+ border: none !important;
352
+ width: 38px !important;
353
+ height: 38px !important;
354
+ min-width: 38px !important;
355
+ border-radius: 10px !important;
356
+ padding: 0 !important;
357
+ font-size: 16px !important;
358
+ font-weight: 600 !important;
359
+ display: inline-flex !important;
360
+ align-items: center !important;
361
+ justify-content: center !important;
362
+ transition: filter 180ms ease, transform 120ms ease, box-shadow 180ms ease;
363
+ box-shadow: 0 0 24px rgba(94,234,212,0.16);
364
+ }
365
+ .gradio-container .er-send-btn button:hover {
366
+ filter: brightness(1.06);
367
+ box-shadow: 0 0 32px rgba(94,234,212,0.28);
368
+ }
369
+ .gradio-container .er-send-btn button:active {
370
+ transform: scale(0.96);
371
+ }
372
+
373
+ /* RESET */
374
+ .er-toolrow {
375
+ display: flex !important;
376
+ align-items: center !important;
377
+ justify-content: space-between !important;
378
+ margin-top: 14px !important;
379
+ gap: 12px !important;
380
+ }
381
+ .er-footnote {
382
+ color: var(--text-dim);
383
+ font-size: 11.5px;
384
+ letter-spacing: 0.01em;
385
+ }
386
+ .gradio-container .er-reset-btn { min-width: 0 !important; flex: 0 0 auto !important; }
387
+ .gradio-container .er-reset-btn button {
388
+ background: transparent !important;
389
+ border: none !important;
390
+ color: var(--text-dim) !important;
391
+ font-size: 12px !important;
392
+ font-weight: 400 !important;
393
+ padding: 6px 10px !important;
394
+ min-width: 0 !important;
395
+ transition: color 180ms ease;
396
+ box-shadow: none !important;
397
+ }
398
+ .gradio-container .er-reset-btn button:hover { color: var(--text-muted) !important; }
399
+
400
+ /* INSPECT DRAWER */
401
+ .er-inspect {
402
+ background: var(--surface) !important;
403
+ border: 1px solid var(--border) !important;
404
+ border-radius: var(--radius-lg) !important;
405
+ padding: 22px 22px 18px !important;
406
+ margin-top: 28px !important;
407
+ animation: er-fade-in 220ms ease both;
408
+ }
409
+ @keyframes er-fade-in {
410
+ from { opacity: 0; transform: translateY(6px); }
411
+ to { opacity: 1; transform: translateY(0); }
412
+ }
413
+ .er-inspect-head {
414
+ display: flex; align-items: center; justify-content: space-between;
415
+ margin-bottom: 18px;
416
+ padding-bottom: 14px;
417
+ border-bottom: 1px solid var(--border);
418
+ }
419
+ .er-inspect-title { font-size: 13.5px; font-weight: 600; color: var(--text); letter-spacing: 0.01em; }
420
+ .er-inspect-sub { font-size: 11.5px; color: var(--text-dim); }
421
+
422
+ /* TABS inside drawer */
423
+ .gradio-container .er-tabs > div[role="tablist"],
424
+ .gradio-container .er-tabs .tab-nav {
425
+ background: transparent !important;
426
+ border: none !important;
427
+ border-bottom: 1px solid var(--border) !important;
428
+ margin-bottom: 18px !important;
429
+ padding: 0 !important;
430
  }
431
+ .gradio-container .er-tabs button {
432
+ background: transparent !important;
433
+ border: none !important;
434
+ color: var(--text-muted) !important;
435
+ font-size: 12.5px !important;
436
+ font-weight: 500 !important;
437
+ padding: 10px 0 !important;
438
+ margin-right: 24px !important;
439
+ border-bottom: 1.5px solid transparent !important;
440
+ border-radius: 0 !important;
441
+ transition: color 180ms ease, border-color 180ms ease;
442
+ min-width: 0 !important;
443
+ box-shadow: none !important;
444
+ }
445
+ .gradio-container .er-tabs button.selected,
446
+ .gradio-container .er-tabs button[aria-selected="true"] {
447
+ color: var(--accent) !important;
448
+ border-bottom-color: var(--accent) !important;
449
+ }
450
+
451
+ /* INSPECT CARDS */
452
+ .er-card { padding: 0; margin-bottom: 18px; }
453
+ .er-card:last-child { margin-bottom: 0; }
454
  .er-mini-title {
455
  font-size: 11px;
456
+ font-weight: 600;
457
  letter-spacing: 0.08em;
458
  text-transform: uppercase;
459
+ color: var(--text-dim);
460
+ margin-bottom: 12px;
461
  }
462
  .er-empty {
463
+ color: var(--text-dim);
464
  font-size: 13px;
465
+ padding: 12px 14px;
466
+ background: var(--surface-2);
467
+ border: 1px solid var(--border);
468
+ border-radius: var(--radius-sm);
469
+ }
470
+
471
+ /* PLAN ROWS */
472
+ .er-plan-rows { display: flex; flex-direction: column; gap: 8px; }
473
+ .er-plan-row {
474
+ display: flex; justify-content: space-between; align-items: flex-start;
475
+ gap: 14px;
476
+ padding: 12px 14px;
477
+ background: var(--surface-2);
478
+ border-radius: var(--radius-sm);
479
+ border: 1px solid var(--border);
480
+ }
481
+ .er-plan-row .k {
482
+ color: var(--text-muted); font-size: 11px;
483
+ text-transform: uppercase; letter-spacing: 0.06em;
484
+ font-weight: 500;
485
+ flex: 0 0 auto;
486
+ padding-top: 1px;
487
+ }
488
+ .er-plan-row .v {
489
+ color: var(--text); font-size: 13.5px; font-weight: 500;
490
+ text-align: right; line-height: 1.5;
491
+ }
492
+ .er-plan-row.accent { border-color: var(--accent-line); background: var(--accent-soft); }
493
+ .er-plan-row.accent .v { color: var(--accent); }
494
+
495
+ /* SOURCE CARDS */
496
+ .er-sources { display: flex; flex-direction: column; gap: 8px; }
497
+ .er-source {
498
+ background: var(--surface-2);
499
+ border: 1px solid var(--border);
500
+ border-radius: var(--radius-sm);
501
+ padding: 13px 14px;
502
+ transition: border-color 180ms ease, background 180ms ease;
503
+ }
504
+ .er-source:hover { border-color: var(--accent-line); background: var(--surface-3); }
505
+ .er-source-title { font-size: 13.5px; font-weight: 500; color: var(--text); margin-bottom: 4px; line-height: 1.4; }
506
+ .er-source-name { font-size: 11.5px; color: var(--text-muted); margin-bottom: 8px; }
507
+ .er-source-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
508
+ .er-tag {
509
+ display: inline-block;
510
+ font-size: 10.5px;
511
+ padding: 3px 8px;
512
+ border-radius: 999px;
513
+ background: rgba(255,255,255,0.04);
514
+ color: var(--text-muted);
515
+ border: 1px solid var(--border);
516
+ letter-spacing: 0.02em;
517
+ }
518
+ .er-tag.crisis { background: rgba(248,113,113,0.10); color: var(--danger); border-color: rgba(248,113,113,0.22); }
519
+ .er-tag.accent { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-line); }
520
+ .er-source-why { font-size: 11.5px; color: var(--text-dim); line-height: 1.55; }
521
+ .er-source a {
522
+ color: var(--accent); font-size: 12px; text-decoration: none;
523
+ border-bottom: 1px solid var(--accent-line);
524
+ transition: border-color 180ms ease;
525
+ }
526
+ .er-source a:hover { border-bottom-color: var(--accent); }
527
+
528
+ /* DIAGNOSTICS GRID */
529
+ .er-diag-grid {
530
  display: grid;
531
  grid-template-columns: 1fr 1fr;
532
  gap: 8px;
533
  }
534
+ .er-diag {
535
+ background: var(--surface-2);
536
+ border: 1px solid var(--border);
537
+ border-radius: var(--radius-sm);
538
+ padding: 11px 12px;
539
+ min-width: 0;
540
  }
541
+ .er-diag .k {
542
+ font-size: 10.5px;
543
+ text-transform: uppercase;
544
+ letter-spacing: 0.06em;
545
+ color: var(--text-dim);
546
+ margin-bottom: 5px;
547
+ font-weight: 500;
548
  }
549
+ .er-diag .v {
550
+ font-size: 13px; color: var(--text); font-weight: 500; line-height: 1.4;
551
+ word-break: break-word;
552
  }
553
+ .er-diag.warn { border-color: rgba(245,182,105,0.22); }
554
+ .er-diag.warn .v { color: var(--warm); }
555
+ .er-diag.danger { border-color: rgba(248,113,113,0.22); }
556
+ .er-diag.danger .v { color: var(--danger); }
557
+
558
+ /* TIMELINE */
559
+ .er-timeline-row { display: flex; flex-wrap: wrap; gap: 6px; }
560
+ .er-time-pill {
561
+ font-size: 10.5px; padding: 4px 10px; border-radius: 999px;
562
+ background: var(--surface-2); color: var(--text-muted);
563
+ border: 1px solid var(--border);
564
+ letter-spacing: 0.02em;
565
+ }
566
+
567
+ /* METER */
568
+ .er-meter {
569
+ height: 4px; border-radius: 999px; overflow: hidden;
570
+ background: rgba(255,255,255,0.06);
571
  margin-top: 10px;
 
 
572
  }
573
+ .er-meter > div { height: 100%; background: var(--accent); transition: width 320ms ease; }
574
+
575
+ /* IG TOKENS */
576
+ .er-ig-row { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
577
+ .er-ig {
578
+ font-size: 10.5px; padding: 3px 9px; border-radius: 999px;
579
+ background: rgba(248,113,113,0.10);
580
+ color: var(--danger);
581
+ border: 1px solid rgba(248,113,113,0.22);
582
  }
583
+
584
+ /* HIDE GRADIO CRUFT */
585
+ .gradio-container footer { display: none !important; }
586
+ .gradio-container .progress-text { color: var(--text-dim) !important; }
587
+ .gradio-container .icon-button-wrapper { background: transparent !important; }
588
+
589
+ /* SCROLLBAR */
590
+ .gradio-container ::-webkit-scrollbar { width: 8px; height: 8px; }
591
+ .gradio-container ::-webkit-scrollbar-thumb {
592
+ background: rgba(255,255,255,0.06); border-radius: 999px;
593
+ }
594
+ .gradio-container ::-webkit-scrollbar-thumb:hover {
595
+ background: rgba(255,255,255,0.12);
596
  }
597
+ .gradio-container ::-webkit-scrollbar-track { background: transparent; }
598
+
599
+ /* RESPONSIVE */
600
+ /* TYPING INDICATOR (3 dots) */
601
+ .er-typing {
602
+ display: inline-flex;
603
  gap: 5px;
604
+ align-items: center;
605
+ height: 1.4em;
606
+ padding: 4px 0;
607
  }
608
+ .er-typing > span {
609
+ width: 6px; height: 6px; border-radius: 50%;
610
+ background: var(--text-dim);
611
+ animation: er-blink 1.4s infinite both;
612
+ display: inline-block;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  }
614
+ .er-typing > span:nth-child(2) { animation-delay: 0.18s; }
615
+ .er-typing > span:nth-child(3) { animation-delay: 0.36s; }
616
+ @keyframes er-blink {
617
+ 0%, 80%, 100% { opacity: 0.25; transform: scale(0.85); }
618
+ 40% { opacity: 1; transform: scale(1); background: var(--accent); }
619
  }
620
+
621
+ /* HERO GRADIENT TEXT */
622
+ .er-hero h1 {
623
+ background: linear-gradient(180deg, #f3f7fc 0%, #b6c2d2 100%);
624
+ -webkit-background-clip: text;
625
+ background-clip: text;
626
+ -webkit-text-fill-color: transparent;
627
+ color: transparent;
 
 
628
  }
629
+
630
+ /* MESSAGE ENTRANCE FADE */
631
+ .gradio-container .er-chat .message {
632
+ animation: er-msg-in 260ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
633
  }
634
+ @keyframes er-msg-in {
635
+ from { opacity: 0; transform: translateY(6px); }
636
+ to { opacity: 1; transform: translateY(0); }
 
 
637
  }
638
+
639
+ /* DRAWER SLIDE-IN (replace plain fade) */
640
+ .er-inspect {
641
+ animation: er-slide-in 320ms cubic-bezier(0.22, 0.61, 0.36, 1) both !important;
642
  }
643
+ @keyframes er-slide-in {
644
+ from { opacity: 0; transform: translateY(14px) scale(0.995); }
645
+ to { opacity: 1; transform: translateY(0) scale(1); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  }
647
+
648
+ /* BRAND-DOT RIPPLE */
649
+ .er-brand-dot {
 
 
650
  position: relative;
651
  }
652
+ .er-brand-dot::after {
653
+ content: "";
654
+ position: absolute;
655
+ inset: -3px;
656
+ border-radius: 50%;
657
+ border: 1px solid var(--accent);
658
+ opacity: 0;
659
+ animation: er-ripple 2.6s ease-out infinite;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
660
  }
661
+ @keyframes er-ripple {
662
+ 0% { transform: scale(0.85); opacity: 0.55; }
663
+ 100% { transform: scale(2.4); opacity: 0; }
 
 
 
 
664
  }
665
+
666
+ /* USER PILL DEPTH */
667
+ .gradio-container .er-chat .message.user,
668
+ .gradio-container .er-chat .user {
669
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.06), 0 1px 2px rgba(0,0,0,0.10) !important;
670
+ border-radius: 16px 16px 4px 16px !important;
671
  }
672
+
673
+ /* SEND BUTTON GRADIENT + KICK */
674
+ .gradio-container .er-send-btn button {
675
+ background: linear-gradient(135deg, #5eead4 0%, #7ff0d9 100%) !important;
676
  }
677
+ .gradio-container .er-send-btn button:active {
678
+ transform: scale(0.94);
679
+ filter: brightness(0.96);
 
 
 
 
680
  }
681
+
682
+ /* SOURCE-TYPE GLYPH */
683
+ .er-source-title::before {
684
+ content: "◇";
685
+ color: var(--accent);
686
+ margin-right: 8px;
687
+ font-size: 11px;
688
+ opacity: 0.7;
689
  }
690
+ .er-source[data-kind="crisis"] .er-source-title::before { content: "✦"; color: var(--danger); opacity: 0.8; }
691
+ .er-source[data-kind="university"] .er-source-title::before { content: "◆"; }
692
+
693
+ /* CHIP HOVER LIFT (already partial; smooth) */
694
+ .gradio-container .er-chip-btn button {
695
+ transition: border-color 200ms ease, background 200ms ease, color 200ms ease, transform 200ms ease, box-shadow 200ms ease !important;
696
  }
697
+ .gradio-container .er-chip-btn button:hover {
698
+ box-shadow: 0 6px 20px rgba(94,234,212,0.10) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
  }
700
+
701
+ /* COMPOSER FOCUS GLOW (animated border) */
702
+ .er-composer-wrap {
703
+ transition: border-color 240ms ease, box-shadow 240ms ease !important;
 
704
  }
705
+
706
+ /* HERO PARAGRAPH SUBTLE EMPHASIS */
707
+ .er-hero p {
708
+ font-style: normal;
709
  }
710
+ .er-hero p::first-letter {
711
+ color: var(--text);
 
 
 
712
  }
713
+ .er-hero-meta {
714
+ margin-top: 22px;
715
+ color: var(--text-dim);
716
+ font-size: 11.5px;
717
+ letter-spacing: 0.04em;
718
  text-transform: uppercase;
 
 
 
 
 
 
719
  }
720
+
721
+ /* International concern soft tag (used in body if needed) */
722
+ .er-intl-tag {
723
+ display: inline-flex;
724
+ align-items: center;
725
+ gap: 6px;
726
  font-size: 11px;
727
+ padding: 3px 9px;
 
 
 
 
728
  border-radius: 999px;
729
+ background: rgba(245,182,105,0.10);
730
+ color: var(--warm);
731
+ border: 1px solid rgba(245,182,105,0.22);
732
+ letter-spacing: 0.02em;
 
 
 
 
 
 
733
  }
734
+
735
+ /* Tighten chip text wrap on smaller widths */
736
+ .gradio-container .er-chip-btn button { white-space: nowrap; }
737
+
738
+ @media (max-width: 700px) {
739
+ .gradio-container { padding: 0 16px 32px !important; }
740
+ .er-topbar { flex-wrap: wrap !important; gap: 10px !important; }
741
+ .er-topbar > .er-mode-wrap { order: 3; flex-basis: 100% !important; justify-content: center; }
742
+ .er-hero { padding: 56px 8px 18px; }
743
+ .er-hero h1 { font-size: 24px; }
744
+ .er-diag-grid { grid-template-columns: 1fr; }
745
+ .gradio-container .er-chat .message.user { max-width: 88% !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
746
  }
747
  """
748
 
 
1384
  def format_emotion_timeline(history, trajectory) -> str:
1385
  if not history:
1386
  return (
1387
+ "<div class='er-card'><div class='er-mini-title'>Session feel</div>"
1388
+ "<div class='er-empty'>No turns yet.</div></div>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1389
  )
1390
+ pretty_traj = escape(str(trajectory).replace("_", " ").title())
1391
+ html = "<div class='er-card'><div class='er-mini-title'>Session feel</div>"
1392
+ html += "<div class='er-plan-rows'>"
1393
+ html += f"<div class='er-plan-row'><span class='k'>Trajectory</span><span class='v'>{pretty_traj}</span></div>"
1394
+ html += "</div>"
1395
+ html += "<div class='er-timeline-row' style='margin-top:10px;'>"
1396
+ for item in history[-12:]:
1397
+ label = escape(str(item['label_name']))
1398
+ turn = escape(str(item['turn']))
1399
+ html += f"<span class='er-time-pill'>T{turn} · {label}</span>"
1400
  html += "</div></div>"
1401
  return html
1402
 
 
1404
  def format_ig_panel(is_crisis, confidence, ig_tokens, loading, explanation_reason="") -> str:
1405
  if not is_crisis:
1406
  return (
1407
+ "<div class='er-card'><div class='er-mini-title'>Safety guardrail</div>"
1408
+ "<div class='er-empty'>No safety intercept on this turn.</div></div>"
1409
  )
1410
+ conf_pct = max(2, min(100, int(confidence * 100)))
1411
+ html = "<div class='er-card'>"
1412
+ html += "<div class='er-mini-title'>Safety guardrail</div>"
1413
+ html += "<div class='er-plan-rows'>"
 
 
 
 
 
 
 
 
 
1414
  html += (
1415
+ f"<div class='er-plan-row'><span class='k'>Crisis signal</span>"
1416
+ f"<span class='v' style='color:var(--danger);'>{confidence:.1%}</span></div>"
1417
  )
1418
+ html += "</div>"
1419
+ html += f"<div class='er-meter'><div style='width:{conf_pct}%; background:var(--danger);'></div></div>"
1420
+ if loading:
1421
+ html += "<div class='er-empty' style='margin-top:12px;'>Computing token attributions…</div>"
1422
+ elif ig_tokens:
1423
+ valid = [(t, s) for t, s in ig_tokens if t.strip()]
1424
+ if valid:
1425
+ html += "<div class='er-mini-title' style='margin-top:14px;'>Top crisis signals</div>"
1426
+ html += "<div class='er-ig-row'>"
1427
+ for tok, _score in valid[:10]:
1428
+ html += f"<span class='er-ig'>{escape(tok)}</span>"
 
 
 
 
 
 
 
 
 
 
 
1429
  html += "</div>"
1430
  elif explanation_reason:
1431
  html += (
1432
+ f"<div class='er-source-why' style='margin-top:10px;'>"
1433
+ f"{escape(str(explanation_reason))}</div>"
1434
  )
 
1435
  html += "</div>"
1436
  return html
1437
 
1438
 
1439
  def format_decision_trace(result=None) -> str:
1440
+ """Support card — what kind of support, what's next, which resources."""
1441
  if not result:
1442
  return (
1443
+ "<div class='er-card'><div class='er-mini-title'>Support card</div>"
1444
+ "<div class='er-empty'>Send a message to see the support path and resources.</div></div>"
1445
  )
 
1446
  route_label = str(result.get("route_label", "unknown"))
1447
  safety_tier = str(result.get("safety_tier", "unknown"))
 
 
1448
  should_intercept = bool(result.get("crisis") or result.get("should_intercept"))
 
1449
  recommended_action = escape(str(result.get("recommended_action", "")))
1450
+ route_text = escape(_pretty_route(route_label))
1451
+ tier_text = escape(_pretty_tier(safety_tier))
1452
+ sources = result.get("retrieved_sources", []) or []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1453
 
1454
+ path_class = "" if should_intercept else "accent"
1455
+
1456
+ html = "<div class='er-card'>"
1457
+ html += "<div class='er-mini-title'>Support card</div>"
1458
+ html += "<div class='er-plan-rows'>"
1459
+ html += f"<div class='er-plan-row {path_class}'><span class='k'>Path</span><span class='v'>{route_text}</span></div>"
1460
+ html += f"<div class='er-plan-row'><span class='k'>Tier</span><span class='v'>{tier_text}</span></div>"
1461
+ if recommended_action:
1462
+ html += f"<div class='er-plan-row'><span class='k'>Next move</span><span class='v'>{recommended_action}</span></div>"
1463
+ html += "</div>"
1464
+
1465
+ if sources:
1466
+ html += "<div class='er-mini-title' style='margin-top:18px;'>Resources</div>"
1467
+ html += "<div class='er-sources'>"
1468
+ for src in sources[:4]:
1469
+ title = escape(str(src.get("title") or src.get("source_name") or "Resource"))
1470
+ sname = escape(str(src.get("source_name") or ""))
1471
+ topic = escape(str(src.get("topic") or ""))
1472
+ risk = str(src.get("risk_level") or "")
1473
+ why = str(src.get("why_retrieved") or "matched prompt intent")
1474
+ url = escape(str(src.get("url") or ""))
1475
+ risk_cls = "crisis" if "crisis" in risk else ""
1476
+ html += "<div class='er-source'>"
1477
+ html += f"<div class='er-source-title'>{title}</div>"
1478
+ if sname and sname != title:
1479
+ html += f"<div class='er-source-name'>{sname}</div>"
1480
+ html += "<div class='er-source-tags'>"
1481
+ if topic: html += f"<span class='er-tag'>{escape(topic)}</span>"
1482
+ if risk: html += f"<span class='er-tag {risk_cls}'>{escape(risk)}</span>"
1483
+ html += "</div>"
1484
+ html += f"<div class='er-source-why'>{escape(_pretty_reason(why))}</div>"
1485
+ if url:
1486
+ html += f"<div style='margin-top:8px;'><a href='{url}' target='_blank' rel='noopener'>Open ↗</a></div>"
1487
+ html += "</div>"
1488
+ html += "</div>"
1489
+ else:
1490
+ html += "<div class='er-mini-title' style='margin-top:18px;'>Resources</div>"
1491
+ html += "<div class='er-empty'>No external resource needed for this turn.</div>"
1492
+ html += "</div>"
1493
+ return html
1494
 
1495
 
1496
  def format_retrieval_panel(result=None) -> str:
1497
+ """Diagnostics — pipeline internals for class & eval review."""
1498
  if not result:
1499
  return (
1500
+ "<div class='er-card'><div class='er-mini-title'>Diagnostics</div>"
1501
+ "<div class='er-empty'>Pipeline metadata appears here once a turn runs.</div></div>"
1502
  )
1503
+ safety_tier = _pretty_tier(str(result.get("safety_tier", "unknown")))
1504
+ safety_reason = _pretty_reason(str(result.get("safety_reason", "")))
1505
+ corpus = str(result.get("retrieval_corpus", "unknown"))
 
 
 
 
 
1506
  output_guard = result.get("output_guard", {}) or {}
1507
+ output_guard_reason = _pretty_reason(str(output_guard.get("reason", "not_checked")))
1508
+ guard_flags = output_guard.get("flags", []) or []
1509
  safety_precheck = result.get("safety_precheck", {}) or {}
1510
+ precheck_reason = _pretty_reason(str(safety_precheck.get("reason", "not_recorded")))
1511
+ precheck_level = _pretty_precheck(
1512
+ str(safety_precheck.get("level", "unknown")),
1513
+ bool(result.get("crisis")),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1514
  )
1515
+ classifier = result.get("classifier_confidence", {}) or {}
1516
+ route_conf = float(classifier.get("route", 0.0) or 0.0)
1517
+ tier_conf = float(classifier.get("tier", 0.0) or 0.0)
1518
+ classifier_label = "learned" if classifier.get("used_ml") else "fallback"
1519
+ retrieval_mode = _pretty_retrieval_mode(str(result.get("retrieval_mode", "")))
1520
+ latency = result.get("latency_ms", {}) or {}
1521
+ total_latency = float(latency.get("total_ms", 0.0) or 0.0)
1522
+ should_intercept = bool(result.get("crisis"))
1523
+ safety_cls = "danger" if should_intercept else ""
1524
+ guard_cls = "warn" if guard_flags else ""
1525
+
1526
+ html = "<div class='er-card'>"
1527
+ html += "<div class='er-mini-title'>Diagnostics</div>"
1528
+ html += "<div class='er-diag-grid'>"
1529
+ html += f"<div class='er-diag {safety_cls}'><div class='k'>Safety check</div><div class='v'>{escape(precheck_level)}</div></div>"
1530
+ html += f"<div class='er-diag'><div class='k'>Tier</div><div class='v'>{escape(safety_tier)}</div></div>"
1531
+ html += f"<div class='er-diag'><div class='k'>Classifier</div><div class='v'>{classifier_label} · r {route_conf:.2f} / t {tier_conf:.2f}</div></div>"
1532
+ html += f"<div class='er-diag'><div class='k'>Retrieval</div><div class='v'>{escape(retrieval_mode or '—')}</div></div>"
1533
+ html += f"<div class='er-diag {guard_cls}'><div class='k'>Response check</div><div class='v'>{escape(output_guard_reason)}</div></div>"
1534
+ html += f"<div class='er-diag'><div class='k'>Speed</div><div class='v'>{total_latency:.0f} ms</div></div>"
1535
+ html += f"<div class='er-diag'><div class='k'>Corpus</div><div class='v'>{escape(corpus)}</div></div>"
1536
+ html += f"<div class='er-diag'><div class='k'>Safety reason</div><div class='v'>{escape(safety_reason or '—')}</div></div>"
1537
+ # Surface cross-cutting NLP flags for the grad-course audience.
1538
+ intl_flag = "yes" if result.get("international_concern") else "no"
1539
+ intl_cls = "warn" if result.get("international_concern") else ""
1540
+ stage_label = str(result.get("conversation_stage") or "—")
1541
+ html += f"<div class='er-diag {intl_cls}'><div class='k'>International concern</div><div class='v'>{escape(intl_flag)}</div></div>"
1542
+ html += f"<div class='er-diag'><div class='k'>Conversation stage</div><div class='v'>{escape(stage_label)}</div></div>"
1543
+ html += "</div>"
1544
 
1545
+ notes = []
1546
+ if precheck_reason and precheck_reason not in {"—", "Not recorded"}:
1547
+ notes.append(f"Safety precheck: {escape(precheck_reason)}")
1548
+ escalation_reason = str(result.get("escalation_reason", ""))
1549
+ if escalation_reason:
1550
+ notes.append(f"Escalation: {escape(escalation_reason)}")
1551
+ if guard_flags:
1552
+ flag_text = ", ".join(escape(str(f)) for f in guard_flags)
1553
+ notes.append(f"Guard flags: {flag_text}")
1554
+ if notes:
1555
+ html += "<div class='er-source-why' style='margin-top:14px;line-height:1.7;'>" + "<br>".join(notes) + "</div>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1556
  html += "</div>"
1557
  return html
1558
 
1559
 
1560
+ TYPING_HTML = "<span class='er-typing'><span></span><span></span><span></span></span>"
1561
+ STREAM_ENABLED = os.getenv("EMPATHRAG_STREAM", "1") != "0"
1562
+ STREAM_WORDS_PER_CHUNK = int(os.getenv("EMPATHRAG_STREAM_WORDS", "2"))
1563
+ STREAM_CHUNK_DELAY_MS = int(os.getenv("EMPATHRAG_STREAM_DELAY_MS", "75"))
1564
+ TYPING_DELAY_MS = int(os.getenv("EMPATHRAG_TYPING_DELAY_MS", "650"))
1565
+
1566
+
1567
+ def _stream_chunks(full_text: str):
1568
+ """Yield growing partial strings to simulate streaming."""
1569
+ if not STREAM_ENABLED or not full_text:
1570
+ yield full_text
1571
+ return
1572
+ words = full_text.split(" ")
1573
+ if len(words) <= STREAM_WORDS_PER_CHUNK:
1574
+ yield full_text
1575
+ return
1576
+ import time as _t
1577
+ cursor = STREAM_WORDS_PER_CHUNK
1578
+ while cursor < len(words):
1579
+ yield " ".join(words[:cursor])
1580
+ _t.sleep(STREAM_CHUNK_DELAY_MS / 1000.0)
1581
+ cursor += STREAM_WORDS_PER_CHUNK
1582
+ yield full_text
1583
+
1584
+
1585
  def respond(message, chat_history, session_state, audience_mode):
1586
  if not session_state:
1587
  session_state = new_session_state()
 
1602
  )
1603
  return
1604
 
1605
+ # Show user message + typing indicator immediately for liveness.
1606
+ chat_history = list(chat_history) + [(message, TYPING_HTML)]
1607
+ yield (
1608
+ chat_history,
1609
+ format_decision_trace(),
1610
+ format_emotion_timeline(emotion_history, "stable"),
1611
+ "stable",
1612
+ format_ig_panel(False, 0.0, [], False),
1613
+ format_retrieval_panel(),
1614
+ session_id,
1615
+ session_state,
1616
+ )
1617
+
1618
+ if STREAM_ENABLED and TYPING_DELAY_MS > 0:
1619
+ import time as _t
1620
+ _t.sleep(TYPING_DELAY_MS / 1000.0)
1621
+
1622
  with pipeline_lock:
1623
  active_pipeline = get_pipeline()
1624
  if hasattr(active_pipeline, "tracker"):
 
1642
  session_state["tracker_history"] = session_state.get("tracker_history", []) + [result["emotion"]]
1643
  session_state["conv_history"] = session_state.get("conv_history", [])
1644
 
1645
+ full_response = result["response"]
1646
  emotion_history.append(
1647
  {
1648
  "turn": len(emotion_history) + 1,
 
1650
  "color": LABEL_COLORS[result["emotion_name"]],
1651
  }
1652
  )
 
1653
  log_turn(session_id, len(emotion_history), message, result)
1654
  timeline_html = format_emotion_timeline(emotion_history, result["trajectory"])
1655
+ decision_html = format_decision_trace(result)
1656
+ retrieval_html = format_retrieval_panel(result)
1657
 
1658
+ # Stream the response into the last chat slot.
1659
+ is_crisis = bool(result.get("crisis"))
1660
+ safety_explanation = result.get("safety_explanation", {}) or {}
1661
+ ig_tokens = safety_explanation.get("ig_tokens") or []
1662
+ explanation_reason = safety_explanation.get("reason", "")
1663
+ explanation_available = bool(safety_explanation.get("available"))
1664
+ ig_panel_html = format_ig_panel(
1665
+ is_crisis,
1666
+ result.get("crisis_confidence", 0.0),
1667
+ ig_tokens,
1668
+ loading=is_crisis and hasattr(get_pipeline(), "guardrail") and not explanation_available,
1669
+ explanation_reason=explanation_reason,
1670
+ )
1671
+
1672
+ for partial in _stream_chunks(full_response):
1673
+ chat_history[-1] = (message, partial)
 
1674
  yield (
1675
  chat_history,
1676
+ decision_html,
1677
  timeline_html,
1678
  result["trajectory"],
1679
+ ig_panel_html,
1680
+ retrieval_html,
1681
  session_id,
1682
  session_state,
1683
  )
1684
 
1685
+ # Crisis: optionally compute IG attributions after the message lands.
1686
+ if is_crisis and hasattr(get_pipeline(), "guardrail") and not explanation_available:
1687
  with pipeline_lock:
1688
  active_pipeline = get_pipeline()
1689
  if hasattr(active_pipeline, "guardrail"):
1690
+ _, confidence, ig_tokens = active_pipeline.guardrail.check(
1691
+ message, threshold=0.5, skip_ig=False
1692
+ )
1693
  else:
1694
  confidence, ig_tokens = result["crisis_confidence"], []
 
1695
  yield (
1696
  chat_history,
1697
+ decision_html,
1698
  timeline_html,
1699
  result["trajectory"],
1700
  format_ig_panel(True, confidence, ig_tokens, loading=False),
1701
+ retrieval_html,
 
 
 
 
 
 
 
 
 
 
 
1702
  session_id,
1703
  session_state,
1704
  )
 
1791
  return mode.replace("_", " ")
1792
 
1793
 
1794
+ theme = gr.themes.Base(
1795
  primary_hue="teal",
1796
+ secondary_hue="teal",
1797
+ neutral_hue="slate",
1798
+ radius_size=gr.themes.sizes.radius_md,
1799
+ font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
1800
+ ).set(
1801
+ body_background_fill="#0a0c10",
1802
+ body_background_fill_dark="#0a0c10",
1803
+ body_text_color="#e7ecf2",
1804
+ background_fill_primary="#0a0c10",
1805
+ background_fill_secondary="#11151c",
1806
+ border_color_primary="rgba(255,255,255,0.06)",
1807
+ button_primary_background_fill="#5eead4",
1808
+ button_primary_background_fill_hover="#5eead4",
1809
+ button_primary_text_color="#061a16",
1810
+ button_secondary_background_fill="transparent",
1811
+ button_secondary_text_color="#8a93a3",
1812
+ input_background_fill="#11151c",
1813
+ input_border_color="rgba(255,255,255,0.06)",
1814
+ block_background_fill="transparent",
1815
+ block_border_color="rgba(255,255,255,0.06)",
1816
+ block_label_background_fill="transparent",
1817
+ block_label_text_color="#8a93a3",
1818
  )
1819
 
1820
+
1821
+ with gr.Blocks(theme=theme, title="EmpathRAG", css=APP_CSS) as demo:
1822
  initial_state = new_session_state()
1823
  session_state = gr.State(value=initial_state)
1824
+ inspect_open = gr.State(value=False)
1825
 
1826
+ # ---- Top bar ----
1827
+ with gr.Row(elem_classes=["er-topbar"]):
1828
+ gr.HTML(
1829
+ """
1830
+ <div class="er-brand">
1831
+ <span class="er-brand-dot"></span>
1832
+ EmpathRAG
1833
+ <span class="er-brand-meta">· support navigator</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1834
  </div>
1835
+ """
1836
+ )
1837
+ audience_mode_box = gr.Radio(
1838
+ choices=[("Student", "student"), ("Helping a friend", "helping_friend")],
1839
+ value="student",
1840
+ show_label=False,
1841
+ container=False,
1842
+ elem_classes=["er-mode-wrap"],
1843
+ )
1844
+ inspect_btn = gr.Button("Inspect", elem_classes=["er-inspect-btn"])
 
 
 
 
 
 
 
 
 
1845
 
1846
+ # ---- Hero (empty state) ----
1847
+ hero_block = gr.HTML(
 
 
 
 
 
 
1848
  """
1849
+ <div class="er-hero">
1850
+ <h1>How are you doing today?</h1>
1851
+ <p>I'm here to listen first — about academic stress, mental health, advisor pressure, or anything weighing on you. When you're ready, I can also help you find specific UMD resources, including ones for international and F-1 students.</p>
1852
+ <div class="er-hero-meta">Conversations are not logged. Not therapy or emergency care.</div>
1853
+ </div>
1854
+ """,
1855
+ visible=True,
1856
  )
1857
 
1858
+ with gr.Row(elem_classes=["er-chips"], visible=True) as chip_row:
1859
+ chip_counseling = gr.Button("I'm thinking about counseling", elem_classes=["er-chip-btn"])
1860
+ chip_ads = gr.Button("ADS accommodations", elem_classes=["er-chip-btn"])
1861
+ chip_advisor = gr.Button("Advisor conflict", elem_classes=["er-chip-btn"])
1862
+ chip_intl = gr.Button("F-1 visa & academic worry", elem_classes=["er-chip-btn"])
1863
+ chip_grounding = gr.Button("Pre-exam grounding", elem_classes=["er-chip-btn"])
1864
+
1865
+ # ---- Chat ----
1866
+ chatbot = gr.Chatbot(
1867
+ elem_classes=["er-chat"],
1868
+ show_label=False,
1869
+ height=520,
1870
+ bubble_full_width=False,
1871
+ avatar_images=None,
1872
+ show_share_button=False,
1873
+ show_copy_button=True,
1874
+ sanitize_html=False,
1875
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1876
 
1877
+ # ---- Composer ----
1878
+ with gr.Group(elem_classes=["er-composer-wrap"]):
1879
+ msg_box = gr.Textbox(
1880
+ placeholder="Tell me what's on your mind…",
1881
+ show_label=False,
1882
+ container=False,
1883
+ lines=1,
1884
+ max_lines=8,
1885
+ autofocus=True,
1886
+ )
1887
+ send_btn = gr.Button("→", elem_classes=["er-send-btn"], variant="primary")
1888
 
1889
+ with gr.Row(elem_classes=["er-toolrow"]):
1890
+ gr.HTML(
1891
+ "<div class='er-footnote'>Conversations are not logged by default. If you are in immediate danger, call or text 988.</div>"
1892
+ )
1893
+ reset_btn = gr.Button("Clear conversation", elem_classes=["er-reset-btn"])
1894
+
1895
+ # Hidden state surfaces (kept to preserve respond() output contract)
1896
+ session_id_box = gr.Textbox(value=initial_state["session_id"], visible=False)
1897
+ trajectory_out = gr.Textbox(value="stable", visible=False)
1898
+
1899
+ # ---- Inspect drawer ----
1900
+ with gr.Column(visible=False, elem_classes=["er-inspect"]) as inspect_drawer:
1901
+ gr.HTML(
1902
+ "<div class='er-inspect-head'>"
1903
+ "<div class='er-inspect-title'>Behind the answer</div>"
1904
+ "<div class='er-inspect-sub'>Pipeline view · class & eval</div>"
1905
+ "</div>"
1906
+ )
1907
+ with gr.Tabs(elem_classes=["er-tabs"]):
1908
+ with gr.Tab("Support card"):
1909
+ decision_out = gr.HTML(value=format_decision_trace())
1910
+ with gr.Tab("Diagnostics"):
1911
+ retrieval_out = gr.HTML(value=format_retrieval_panel())
1912
+ timeline_out = gr.HTML(value=format_emotion_timeline([], "stable"))
1913
+ crisis_out = gr.HTML(value=format_ig_panel(False, 0.0, [], False))
1914
+
1915
+ # ---- Wiring ----
1916
  submit_outputs = [
1917
  chatbot,
1918
  decision_out,
 
1922
  retrieval_out,
1923
  session_id_box,
1924
  session_state,
1925
+ hero_block,
1926
+ chip_row,
1927
  ]
1928
 
1929
+ def respond_with_chrome(message, chat_history, session_state, audience_mode):
1930
+ hide = bool(message and message.strip())
1931
+ chrome = (gr.update(visible=not hide), gr.update(visible=not hide))
1932
+ for tup in respond(message, chat_history, session_state, audience_mode):
1933
+ yield tup + chrome
1934
+
1935
  msg_box.submit(
1936
+ respond_with_chrome,
1937
  inputs=[msg_box, chatbot, session_state, audience_mode_box],
1938
  outputs=submit_outputs,
1939
  ).then(lambda: "", outputs=msg_box)
1940
 
1941
  send_btn.click(
1942
+ respond_with_chrome,
1943
  inputs=[msg_box, chatbot, session_state, audience_mode_box],
1944
  outputs=submit_outputs,
1945
  ).then(lambda: "", outputs=msg_box)
1946
 
1947
+ def reset_with_chrome():
1948
+ base = reset_session_handler()
1949
+ return base + (gr.update(visible=True), gr.update(visible=True))
1950
+
1951
+ reset_btn.click(reset_with_chrome, outputs=submit_outputs)
1952
 
1953
+ def toggle_inspect(open_state):
1954
+ new_state = not open_state
1955
+ return new_state, gr.update(visible=new_state)
1956
+
1957
+ inspect_btn.click(
1958
+ toggle_inspect,
1959
+ inputs=[inspect_open],
1960
+ outputs=[inspect_open, inspect_drawer],
1961
+ )
1962
+
1963
+ chip_counseling.click(
1964
  lambda: set_prompt("I think I need counseling at UMD, but I do not know how to start."),
1965
  outputs=msg_box,
1966
  )
1967
+ chip_ads.click(
1968
+ lambda: set_prompt("I need disability accommodations for an upcoming exam at UMD."),
1969
  outputs=msg_box,
1970
  )
1971
+ chip_advisor.click(
1972
  lambda: set_prompt("My advisor keeps dismissing my concerns and I need someone neutral to talk to."),
1973
  outputs=msg_box,
1974
  )
1975
+ chip_grounding.click(
1976
  lambda: set_prompt("I am panicking before my exam. Can you help me with a grounding exercise?"),
1977
  outputs=msg_box,
1978
  )
1979
+ chip_intl.click(
1980
+ lambda: set_prompt(
1981
+ "I'm an F-1 student and I think I'm going to fail my final tomorrow. "
1982
+ "I'm scared about what this means for my visa status."
1983
+ ),
 
 
 
 
 
1984
  outputs=msg_box,
1985
  )
1986
 
1987
 
1988
+
1989
  if __name__ == "__main__":
1990
  os.makedirs("eval", exist_ok=True)
1991
  demo.launch(share=SHARE_DEMO)
src/pipeline/core.py CHANGED
@@ -14,7 +14,13 @@ import time
14
 
15
  from .ml_router import MLRouter
16
  from .output_guard import validate_output
17
- from .response_planner import build_response_plan, render_crisis_response
 
 
 
 
 
 
18
  from .safety_policy import SafetyLevel, SafetyTriagePolicy
19
  from .service_graph import match_services
20
  from .v2_schema import SafetyTier, SupportRoute, classify_route, map_safety_level
@@ -46,6 +52,9 @@ class EmpathRAGResult:
46
  crisis: bool = False
47
  crisis_confidence: float = 0.0
48
  retrieved_chunks: list[str] | None = None
 
 
 
49
 
50
  def to_dict(self) -> dict:
51
  row = asdict(self)
@@ -80,14 +89,23 @@ class EmpathRAGCore:
80
  self._guardrail_error = ""
81
  self.tier_history: dict[str, list[str]] = {}
82
  self.locked_sessions: dict[str, str] = {}
 
 
 
 
 
83
 
84
  def reset_session(self, session_id: str | None = None) -> None:
85
  if session_id:
86
  self.tier_history.pop(session_id, None)
87
  self.locked_sessions.pop(session_id, None)
 
 
88
  else:
89
  self.tier_history.clear()
90
  self.locked_sessions.clear()
 
 
91
 
92
  def run_turn(
93
  self,
@@ -96,7 +114,10 @@ class EmpathRAGCore:
96
  audience_mode: AudienceMode = "student",
97
  resource_profile: str = "umd",
98
  backend_mode: BackendMode = "hybrid_ml",
 
99
  ) -> EmpathRAGResult:
 
 
100
  t_total = time.perf_counter()
101
  latency: dict[str, float] = {}
102
 
@@ -151,18 +172,58 @@ class EmpathRAGCore:
151
  retrieved = self._retrieve(message, route_label, safety_tier.value, audience_mode, should_intercept)
152
  latency["retrieval_ms"] = _elapsed_ms(t0)
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  if should_intercept:
155
  response = render_crisis_response(route_label, audience_mode=audience_mode)
156
  output_guard = {"allowed": True, "reason": "crisis_template", "flags": []}
157
  recommended_action = _recommended_action(route_label, safety_tier.value)
 
158
  else:
159
- plan = build_response_plan(message, route_label, safety_tier.value, retrieved, audience_mode)
160
- response = plan.render()
 
 
 
 
 
 
 
 
161
  recommended_action = plan.recommended_action
162
- guard = validate_output(response, retrieved, safety_tier.value, route_label, [])
163
- output_guard = {"allowed": guard.allowed, "reason": guard.reason, "flags": guard.flags}
164
- if guard.fallback_required and guard.corrected_response:
165
- response = guard.corrected_response
 
 
 
 
 
 
 
 
 
166
 
167
  latency["total_ms"] = _elapsed_ms(t_total)
168
  return EmpathRAGResult(
@@ -198,6 +259,9 @@ class EmpathRAGCore:
198
  crisis=should_intercept,
199
  crisis_confidence=float(guardrail_info.get("confidence") or (1.0 if should_intercept else 0.0)),
200
  retrieved_chunks=[row.get("text", "") for row in retrieved],
 
 
 
201
  )
202
 
203
  def _run_optional_guardrail(self, message: str, skip_ig: bool) -> dict:
 
14
 
15
  from .ml_router import MLRouter
16
  from .output_guard import validate_output
17
+ from .response_planner import (
18
+ INTERNATIONAL_SOURCE_HINT,
19
+ build_response_plan,
20
+ decide_stage,
21
+ has_international_concern,
22
+ render_crisis_response,
23
+ )
24
  from .safety_policy import SafetyLevel, SafetyTriagePolicy
25
  from .service_graph import match_services
26
  from .v2_schema import SafetyTier, SupportRoute, classify_route, map_safety_level
 
52
  crisis: bool = False
53
  crisis_confidence: float = 0.0
54
  retrieved_chunks: list[str] | None = None
55
+ international_concern: bool = False
56
+ conversation_stage: str = "offer"
57
+ turn_index: int = 1
58
 
59
  def to_dict(self) -> dict:
60
  row = asdict(self)
 
89
  self._guardrail_error = ""
90
  self.tier_history: dict[str, list[str]] = {}
91
  self.locked_sessions: dict[str, str] = {}
92
+ # Session-scoped sticky flags so context detected on one turn carries
93
+ # forward (international concern, last specific route) instead of being
94
+ # re-derived from each message in isolation.
95
+ self.session_intl_flag: dict[str, bool] = {}
96
+ self.session_last_specific_route: dict[str, str] = {}
97
 
98
  def reset_session(self, session_id: str | None = None) -> None:
99
  if session_id:
100
  self.tier_history.pop(session_id, None)
101
  self.locked_sessions.pop(session_id, None)
102
+ self.session_intl_flag.pop(session_id, None)
103
+ self.session_last_specific_route.pop(session_id, None)
104
  else:
105
  self.tier_history.clear()
106
  self.locked_sessions.clear()
107
+ self.session_intl_flag.clear()
108
+ self.session_last_specific_route.clear()
109
 
110
  def run_turn(
111
  self,
 
114
  audience_mode: AudienceMode = "student",
115
  resource_profile: str = "umd",
116
  backend_mode: BackendMode = "hybrid_ml",
117
+ turn_index: int | None = None,
118
  ) -> EmpathRAGResult:
119
+ if turn_index is None:
120
+ turn_index = len(self.tier_history.get(session_id, [])) + 1
121
  t_total = time.perf_counter()
122
  latency: dict[str, float] = {}
123
 
 
172
  retrieved = self._retrieve(message, route_label, safety_tier.value, audience_mode, should_intercept)
173
  latency["retrieval_ms"] = _elapsed_ms(t0)
174
 
175
+ # Cross-cutting: when an F-1 / visa / international-status worry shows up
176
+ # at any point in the session, keep the ISSS surface and the
177
+ # international-aware framing for subsequent turns. A student who
178
+ # mentioned deportation on turn 1 shouldn't have to repeat themselves
179
+ # on turn 3 to keep ISSS visible.
180
+ intl_now = has_international_concern(message)
181
+ if intl_now:
182
+ self.session_intl_flag[session_id] = True
183
+ intl_session = self.session_intl_flag.get(session_id, False)
184
+
185
+ if not should_intercept and intl_session:
186
+ already_has_isss = any(
187
+ (s.get("source_name") or "").lower().startswith("umd international")
188
+ or (s.get("service_id") or "").startswith("umd_isss")
189
+ for s in retrieved
190
+ )
191
+ if not already_has_isss:
192
+ retrieved = [INTERNATIONAL_SOURCE_HINT] + retrieved
193
+
194
+ # `intl_concern` exposed downstream uses the session-sticky value so
195
+ # diagnostics & support card stay consistent across the conversation.
196
+ intl_concern = intl_session
197
  if should_intercept:
198
  response = render_crisis_response(route_label, audience_mode=audience_mode)
199
  output_guard = {"allowed": True, "reason": "crisis_template", "flags": []}
200
  recommended_action = _recommended_action(route_label, safety_tier.value)
201
+ stage = "offer"
202
  else:
203
+ plan = build_response_plan(
204
+ message,
205
+ route_label,
206
+ safety_tier.value,
207
+ retrieved,
208
+ audience_mode,
209
+ international_concern_override=intl_session,
210
+ )
211
+ stage = decide_stage(message, route_label, safety_tier.value, turn_index)
212
+ response = plan.render(stage)
213
  recommended_action = plan.recommended_action
214
+ # Output guard catches dead-end validation responses, but LISTEN and
215
+ # PERMISSION stages are intentionally reflective ("sit with this"
216
+ # then invite). Applying the missing-action check there would
217
+ # punish the very thing those stages are designed to do. Run the
218
+ # safety subset (dependency, harmful agreement, ungrounded contact)
219
+ # but skip the action/validation checks unless we're in OFFER.
220
+ if stage == "offer":
221
+ guard = validate_output(response, retrieved, safety_tier.value, route_label, [])
222
+ output_guard = {"allowed": guard.allowed, "reason": guard.reason, "flags": guard.flags}
223
+ if guard.fallback_required and guard.corrected_response:
224
+ response = guard.corrected_response
225
+ else:
226
+ output_guard = {"allowed": True, "reason": f"listening_stage_{stage}", "flags": []}
227
 
228
  latency["total_ms"] = _elapsed_ms(t_total)
229
  return EmpathRAGResult(
 
259
  crisis=should_intercept,
260
  crisis_confidence=float(guardrail_info.get("confidence") or (1.0 if should_intercept else 0.0)),
261
  retrieved_chunks=[row.get("text", "") for row in retrieved],
262
+ international_concern=intl_concern,
263
+ conversation_stage=stage,
264
+ turn_index=turn_index,
265
  )
266
 
267
  def _run_optional_guardrail(self, message: str, skip_ig: bool) -> dict:
src/pipeline/output_guard.py CHANGED
@@ -30,9 +30,27 @@ NEXT_ACTION_MARKERS = (
30
  "call",
31
  "text",
32
  "office hours",
 
33
  "email",
34
  "use ",
35
  "route",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  )
37
 
38
  DEPENDENCY_PATTERNS = (
 
30
  "call",
31
  "text",
32
  "office hours",
33
+ "office-hours",
34
  "email",
35
  "use ",
36
  "route",
37
+ "send ",
38
+ "take ",
39
+ "pick ",
40
+ "start ",
41
+ "write ",
42
+ "ask ",
43
+ "tell ",
44
+ "choose ",
45
+ "make ",
46
+ "decide ",
47
+ "identify ",
48
+ "do one",
49
+ "do a",
50
+ "stay ",
51
+ "involve ",
52
+ "try ",
53
+ "keep it",
54
  )
55
 
56
  DEPENDENCY_PATTERNS = (
src/pipeline/response_planner.py CHANGED
@@ -1,12 +1,96 @@
1
- """Constrained response planning for EmpathRAG Core."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
- from dataclasses import dataclass
 
6
 
7
  from .v2_schema import SupportRoute
8
 
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  @dataclass(frozen=True)
11
  class ResponsePlan:
12
  route: str
@@ -17,16 +101,160 @@ class ResponsePlan:
17
  support_option: str
18
  backup_option: str
19
  follow_up_question: str
 
 
 
 
 
20
 
21
- def render(self) -> str:
22
- return (
23
- f"{self.validation} {self.reframe}\n\n"
24
- f"For right now: {self.recommended_action}\n\n"
25
- f"Grounded support: {self.support_option}\n\n"
26
- f"Backup plan: {self.backup_option}\n\n"
27
- f"{self.follow_up_question}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  )
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  def build_response_plan(
32
  message: str,
@@ -34,80 +262,129 @@ def build_response_plan(
34
  safety_tier: str,
35
  retrieved_sources: list[dict],
36
  audience_mode: str = "student",
 
37
  ) -> ResponsePlan:
38
  source_label = _source_label(retrieved_sources)
39
- source_names = _source_names(retrieved_sources)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  if route == SupportRoute.ACADEMIC_SETBACK.value:
42
  return ResponsePlan(
43
  route,
44
  safety_tier,
45
- "That sounds like a painful academic setback.",
46
- "One exam or assignment can feel huge, but the safer move is to make the next step concrete instead of treating it as your whole future.",
47
- "Send a short professor/TA office-hours note asking what went wrong and what to change before the next assessment.",
48
  f"Use {source_label} if the stress is affecting sleep, panic, or your ability to function.",
49
  "If the situation starts feeling unsafe, switch from academic planning to crisis or human support immediately.",
50
- "Do you want the short email script or the next-step checklist first?",
 
 
 
 
 
 
 
 
 
 
 
51
  )
52
 
53
  if route == SupportRoute.EXAM_STRESS.value:
54
  return ResponsePlan(
55
  route,
56
  safety_tier,
57
- "Tomorrow's test feeling this heavy makes sense, especially when your mind is already jumping to worst-case outcomes.",
58
- "I do not want to treat the fear as proof that you are doomed; the useful move is to shrink the next few hours into something controllable.",
59
- "Take a 10-minute reset, pick two high-yield topics, and make a small plan for what you will do before sleep instead of trying to fix everything tonight.",
60
  f"Use {source_label} if the stress is affecting sleep, panic, or your ability to function.",
61
  "If this shifts into not feeling safe or being unable to stay with yourself, use crisis or emergency support instead of continuing study planning.",
62
- "Would you rather make a quick study plan, do a grounding reset, or draft a message to your instructor/TA?",
 
 
 
 
 
 
 
 
 
 
 
63
  )
64
 
65
  if route == SupportRoute.ACCESSIBILITY_ADS.value:
66
  return ResponsePlan(
67
  route,
68
  safety_tier,
69
- "That is a practical accommodations question, not something you need to improvise alone.",
70
- "The strongest path is to use the official ADS process so the request is documented and traceable.",
71
- "Identify the course/exam barrier and start from the ADS source shown here.",
72
  f"Use {source_label} for the official accommodations workflow.",
73
- "If a deadline is urgent, also contact the instructor or program staff with a brief factual note.",
74
- "Is this for an exam, assignment deadline, temporary condition, or ongoing accommodation?",
 
75
  )
76
 
77
  if route == SupportRoute.ADVISOR_CONFLICT.value:
78
  return ResponsePlan(
79
  route,
80
  safety_tier,
81
- "That sounds stressful, especially when power and funding are involved.",
82
- "The safer path is to separate facts, deadlines, and relationship concerns before escalating.",
83
- "Write a factual timeline and use a neutral graduate support or Ombuds route before making irreversible decisions.",
84
  f"Use {source_label} as the campus support starting point.",
85
  "If the stress becomes unsafe or overwhelming, use counseling or crisis support before continuing the conflict process.",
86
- "Do you need help turning this into a neutral message or a timeline?",
 
87
  )
88
 
89
  if route == SupportRoute.BASIC_NEEDS.value:
90
  return ResponsePlan(
91
  route,
92
  safety_tier,
93
- "Food, housing, or money stress can make everything else harder very quickly.",
94
- "This is a support-navigation problem, not a personal failure.",
95
- "Contact a campus student-support office and state the concrete need for today.",
96
  f"Use {source_label}; rely on the source card for verified contact, hours, and eligibility details.",
97
  "If your safety or shelter is immediately at risk, use emergency or crisis support instead of waiting.",
98
- "Is the most urgent need food, housing, money, or a campus contact?",
 
99
  )
100
 
101
  if route == SupportRoute.PEER_HELPER.value:
102
  return ResponsePlan(
103
  route,
104
  safety_tier,
105
- "It makes sense that you are worried about your friend.",
106
- "You can support them, but you should not be the only safety plan.",
107
- "Ask directly whether they are safe right now, stay with them or keep them connected if possible, and involve emergency/crisis support if there may be immediate danger.",
108
  f"Use {source_label} for helping-someone-else or crisis guidance.",
109
- "What to say: I care about you, I am worried, and I want to get another person involved so you are not alone. What not to say: do not promise secrecy or agree to handle safety risk by yourself.",
110
  "Are they reachable right now, and is someone physically nearby who can check on them?",
 
111
  )
112
 
113
  if route == SupportRoute.ANXIETY_PANIC.value:
@@ -117,32 +394,86 @@ def build_response_plan(
117
  safety_tier,
118
  "That kind of nervousness before meeting someone you like is very normal.",
119
  "It does not mean something is wrong; it usually means the moment matters to you and your brain is trying to predict every possible outcome.",
120
- "Keep it simple: decide one easy opener, one genuine question you can ask her, and one way to exit gracefully if either of you feels awkward.",
121
  "You probably do not need a formal campus resource for ordinary date nerves; if anxiety starts disrupting sleep, eating, or daily functioning, the source cards can point you toward support.",
122
  "If this shifts into panic, feeling unsafe, or being unable to function, use a human support option rather than trying to push through alone.",
123
  "Want to brainstorm a relaxed opening line, a few questions to ask, or how to calm down beforehand?",
 
 
 
 
 
 
 
 
 
 
 
124
  )
125
  return ResponsePlan(
126
  route,
127
  safety_tier,
128
  "That sounds like anxiety is taking up a lot of space right now.",
129
- "The goal is to lower the intensity first, then decide whether you need a campus support path.",
130
- "Do one short grounding step, then pick one follow-up action if the anxiety keeps interfering.",
131
  f"Use {source_label} for anxiety, grounding, or counseling support.",
132
  "If the anxiety shifts into not feeling safe, use crisis support instead of continuing here.",
133
- "Would you rather start with grounding or with finding who to contact?",
 
 
 
 
 
 
 
 
 
 
 
134
  )
135
 
136
  if route == SupportRoute.LOW_MOOD.value:
137
  return ResponsePlan(
138
  route,
139
  safety_tier,
140
- "That sounds heavy, and it deserves support instead of being minimized.",
141
- "Low mood can make isolation feel logical, but that does not mean handling it alone is the safest path.",
142
- "Tell one trusted person what is going on and use a counseling or support starting point.",
143
  f"Use {source_label} as a grounded starting point.",
144
  "If this turns into not feeling safe, use 988 or emergency support immediately.",
145
- "Is the hardest part right now motivation, isolation, sleep, or asking for help?",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  )
147
 
148
  if route == SupportRoute.COUNSELING_NAVIGATION.value:
@@ -150,46 +481,63 @@ def build_response_plan(
150
  route,
151
  safety_tier,
152
  "It is completely reasonable to want a clear first step.",
153
- "Counseling navigation should be practical and source-grounded, not vague encouragement.",
154
- "Start with the official counseling source and look for intake, scheduling, or referral instructions.",
155
  f"Use {source_label} for the current official path.",
156
  "If the need becomes urgent or safety-related, use crisis resources instead of ordinary scheduling.",
157
  "Are you trying to start care, understand confidentiality, or find referral options?",
 
158
  )
159
 
160
  if route == SupportRoute.OUT_OF_SCOPE.value:
161
  return ResponsePlan(
162
  route,
163
  safety_tier,
164
- "That does not look like a student-support navigation request.",
165
  "I should avoid pretending this system is a general authority outside its scope.",
166
  "Use an appropriate official or professional source for that topic.",
167
  "No support source is retrieved for this route because the request is outside the system scope.",
168
  "For medical, legal, or emergency issues, use qualified human support.",
169
  "Do you want to rephrase this as a campus-support question?",
 
170
  )
171
 
 
172
  return ResponsePlan(
173
  route,
174
  safety_tier,
175
- "That sounds like something you should not have to sort through from scratch.",
176
- "The helpful next step is to name what kind of support this is, then choose one concrete action instead of staying in a vague overwhelm loop.",
177
- "Choose the closest source card and take one small action from it today.",
178
  f"Use {source_label} as the starting point.",
179
  "If this becomes urgent or safety-related, switch to crisis or emergency support.",
180
- "What would help most first: a next-step checklist, who to contact, or what to expect?",
 
 
 
 
 
 
 
 
 
 
181
  )
182
 
183
 
184
  def render_crisis_response(route: str, audience_mode: str = "student") -> str:
185
  if route == SupportRoute.PEER_HELPER.value or audience_mode == "helping_friend":
186
  return (
187
- "I am concerned this could be an immediate safety situation for your friend. "
188
- "Do not handle this alone. Contact emergency/crisis support now, and involve a trusted nearby person, RA, supervisor, or campus support while you try to reach them."
 
 
189
  )
190
  return (
191
- "I am concerned this may be an immediate safety moment. Please call or text 988 now, "
192
- "or call emergency services if there is immediate danger. If someone nearby can stay with you, move near them while you get help."
 
 
193
  )
194
 
195
 
@@ -217,7 +565,7 @@ def _is_social_or_date_nerves(message: str) -> bool:
217
 
218
 
219
  def _source_names(retrieved_sources: list[dict]) -> str:
220
- seen = []
221
  for source in retrieved_sources:
222
  name = str(source.get("source_name") or source.get("title") or "").strip()
223
  if name and name not in seen:
 
1
+ """Constrained response planning for EmpathRAG Core.
2
+
3
+ Three layers compose the chat response:
4
+
5
+ 1. Per-route content (validation / reframe / recommended action / follow-up).
6
+ 2. Conversation stage (LISTEN / PERMISSION / OFFER) — controls *how much* of the
7
+ plan surfaces and how directive the wording is.
8
+ 3. Cross-cutting concern flags (international_concern) — append context-aware
9
+ paragraphs without changing the route.
10
+
11
+ Stages:
12
+
13
+ * ``LISTEN`` - turn 1, listen-eligible route, no explicit ask.
14
+ Acknowledge with reflection in the user's frame, plus a soft
15
+ options hint so they know what's available without being
16
+ pushed.
17
+ * ``PERMISSION`` - turn 2, still no explicit ask. Different opener than
18
+ listen (no repetition), names 1-2 specific resources
19
+ gently, asks permission to share more.
20
+ * ``OFFER`` - turn 3+, explicit ask, always-direct route, or imminent
21
+ safety. Full plan with named resources and follow-up.
22
+
23
+ Always-direct routes skip LISTEN entirely so users with explicit asks get help
24
+ immediately. Crisis bypasses everything via render_crisis_response().
25
+ """
26
 
27
  from __future__ import annotations
28
 
29
+ from dataclasses import dataclass, field
30
+ from typing import Tuple
31
 
32
  from .v2_schema import SupportRoute
33
 
34
 
35
+ LISTEN = "listen"
36
+ PERMISSION = "permission"
37
+ OFFER = "offer"
38
+
39
+
40
+ # Routes that always go straight to OFFER. The prompt itself is an explicit
41
+ # practical ask (ADS, counseling navigation, basic needs) or the safety/scope
42
+ # contract requires immediate, direct guidance.
43
+ ALWAYS_DIRECT_ROUTES = frozenset({
44
+ SupportRoute.ACCESSIBILITY_ADS.value,
45
+ SupportRoute.COUNSELING_NAVIGATION.value,
46
+ SupportRoute.BASIC_NEEDS.value,
47
+ SupportRoute.ADVISOR_CONFLICT.value,
48
+ SupportRoute.PEER_HELPER.value,
49
+ SupportRoute.CRISIS_IMMEDIATE.value,
50
+ SupportRoute.OUT_OF_SCOPE.value,
51
+ SupportRoute.CARE_VIOLENCE_CONFIDENTIAL.value,
52
+ })
53
+
54
+ EXPLICIT_ASK_PATTERNS = (
55
+ "what should i", "what do i do", "how do i", "where do i",
56
+ "what can i do", "how can i", "who do i", "where can i",
57
+ "any advice", "any tips", "can you help", "help me",
58
+ "options", "resources", "what are the", "tell me how",
59
+ "should i", "i need to", "i want to", "i'm ready",
60
+ "next step", "what now",
61
+ )
62
+
63
+ # Cues that the student is talking about visa / international-status pressure.
64
+ # F-1 students carry a different weight on academic outcomes (status tied to
65
+ # enrollment + grades + SEVIS reporting). Catching these signals lets us
66
+ # acknowledge the layered fear and surface ISSS, which handles things regular
67
+ # counseling can't.
68
+ INTERNATIONAL_PATTERNS = (
69
+ "deport", "deportation", "f-1", "f1 ", " f1", " f1.",
70
+ "visa", "i-20", "i20", "sevis", "sevp",
71
+ "international student", "international students",
72
+ "opt ", " opt.", "cpt ", " cpt.",
73
+ "academic standing",
74
+ "back to my country", "sent home", "lose my status",
75
+ )
76
+
77
+ # Reusable ISSS source dict that we inject into retrieved sources whenever we
78
+ # detect an international concern, so the Support card actually shows ISSS
79
+ # alongside whatever the route normally surfaces.
80
+ INTERNATIONAL_SOURCE_HINT = {
81
+ "service_id": "umd_isss",
82
+ "source_id": "umd_isss",
83
+ "source_name": "UMD International Student & Scholar Services (ISSS)",
84
+ "title": "UMD International Student & Scholar Services (ISSS)",
85
+ "topic": "international_student_support",
86
+ "risk_level": "safe",
87
+ "usage_mode": "retrieval",
88
+ "source_type": "university_resource",
89
+ "url": "https://globalmaryland.umd.edu/offices/international-students-scholars",
90
+ "why_retrieved": "international_concern_detected",
91
+ }
92
+
93
+
94
  @dataclass(frozen=True)
95
  class ResponsePlan:
96
  route: str
 
101
  support_option: str
102
  backup_option: str
103
  follow_up_question: str
104
+ listen_reflection: str = ""
105
+ listen_invite: str = ""
106
+ permission_opener: str = ""
107
+ international_concern: bool = False
108
+ retrieved_sources: Tuple[dict, ...] = field(default_factory=tuple)
109
 
110
+ def render(self, stage: str = OFFER) -> str:
111
+ if self.safety_tier == "imminent_safety":
112
+ stage = OFFER
113
+ if stage == LISTEN:
114
+ return self._render_listen()
115
+ if stage == PERMISSION:
116
+ return self._render_permission()
117
+ return self._render_offer()
118
+
119
+ # ------------------------------------------------------------------
120
+ def _resource_names(self, n: int = 2) -> list[str]:
121
+ names: list[str] = []
122
+ for src in self.retrieved_sources[:8]:
123
+ nm = (src.get("source_name") or src.get("title") or "").strip()
124
+ if nm and nm not in names:
125
+ names.append(nm)
126
+ if len(names) >= n:
127
+ break
128
+ return names
129
+
130
+ def _phrase_resources(self, names: list[str]) -> str:
131
+ if not names:
132
+ return ""
133
+ if len(names) == 1:
134
+ return names[0]
135
+ if len(names) == 2:
136
+ return f"{names[0]} and {names[1]}"
137
+ return f"{names[0]}, {names[1]}, and a couple of others"
138
+
139
+ # ------------------------------------------------------------------
140
+ # Stage renderers
141
+ # ------------------------------------------------------------------
142
+ def _render_listen(self) -> str:
143
+ opener = self.listen_reflection or f"{self.validation} {self.reframe}".strip()
144
+
145
+ intl_ack = ""
146
+ if self.international_concern:
147
+ intl_ack = (
148
+ "And I noticed visa or status worry is in this too — that's a different "
149
+ "layer of fear, not just \"academic\" stress. It changes who's actually "
150
+ "the right person to talk to."
151
+ )
152
+
153
+ invite = self.listen_invite or "I'm here. Want to tell me more about what's coming up for you?"
154
+ options = (
155
+ "If it'd help: I can keep listening, sit with what's underneath, or — when you're "
156
+ "ready — point you toward a specific place to start. Whichever you want."
157
  )
158
 
159
+ parts = [opener]
160
+ if intl_ack:
161
+ parts.append(intl_ack)
162
+ parts.append(invite)
163
+ parts.append(options)
164
+ return "\n\n".join(parts)
165
+
166
+ def _render_permission(self) -> str:
167
+ # Use a different opener than listen so back-to-back turns don't repeat.
168
+ opener = self.permission_opener or "Thanks for staying with this."
169
+
170
+ names = self._resource_names(2)
171
+ phrase = self._phrase_resources(names)
172
+ if phrase:
173
+ hint = (
174
+ f"When you want to widen this out: {phrase} are usually the most useful "
175
+ "starting points for what you've described. Or we can keep talking — "
176
+ "no pressure either way."
177
+ )
178
+ else:
179
+ hint = (
180
+ "When you want to widen this out, I can point you toward a couple of "
181
+ "specific places that fit. Or we can keep talking — no pressure either way."
182
+ )
183
+
184
+ intl_line = ""
185
+ if self.international_concern:
186
+ intl_line = (
187
+ "If the visa or academic-standing piece is part of why this feels so big, "
188
+ "UMD's International Student & Scholar Services (ISSS) has advisors who "
189
+ "specifically handle that — that's a different conversation from regular "
190
+ "counseling, and it's a good one to have early."
191
+ )
192
+
193
+ parts = [opener]
194
+ if intl_line:
195
+ parts.append(intl_line)
196
+ parts.append(hint)
197
+ return "\n\n".join(parts)
198
+
199
+ def _render_offer(self) -> str:
200
+ parts = [f"{self.validation} {self.reframe}".strip()]
201
+ if self.recommended_action:
202
+ parts.append(self.recommended_action)
203
+
204
+ # Soft, named resource mention — only for routes where it adds value.
205
+ if self.route not in ("crisis_immediate", "out_of_scope", "peer_helper"):
206
+ names = self._resource_names(2)
207
+ phrase = self._phrase_resources(names)
208
+ if phrase:
209
+ parts.append(
210
+ f"Specific places worth knowing: {phrase}. The source cards are on the "
211
+ "side under Inspect → Support card if you want details."
212
+ )
213
+
214
+ if self.international_concern and self.route not in ("crisis_immediate", "out_of_scope"):
215
+ parts.append(
216
+ "Because visa or international-status worry is part of this: UMD's "
217
+ "International Student & Scholar Services (ISSS) has academic-difficulty "
218
+ "advising specifically for F-1 students. They can clarify what your "
219
+ "academic standing actually means for SEVIS and your status — that's a "
220
+ "different conversation from regular counseling, and worth having early "
221
+ "rather than late."
222
+ )
223
+
224
+ # Essential safety / scope content for specific routes
225
+ if self.route in ("peer_helper", "out_of_scope") and self.support_option:
226
+ parts.append(self.support_option)
227
+ if self.route == "peer_helper" and self.backup_option:
228
+ parts.append(self.backup_option)
229
+ if self.follow_up_question:
230
+ parts.append(self.follow_up_question)
231
+ return "\n\n".join(p for p in parts if p)
232
+
233
+
234
+ def has_international_concern(message: str) -> bool:
235
+ text = (message or "").lower()
236
+ return any(p in text for p in INTERNATIONAL_PATTERNS)
237
+
238
+
239
+ def decide_stage(message: str, route: str, safety_tier: str, turn_index: int) -> str:
240
+ """Decide which conversation stage applies for this turn."""
241
+ if safety_tier == "imminent_safety":
242
+ return OFFER
243
+ if route in ALWAYS_DIRECT_ROUTES:
244
+ return OFFER
245
+ if _has_explicit_ask(message):
246
+ return OFFER
247
+ if turn_index >= 3:
248
+ return OFFER
249
+ if turn_index == 2:
250
+ return PERMISSION
251
+ return LISTEN
252
+
253
+
254
+ def _has_explicit_ask(message: str) -> bool:
255
+ text = (message or "").lower()
256
+ return any(pattern in text for pattern in EXPLICIT_ASK_PATTERNS)
257
+
258
 
259
  def build_response_plan(
260
  message: str,
 
262
  safety_tier: str,
263
  retrieved_sources: list[dict],
264
  audience_mode: str = "student",
265
+ international_concern_override: bool | None = None,
266
  ) -> ResponsePlan:
267
  source_label = _source_label(retrieved_sources)
268
+ if international_concern_override is None:
269
+ international_concern = has_international_concern(message)
270
+ else:
271
+ international_concern = bool(international_concern_override)
272
+
273
+ # When international concern is detected, ensure ISSS is in the retrieved
274
+ # source list so it shows up on the Support card alongside whatever the
275
+ # router otherwise surfaces.
276
+ enriched_sources: list[dict] = list(retrieved_sources or [])
277
+ if international_concern:
278
+ already_has_isss = any(
279
+ (s.get("source_name") or "").lower().startswith("umd international")
280
+ or (s.get("service_id") or "").startswith("umd_isss")
281
+ for s in enriched_sources
282
+ )
283
+ if not already_has_isss:
284
+ enriched_sources = [INTERNATIONAL_SOURCE_HINT] + enriched_sources
285
+
286
+ sources_tuple = tuple(enriched_sources)
287
+ common = {
288
+ "international_concern": international_concern,
289
+ "retrieved_sources": sources_tuple,
290
+ }
291
 
292
  if route == SupportRoute.ACADEMIC_SETBACK.value:
293
  return ResponsePlan(
294
  route,
295
  safety_tier,
296
+ "That sounds like a really painful moment.",
297
+ "Failing something you cared about can land hard, especially when the brain immediately starts treating it as proof of something bigger.",
298
+ "When you're ready, a small grounded step is a short office-hours note to your professor or TA — just to understand what went wrong and what to do differently next time.",
299
  f"Use {source_label} if the stress is affecting sleep, panic, or your ability to function.",
300
  "If the situation starts feeling unsafe, switch from academic planning to crisis or human support immediately.",
301
+ "Do you want me to share an email script you could adapt, or would you rather just talk it through more first?",
302
+ listen_reflection=(
303
+ "That sounds really heavy. Failing something you cared about can land "
304
+ "hard — and the part of your mind that's catastrophizing right now isn't "
305
+ "lying, it's just turned up too loud."
306
+ ),
307
+ listen_invite="Want to tell me more about what it's been bringing up for you?",
308
+ permission_opener=(
309
+ "I hear you. The piece about everything stacking at once is real — when "
310
+ "one thing tips, the rest start feeling fragile too."
311
+ ),
312
+ **common,
313
  )
314
 
315
  if route == SupportRoute.EXAM_STRESS.value:
316
  return ResponsePlan(
317
  route,
318
  safety_tier,
319
+ "That kind of pre-test heaviness makes a lot of sense.",
320
+ "When the test is this close, the mind tends to grab every worst-case scenario at once that doesn't mean any of them are true, it just means the stakes feel real.",
321
+ "If a small grounded step would help: a 10-minute reset, then picking two high-yield topics and a tiny study plan for tonight is usually more useful than trying to fix everything.",
322
  f"Use {source_label} if the stress is affecting sleep, panic, or your ability to function.",
323
  "If this shifts into not feeling safe or being unable to stay with yourself, use crisis or emergency support instead of continuing study planning.",
324
+ "Would a quick study plan, a grounding reset, or drafting a message to your instructor help most right now?",
325
+ listen_reflection=(
326
+ "That kind of pre-test heaviness is real. When something feels this "
327
+ "close and this big, the body tends to react like it's already happening."
328
+ ),
329
+ listen_invite="Want to tell me a bit more about what's been swirling?",
330
+ permission_opener=(
331
+ "Thanks for naming a bit more. The way the worry is spreading past the "
332
+ "test itself makes sense, especially when the consequences feel like they "
333
+ "go further than one grade."
334
+ ),
335
+ **common,
336
  )
337
 
338
  if route == SupportRoute.ACCESSIBILITY_ADS.value:
339
  return ResponsePlan(
340
  route,
341
  safety_tier,
342
+ "That's a practical accommodations question, and you don't have to figure it out alone.",
343
+ "Going through the official ADS process gets the request documented and traceable, which protects you later.",
344
+ "A good first move: identify the specific course or exam barrier, then start with the ADS intake page.",
345
  f"Use {source_label} for the official accommodations workflow.",
346
+ "If a deadline is urgent, also send a brief factual note to the instructor or program staff in parallel.",
347
+ "Is this for an exam, an assignment deadline, a temporary condition, or an ongoing accommodation?",
348
+ **common,
349
  )
350
 
351
  if route == SupportRoute.ADVISOR_CONFLICT.value:
352
  return ResponsePlan(
353
  route,
354
  safety_tier,
355
+ "That sounds genuinely stressful, especially when funding or power is involved.",
356
+ "Keeping the record factual and using a neutral channel before escalating tends to protect you the most.",
357
+ "A grounded next move: write down a short factual timeline of what was said and when, then look at a neutral graduate-support or Ombuds path before doing anything irreversible.",
358
  f"Use {source_label} as the campus support starting point.",
359
  "If the stress becomes unsafe or overwhelming, use counseling or crisis support before continuing the conflict process.",
360
+ "Would it help to draft the timeline together, or would you rather talk through what's actually been happening first?",
361
+ **common,
362
  )
363
 
364
  if route == SupportRoute.BASIC_NEEDS.value:
365
  return ResponsePlan(
366
  route,
367
  safety_tier,
368
+ "Food, housing, or money stress can make everything else harder very fast.",
369
+ "This is a support-navigation problem, not a personal failure, and it's exactly the kind of thing campus offices are built for.",
370
+ "A grounded next move: contact a campus student-support office and say plainly what you need help with today.",
371
  f"Use {source_label}; rely on the source card for verified contact, hours, and eligibility details.",
372
  "If your safety or shelter is immediately at risk, use emergency or crisis support instead of waiting.",
373
+ "What's the most urgent piece right now — food, housing, money, or finding the right campus contact?",
374
+ **common,
375
  )
376
 
377
  if route == SupportRoute.PEER_HELPER.value:
378
  return ResponsePlan(
379
  route,
380
  safety_tier,
381
+ "It makes sense that you're worried about your friend.",
382
+ "You can support them, and you should not be the only safety plan.",
383
+ "If you're with them now: ask directly whether they're safe, stay with them or keep them connected if possible, and involve emergency or crisis support if there may be immediate danger.",
384
  f"Use {source_label} for helping-someone-else or crisis guidance.",
385
+ "What to say: I care about you, I'm worried, and I want to get another person involved so you're not alone. What not to say: do not promise secrecy or agree to handle safety risk by yourself.",
386
  "Are they reachable right now, and is someone physically nearby who can check on them?",
387
+ **common,
388
  )
389
 
390
  if route == SupportRoute.ANXIETY_PANIC.value:
 
394
  safety_tier,
395
  "That kind of nervousness before meeting someone you like is very normal.",
396
  "It does not mean something is wrong; it usually means the moment matters to you and your brain is trying to predict every possible outcome.",
397
+ "If it would help: keep it simple — one easy opener, one genuine question you could ask, and one graceful exit if either of you feels awkward.",
398
  "You probably do not need a formal campus resource for ordinary date nerves; if anxiety starts disrupting sleep, eating, or daily functioning, the source cards can point you toward support.",
399
  "If this shifts into panic, feeling unsafe, or being unable to function, use a human support option rather than trying to push through alone.",
400
  "Want to brainstorm a relaxed opening line, a few questions to ask, or how to calm down beforehand?",
401
+ listen_reflection=(
402
+ "That kind of nervousness before meeting someone you like is very "
403
+ "normal. It usually means the moment matters to you and your brain "
404
+ "is trying to predict every possible outcome."
405
+ ),
406
+ listen_invite="Want to tell me a bit more about what you're hoping for or worried about?",
407
+ permission_opener=(
408
+ "I hear you. Pre-date jitters can balloon when you've been thinking "
409
+ "about it for a while — they don't always shrink with more thinking."
410
+ ),
411
+ **common,
412
  )
413
  return ResponsePlan(
414
  route,
415
  safety_tier,
416
  "That sounds like anxiety is taking up a lot of space right now.",
417
+ "Anxiety like this can feel huge in the body even when nothing visible has changed yet that's exhausting to ride out alone.",
418
+ "If a grounded step would help: one short grounding reset, and then we can decide whether finding a campus support path is the next move.",
419
  f"Use {source_label} for anxiety, grounding, or counseling support.",
420
  "If the anxiety shifts into not feeling safe, use crisis support instead of continuing here.",
421
+ "Would you rather try a grounding reset together, or look at who you could talk to?",
422
+ listen_reflection=(
423
+ "That sounds like anxiety is taking up a lot of room right now. "
424
+ "Even when nothing visible has changed, the body can feel like it's "
425
+ "in the middle of something — and that's exhausting."
426
+ ),
427
+ listen_invite="Want to tell me a bit more about what's been going on?",
428
+ permission_opener=(
429
+ "Thanks for sharing more. Anxiety this loud is a real thing to be sitting "
430
+ "with — it's not a small ask of yourself."
431
+ ),
432
+ **common,
433
  )
434
 
435
  if route == SupportRoute.LOW_MOOD.value:
436
  return ResponsePlan(
437
  route,
438
  safety_tier,
439
+ "That sounds heavy.",
440
+ "Low mood has a way of making everything quieter and farther away including the parts of life that usually help.",
441
+ "If a small step would feel doable: telling one trusted person what's been going on, and looking at a counseling starting point. We don't have to do both today.",
442
  f"Use {source_label} as a grounded starting point.",
443
  "If this turns into not feeling safe, use 988 or emergency support immediately.",
444
+ "What's been the hardest part lately motivation, isolation, sleep, or asking for help?",
445
+ listen_reflection=(
446
+ "That sounds heavy. Low mood has a way of pulling everything that "
447
+ "usually helps a little farther out of reach."
448
+ ),
449
+ listen_invite="Take your time — what's been on your mind most?",
450
+ permission_opener=(
451
+ "I hear you. The way it's been pulling everything farther away — "
452
+ "that's a real thing, not weakness."
453
+ ),
454
+ **common,
455
+ )
456
+
457
+ if route == SupportRoute.LONELINESS_ISOLATION.value:
458
+ return ResponsePlan(
459
+ route,
460
+ safety_tier,
461
+ "That sounds really lonely.",
462
+ "Isolation can quietly become its own thing — it's not weakness, and it doesn't mean you haven't tried.",
463
+ "If a small step would help: reaching out to one person you used to feel close to, even with a tiny message, is often more useful than trying to rebuild a whole social life at once.",
464
+ f"Use {source_label} for connection and support.",
465
+ "If isolation starts feeling unsafe or hopeless, use a counseling or crisis support path.",
466
+ "Has there been one person you keep almost reaching out to but haven't?",
467
+ listen_reflection=(
468
+ "That sounds really lonely. Isolation has a way of growing in the "
469
+ "background until everything starts feeling far away."
470
+ ),
471
+ listen_invite="Want to tell me a bit about what it's been like?",
472
+ permission_opener=(
473
+ "Thanks for staying with this. Loneliness is one of those feelings that "
474
+ "gets quieter the longer it sits, and that makes it harder to name."
475
+ ),
476
+ **common,
477
  )
478
 
479
  if route == SupportRoute.COUNSELING_NAVIGATION.value:
 
481
  route,
482
  safety_tier,
483
  "It is completely reasonable to want a clear first step.",
484
+ "Counseling navigation works best when it's practical and source-grounded, not vague encouragement.",
485
+ "A solid first move: start with the official counseling source and look for intake, scheduling, or referral instructions.",
486
  f"Use {source_label} for the current official path.",
487
  "If the need becomes urgent or safety-related, use crisis resources instead of ordinary scheduling.",
488
  "Are you trying to start care, understand confidentiality, or find referral options?",
489
+ **common,
490
  )
491
 
492
  if route == SupportRoute.OUT_OF_SCOPE.value:
493
  return ResponsePlan(
494
  route,
495
  safety_tier,
496
+ "That doesn't look like a student-support navigation request.",
497
  "I should avoid pretending this system is a general authority outside its scope.",
498
  "Use an appropriate official or professional source for that topic.",
499
  "No support source is retrieved for this route because the request is outside the system scope.",
500
  "For medical, legal, or emergency issues, use qualified human support.",
501
  "Do you want to rephrase this as a campus-support question?",
502
+ **common,
503
  )
504
 
505
+ # Generic / general_student_support fallback - listen-eligible.
506
  return ResponsePlan(
507
  route,
508
  safety_tier,
509
+ "That sounds like a lot to carry.",
510
+ "You shouldn't have to sort through this from scratch on your own.",
511
+ "When you're ready, a useful first move is naming the kind of support that fits whether that's someone to talk to, a campus office, or a small concrete next step.",
512
  f"Use {source_label} as the starting point.",
513
  "If this becomes urgent or safety-related, switch to crisis or emergency support.",
514
+ "What would help most first: a next-step checklist, who to contact, or just talking it through more?",
515
+ listen_reflection=(
516
+ "That sounds like a lot to carry. Whatever's underneath this, you don't "
517
+ "have to have it figured out right now."
518
+ ),
519
+ listen_invite="What's been weighing on you most lately?",
520
+ permission_opener=(
521
+ "I hear you. The way it's all collapsing at once is exhausting — that "
522
+ "kind of overload usually has more than one thing pulling on it."
523
+ ),
524
+ **common,
525
  )
526
 
527
 
528
  def render_crisis_response(route: str, audience_mode: str = "student") -> str:
529
  if route == SupportRoute.PEER_HELPER.value or audience_mode == "helping_friend":
530
  return (
531
+ "I'm really glad you told me about this. This sounds like an immediate safety "
532
+ "situation for your friend, and you should not handle it alone. Please contact "
533
+ "emergency or crisis support now — and involve a trusted nearby person, RA, "
534
+ "supervisor, or campus support while you try to reach them."
535
  )
536
  return (
537
+ "I'm really glad you told me. What you're describing sounds like a moment where "
538
+ "having a real person with you matters. Please call or text 988 now, or call "
539
+ "emergency services if there's immediate danger. If someone nearby can stay with "
540
+ "you, move near them while you get help. I'm here while you do."
541
  )
542
 
543
 
 
565
 
566
 
567
  def _source_names(retrieved_sources: list[dict]) -> str:
568
+ seen: list[str] = []
569
  for source in retrieved_sources:
570
  name = str(source.get("source_name") or source.get("title") or "").strip()
571
  if name and name not in seen:
tests/test_v25_support_navigator.py CHANGED
@@ -32,7 +32,10 @@ def test_failed_exam_routes_to_academic_setback_with_action():
32
  result = make_fast_pipeline().run("I failed my exam and my future is over")
33
  assert result["route_label"] == SupportRoute.ACADEMIC_SETBACK.value
34
  assert result["safety_tier"] in {SafetyTier.SUPPORT_NAVIGATION.value, SafetyTier.HIGH_DISTRESS.value}
35
- assert "For right now:" in result["response"]
 
 
 
36
  assert not any(source["usage_mode"] == "crisis_only" for source in result["retrieved_sources"])
37
 
38
 
@@ -46,15 +49,15 @@ def test_test_tomorrow_distress_routes_to_exam_stress():
46
  result = make_fast_pipeline().run("I'm scared about my test tomorrow, I feel very devastated.")
47
  assert result["route_label"] == SupportRoute.EXAM_STRESS.value
48
  assert result["safety_tier"] in {SafetyTier.SUPPORT_NAVIGATION.value, SafetyTier.HIGH_DISTRESS.value}
49
- assert "For right now:" in result["response"]
50
- assert "study plan" in result["response"].lower() or "grounding" in result["response"].lower()
51
 
52
 
53
  def test_social_date_nerves_do_not_route_to_exam_stress():
54
  result = make_fast_pipeline().run("I'm nervous to meet a girl I asked out tomorrow")
55
  assert result["route_label"] == SupportRoute.ANXIETY_PANIC.value
56
  assert result["safety_tier"] != SafetyTier.IMMINENT_SAFETY.value
57
- assert "ordinary date nerves" in result["response"]
58
  assert "study plan" not in result["response"].lower()
59
 
60
 
@@ -67,7 +70,7 @@ def test_advisor_conflict_does_not_over_escalate():
67
  def test_basic_needs_route_uses_available_graph_without_hallucinating_pantries():
68
  result = make_fast_pipeline().run("I have not eaten today because I am out of money")
69
  assert result["route_label"] == SupportRoute.BASIC_NEEDS.value
70
- assert "For right now:" in result["response"]
71
  assert any(
72
  "Dean of Students" in source["source_name"]
73
  or "Campus Pantry" in source["source_name"]
@@ -87,7 +90,8 @@ def test_peer_helper_mode_routes_friend_safety():
87
  result = make_fast_pipeline().run("My roommate said goodbye and will not answer", audience_mode="helping_friend")
88
  assert result["route_label"] == SupportRoute.PEER_HELPER.value
89
  assert result["safety_tier"] == SafetyTier.IMMINENT_SAFETY.value
90
- assert "handle this alone" in result["response"].lower()
 
91
 
92
 
93
  def test_output_guard_catches_self_degrading_compliance():
 
32
  result = make_fast_pipeline().run("I failed my exam and my future is over")
33
  assert result["route_label"] == SupportRoute.ACADEMIC_SETBACK.value
34
  assert result["safety_tier"] in {SafetyTier.SUPPORT_NAVIGATION.value, SafetyTier.HIGH_DISTRESS.value}
35
+ # The chat response on turn 1 is LISTEN-stage (acknowledgment + open invite),
36
+ # so the practical move lives in recommended_action, surfaced via the Support card.
37
+ rec = result["recommended_action"].lower()
38
+ assert "office-hours" in rec or "professor" in rec
39
  assert not any(source["usage_mode"] == "crisis_only" for source in result["retrieved_sources"])
40
 
41
 
 
49
  result = make_fast_pipeline().run("I'm scared about my test tomorrow, I feel very devastated.")
50
  assert result["route_label"] == SupportRoute.EXAM_STRESS.value
51
  assert result["safety_tier"] in {SafetyTier.SUPPORT_NAVIGATION.value, SafetyTier.HIGH_DISTRESS.value}
52
+ rec = result["recommended_action"].lower()
53
+ assert "study plan" in rec or "grounding" in rec or "reset" in rec
54
 
55
 
56
  def test_social_date_nerves_do_not_route_to_exam_stress():
57
  result = make_fast_pipeline().run("I'm nervous to meet a girl I asked out tomorrow")
58
  assert result["route_label"] == SupportRoute.ANXIETY_PANIC.value
59
  assert result["safety_tier"] != SafetyTier.IMMINENT_SAFETY.value
60
+ assert "moment matters" in result["response"].lower() or "brainstorm" in result["response"].lower()
61
  assert "study plan" not in result["response"].lower()
62
 
63
 
 
70
  def test_basic_needs_route_uses_available_graph_without_hallucinating_pantries():
71
  result = make_fast_pipeline().run("I have not eaten today because I am out of money")
72
  assert result["route_label"] == SupportRoute.BASIC_NEEDS.value
73
+ assert "campus" in result["response"].lower() or "support office" in result["response"].lower()
74
  assert any(
75
  "Dean of Students" in source["source_name"]
76
  or "Campus Pantry" in source["source_name"]
 
90
  result = make_fast_pipeline().run("My roommate said goodbye and will not answer", audience_mode="helping_friend")
91
  assert result["route_label"] == SupportRoute.PEER_HELPER.value
92
  assert result["safety_tier"] == SafetyTier.IMMINENT_SAFETY.value
93
+ response = result["response"].lower()
94
+ assert "not handle it alone" in response or "handle this alone" in response or "should not handle" in response
95
 
96
 
97
  def test_output_guard_catches_self_degrading_compliance():