Add stage-aware listening planner and F-1 student awareness
Browse filesPrevious 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 +3 -0
- demo/app.py +965 -905
- src/pipeline/core.py +71 -7
- src/pipeline/output_guard.py +18 -0
- src/pipeline/response_planner.py +403 -55
- tests/test_v25_support_navigator.py +10 -6
|
@@ -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."}
|
|
@@ -50,645 +50,699 @@ CURATED_DB_PATH = Path(os.getenv("EMPATHRAG_CURATED_DB", "data/curated/indexes/m
|
|
| 50 |
|
| 51 |
APP_CSS = """
|
| 52 |
:root {
|
| 53 |
-
--
|
| 54 |
-
--
|
| 55 |
-
--
|
| 56 |
-
--
|
| 57 |
-
--
|
| 58 |
-
--
|
| 59 |
-
--
|
| 60 |
-
--
|
| 61 |
-
--
|
| 62 |
-
--
|
| 63 |
-
--
|
| 64 |
-
--
|
| 65 |
-
--
|
| 66 |
-
--
|
| 67 |
-
--
|
| 68 |
-
--
|
| 69 |
-
--
|
| 70 |
-
--
|
| 71 |
-
--
|
| 72 |
-
--
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
html, body {
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 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 |
-
|
| 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 |
-
|
| 111 |
-
|
| 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 |
-
|
| 121 |
-
|
|
|
|
|
|
|
| 122 |
}
|
| 123 |
|
| 124 |
-
.gradio-container {
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
}
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
color: var(--
|
| 157 |
}
|
|
|
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
border:
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
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 |
-
|
| 215 |
-
max-width:
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
display: flex;
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
}
|
| 226 |
-
.er-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 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-
|
| 264 |
-
|
| 265 |
-
border
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
}
|
| 274 |
-
.er-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
color:
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
}
|
| 282 |
-
.er-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
position: sticky;
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
|
|
|
| 288 |
border-radius: 16px;
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
color: var(--
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
.er-mini-title {
|
| 299 |
font-size: 11px;
|
| 300 |
-
|
| 301 |
letter-spacing: 0.08em;
|
| 302 |
text-transform: uppercase;
|
| 303 |
-
|
|
|
|
| 304 |
}
|
| 305 |
.er-empty {
|
| 306 |
-
color: var(--
|
| 307 |
font-size: 13px;
|
| 308 |
-
padding:
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
display: grid;
|
| 312 |
grid-template-columns: 1fr 1fr;
|
| 313 |
gap: 8px;
|
| 314 |
}
|
| 315 |
-
.er-
|
| 316 |
-
|
| 317 |
-
border
|
| 318 |
-
|
| 319 |
-
|
|
|
|
| 320 |
}
|
| 321 |
-
.er-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
|
|
|
|
|
|
| 326 |
}
|
| 327 |
-
.er-
|
| 328 |
-
font-size: 13px;
|
| 329 |
-
|
| 330 |
}
|
| 331 |
-
.er-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
}
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
}
|
| 350 |
-
.
|
| 351 |
-
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
| 353 |
gap: 5px;
|
| 354 |
-
|
|
|
|
|
|
|
| 355 |
}
|
| 356 |
-
.er-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 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-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
|
|
|
| 396 |
}
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
color:
|
| 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 |
-
|
| 409 |
-
|
|
|
|
|
|
|
| 410 |
}
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 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 |
-
|
| 418 |
-
|
| 419 |
-
|
|
|
|
| 420 |
}
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 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 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
gap: 8px;
|
| 460 |
-
margin-top: 18px;
|
| 461 |
position: relative;
|
| 462 |
}
|
| 463 |
-
.er-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 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 |
-
|
| 514 |
-
|
| 515 |
-
|
| 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 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
|
|
|
| 526 |
}
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
|
|
|
| 530 |
}
|
| 531 |
-
.er-
|
| 532 |
-
|
| 533 |
-
|
| 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 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
|
|
|
|
|
|
|
|
|
| 544 |
}
|
| 545 |
-
.er-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
|
|
|
| 550 |
}
|
| 551 |
-
.er-
|
| 552 |
-
|
| 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 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
line-height: 1.4;
|
| 574 |
}
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
|
|
|
| 578 |
}
|
| 579 |
-
.er-
|
| 580 |
-
|
| 581 |
-
border-radius: 13px;
|
| 582 |
-
padding: 10px;
|
| 583 |
-
background: rgba(3,7,18,0.48);
|
| 584 |
}
|
| 585 |
-
.er-
|
| 586 |
-
|
| 587 |
-
color: var(--
|
| 588 |
-
font-size:
|
| 589 |
-
letter-spacing: 0.
|
| 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 |
-
|
| 599 |
-
|
| 600 |
-
|
|
|
|
|
|
|
|
|
|
| 601 |
font-size: 11px;
|
| 602 |
-
|
| 603 |
-
margin-top: 4px;
|
| 604 |
-
}
|
| 605 |
-
.er-meter {
|
| 606 |
-
height: 6px;
|
| 607 |
border-radius: 999px;
|
| 608 |
-
background: rgba(
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 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 |
-
|
| 620 |
-
|
| 621 |
-
}
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
}
|
| 625 |
-
.er-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
}
|
| 629 |
-
.er-
|
| 630 |
-
|
| 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'>
|
| 1334 |
-
"<div class='er-empty'>
|
| 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
|
| 1370 |
-
"<div class='er-empty'>No
|
| 1371 |
)
|
| 1372 |
-
|
| 1373 |
-
|
| 1374 |
-
|
| 1375 |
-
|
| 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
|
| 1387 |
-
f"
|
| 1388 |
)
|
| 1389 |
-
html +=
|
| 1390 |
-
|
| 1391 |
-
|
| 1392 |
-
|
| 1393 |
-
|
| 1394 |
-
|
| 1395 |
-
|
| 1396 |
-
|
| 1397 |
-
|
| 1398 |
-
|
| 1399 |
-
"<
|
| 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-
|
| 1415 |
-
f"
|
| 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
|
| 1426 |
-
"<div class='er-empty'>
|
| 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 |
-
|
| 1437 |
-
|
| 1438 |
-
|
| 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 |
-
|
| 1455 |
-
|
| 1456 |
-
|
| 1457 |
-
|
| 1458 |
-
|
| 1459 |
-
|
| 1460 |
-
|
| 1461 |
-
|
| 1462 |
-
f"<div class='er-
|
| 1463 |
-
|
| 1464 |
-
|
| 1465 |
-
|
| 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'>
|
| 1473 |
-
"<div class='er-empty'>
|
| 1474 |
)
|
| 1475 |
-
|
| 1476 |
-
|
| 1477 |
-
|
| 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 =
|
|
|
|
| 1485 |
safety_precheck = result.get("safety_precheck", {}) or {}
|
| 1486 |
-
precheck_reason =
|
| 1487 |
-
precheck_level =
|
| 1488 |
-
|
| 1489 |
-
|
| 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 |
-
|
| 1515 |
-
|
| 1516 |
-
|
| 1517 |
-
|
| 1518 |
-
|
| 1519 |
-
|
| 1520 |
-
|
| 1521 |
-
|
| 1522 |
-
|
| 1523 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1524 |
|
| 1525 |
-
|
| 1526 |
-
if not
|
| 1527 |
-
|
| 1528 |
-
|
| 1529 |
-
|
| 1530 |
-
|
| 1531 |
-
|
| 1532 |
-
|
| 1533 |
-
|
| 1534 |
-
|
| 1535 |
-
|
| 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 |
-
|
| 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 |
-
|
| 1615 |
-
|
| 1616 |
-
|
| 1617 |
-
|
| 1618 |
-
|
| 1619 |
-
|
| 1620 |
-
|
| 1621 |
-
|
| 1622 |
-
|
| 1623 |
-
|
| 1624 |
-
|
| 1625 |
-
|
| 1626 |
-
|
| 1627 |
-
|
| 1628 |
-
|
| 1629 |
-
|
| 1630 |
-
return
|
| 1631 |
yield (
|
| 1632 |
chat_history,
|
| 1633 |
-
|
| 1634 |
timeline_html,
|
| 1635 |
result["trajectory"],
|
| 1636 |
-
|
| 1637 |
-
|
| 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(
|
|
|
|
|
|
|
| 1646 |
else:
|
| 1647 |
confidence, ig_tokens = result["crisis_confidence"], []
|
| 1648 |
-
|
| 1649 |
yield (
|
| 1650 |
chat_history,
|
| 1651 |
-
|
| 1652 |
timeline_html,
|
| 1653 |
result["trajectory"],
|
| 1654 |
format_ig_panel(True, confidence, ig_tokens, loading=False),
|
| 1655 |
-
|
| 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.
|
| 1760 |
primary_hue="teal",
|
| 1761 |
-
secondary_hue="
|
| 1762 |
-
neutral_hue="
|
| 1763 |
-
radius_size=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1764 |
)
|
| 1765 |
|
| 1766 |
-
|
|
|
|
| 1767 |
initial_state = new_session_state()
|
| 1768 |
session_state = gr.State(value=initial_state)
|
|
|
|
| 1769 |
|
| 1770 |
-
|
| 1771 |
-
|
| 1772 |
-
|
| 1773 |
-
|
| 1774 |
-
<div>
|
| 1775 |
-
<
|
| 1776 |
-
|
| 1777 |
-
|
| 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 |
-
|
| 1799 |
-
|
| 1800 |
-
|
| 1801 |
-
|
| 1802 |
-
|
| 1803 |
-
|
| 1804 |
-
|
| 1805 |
-
|
| 1806 |
-
|
| 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 |
-
|
| 1819 |
-
|
| 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-
|
| 1830 |
-
|
| 1831 |
-
|
| 1832 |
-
|
| 1833 |
-
|
| 1834 |
-
|
| 1835 |
-
|
| 1836 |
-
|
| 1837 |
-
|
| 1838 |
-
|
| 1839 |
-
|
| 1840 |
-
|
| 1841 |
-
|
| 1842 |
-
|
| 1843 |
-
|
| 1844 |
-
|
| 1845 |
-
|
| 1846 |
-
|
| 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 |
-
|
| 1868 |
-
|
| 1869 |
-
|
| 1870 |
-
|
| 1871 |
-
|
| 1872 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 1893 |
inputs=[msg_box, chatbot, session_state, audience_mode_box],
|
| 1894 |
outputs=submit_outputs,
|
| 1895 |
).then(lambda: "", outputs=msg_box)
|
| 1896 |
|
| 1897 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1898 |
|
| 1899 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1904 |
-
lambda: set_prompt("I need disability accommodations for
|
| 1905 |
outputs=msg_box,
|
| 1906 |
)
|
| 1907 |
-
|
| 1908 |
lambda: set_prompt("My advisor keeps dismissing my concerns and I need someone neutral to talk to."),
|
| 1909 |
outputs=msg_box,
|
| 1910 |
)
|
| 1911 |
-
|
| 1912 |
lambda: set_prompt("I am panicking before my exam. Can you help me with a grounding exercise?"),
|
| 1913 |
outputs=msg_box,
|
| 1914 |
)
|
| 1915 |
-
|
| 1916 |
-
lambda: set_prompt(
|
| 1917 |
-
|
| 1918 |
-
|
| 1919 |
-
|
| 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)
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
recommended_action = plan.recommended_action
|
| 162 |
-
guard
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -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 = (
|
|
@@ -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 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
if route == SupportRoute.ACADEMIC_SETBACK.value:
|
| 42 |
return ResponsePlan(
|
| 43 |
route,
|
| 44 |
safety_tier,
|
| 45 |
-
"That sounds like a painful
|
| 46 |
-
"
|
| 47 |
-
"
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
)
|
| 52 |
|
| 53 |
if route == SupportRoute.EXAM_STRESS.value:
|
| 54 |
return ResponsePlan(
|
| 55 |
route,
|
| 56 |
safety_tier,
|
| 57 |
-
"
|
| 58 |
-
"
|
| 59 |
-
"
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
)
|
| 64 |
|
| 65 |
if route == SupportRoute.ACCESSIBILITY_ADS.value:
|
| 66 |
return ResponsePlan(
|
| 67 |
route,
|
| 68 |
safety_tier,
|
| 69 |
-
"That
|
| 70 |
-
"
|
| 71 |
-
"
|
| 72 |
f"Use {source_label} for the official accommodations workflow.",
|
| 73 |
-
"If a deadline is urgent, also
|
| 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
|
| 82 |
-
"
|
| 83 |
-
"
|
| 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 |
-
"
|
|
|
|
| 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
|
| 94 |
-
"This is a support-navigation problem, not a personal failure.",
|
| 95 |
-
"
|
| 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 |
-
"
|
|
|
|
| 99 |
)
|
| 100 |
|
| 101 |
if route == SupportRoute.PEER_HELPER.value:
|
| 102 |
return ResponsePlan(
|
| 103 |
route,
|
| 104 |
safety_tier,
|
| 105 |
-
"It makes sense that you
|
| 106 |
-
"You can support them,
|
| 107 |
-
"
|
| 108 |
f"Use {source_label} for helping-someone-else or crisis guidance.",
|
| 109 |
-
"What to say: I care about you, I
|
| 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 |
-
"
|
| 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 |
-
"
|
| 130 |
-
"
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
)
|
| 135 |
|
| 136 |
if route == SupportRoute.LOW_MOOD.value:
|
| 137 |
return ResponsePlan(
|
| 138 |
route,
|
| 139 |
safety_tier,
|
| 140 |
-
"That sounds heavy
|
| 141 |
-
"Low mood
|
| 142 |
-
"
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 154 |
-
"
|
| 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
|
| 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
|
| 176 |
-
"
|
| 177 |
-
"
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 188 |
-
"
|
|
|
|
|
|
|
| 189 |
)
|
| 190 |
return (
|
| 191 |
-
"I
|
| 192 |
-
"
|
|
|
|
|
|
|
| 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:
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 50 |
-
assert "study plan" in
|
| 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 "
|
| 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 "
|
| 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 |
-
|
|
|
|
| 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():
|