MukulRay commited on
Commit
8d5f30a
Β·
1 Parent(s): 65074ce

Fix conversational planner: advisor over-trigger, yes-after-offer flow, crisis + misconduct pattern gaps

Browse files

- v2_schema: drop bare "advisor" keyword from ADVISOR_CONFLICT rule; undergrad mentions
of an advisor no longer route to grad-Ombuds template. Explicit conflict signals
(Ombuds, funding-threat, PI, retaliation) still fire the route.

- v2_schema: broaden AUTHORITY_MISCONDUCT_CONTENT to cover the common phrasings of
"told me i should drop out / give up", "told me to just drop out", etc., so the
authority-misconduct route fires on natural hedged speech instead of only the
literal "told me to drop out" / "told me to give up".

- safety_policy: add "don't want to be here anymore" / "do not want to be here
anymore" to EXPLICIT_CRISIS_PATTERNS. Original "not be here anymore" regex
missed the natural negated phrasing where the negation lives one token earlier.

- core: add session_open_offer state machine. When an OFFER turn renders with a
follow-up question, the slot is captured. A subsequent minimal-affirm ("yes" /
"ok") advances through initial -> acknowledged -> delivered instead of either
re-rendering the same OFFER template (the original bug) or falling back to the
generic minimal-followup clarifier. Per-route concrete starters: academic_setback
ships an email template, anxiety_panic ships a grounding script, low_mood ships
a small-action prompt, etc.

src/pipeline/core.py CHANGED
@@ -17,6 +17,7 @@ from .output_guard import validate_output
17
  from .response_planner import (
18
  CLARIFY,
19
  INTERNATIONAL_SOURCE_HINT,
 
20
  build_response_plan,
21
  classify_intl_topic,
22
  decide_stage,
@@ -159,6 +160,19 @@ class EmpathRAGCore:
159
  # every subsequent response.
160
  self.session_turns_since_intl: dict[str, int] = {}
161
  self.session_last_specific_route: dict[str, str] = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  # Rolling per-session message log so the rephraser has continuity.
163
  # Only the last few user-assistant pairs are passed to the LLM; full
164
  # history is kept here for diagnostics. Each entry: {"role", "content"}.
@@ -171,6 +185,7 @@ class EmpathRAGCore:
171
  self.session_intl_flag.pop(session_id, None)
172
  self.session_turns_since_intl.pop(session_id, None)
173
  self.session_last_specific_route.pop(session_id, None)
 
174
  self.session_message_history.pop(session_id, None)
175
  else:
176
  self.tier_history.clear()
@@ -178,6 +193,7 @@ class EmpathRAGCore:
178
  self.session_intl_flag.clear()
179
  self.session_turns_since_intl.clear()
180
  self.session_last_specific_route.clear()
 
181
  self.session_message_history.clear()
182
 
183
  # Input length cap: defends against (1) accidental wall-of-text pastes
@@ -463,6 +479,36 @@ class EmpathRAGCore:
463
  template_response = _render_meta()
464
  stage = CLARIFY
465
  recommended_action = response_plan.recommended_action
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  elif minimal_kind:
467
  template_response = _render_minimal_followup(minimal_kind, intl_session)
468
  stage = CLARIFY
@@ -481,6 +527,39 @@ class EmpathRAGCore:
481
  template_response = response_plan.render(stage)
482
  recommended_action = response_plan.recommended_action
483
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
  return _TurnPlan(
485
  message=message,
486
  session_id=session_id,
@@ -1045,6 +1124,145 @@ def _render_incomplete_followup() -> str:
1045
  )
1046
 
1047
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1048
  def _render_minimal_followup(kind: str, intl_session: bool) -> str:
1049
  """Short clarifying response when the user's reply was just yes/no/maybe.
1050
 
 
17
  from .response_planner import (
18
  CLARIFY,
19
  INTERNATIONAL_SOURCE_HINT,
20
+ OFFER,
21
  build_response_plan,
22
  classify_intl_topic,
23
  decide_stage,
 
160
  # every subsequent response.
161
  self.session_turns_since_intl: dict[str, int] = {}
162
  self.session_last_specific_route: dict[str, str] = {}
163
+ # Per-session "open offer" state. Set whenever the planner renders an
164
+ # OFFER turn that ends in a follow-up question. Used to handle the
165
+ # next-turn minimal-affirm response intelligently β€” a bare "yes" /
166
+ # "ok" after an offer should *advance* the conversation (acknowledge
167
+ # the consent, then deliver something concrete), not re-render the
168
+ # same OFFER template back at the user. State machine per session:
169
+ # {
170
+ # "route": str, # the OFFER route that set this state
171
+ # "question": str, # the follow-up question that was asked
172
+ # "offer_turn": int, # turn index when the OFFER was rendered
173
+ # "stage": "initial" | "acknowledged" | "delivered",
174
+ # }
175
+ self.session_open_offer: dict[str, dict] = {}
176
  # Rolling per-session message log so the rephraser has continuity.
177
  # Only the last few user-assistant pairs are passed to the LLM; full
178
  # history is kept here for diagnostics. Each entry: {"role", "content"}.
 
185
  self.session_intl_flag.pop(session_id, None)
186
  self.session_turns_since_intl.pop(session_id, None)
187
  self.session_last_specific_route.pop(session_id, None)
188
+ self.session_open_offer.pop(session_id, None)
189
  self.session_message_history.pop(session_id, None)
190
  else:
191
  self.tier_history.clear()
 
193
  self.session_intl_flag.clear()
194
  self.session_turns_since_intl.clear()
195
  self.session_last_specific_route.clear()
196
+ self.session_open_offer.clear()
197
  self.session_message_history.clear()
198
 
199
  # Input length cap: defends against (1) accidental wall-of-text pastes
 
479
  template_response = _render_meta()
480
  stage = CLARIFY
481
  recommended_action = response_plan.recommended_action
482
+ elif minimal_kind == "affirm" and _open_offer_recent(
483
+ self.session_open_offer.get(session_id), turn_index
484
+ ):
485
+ # The student just said yes/ok to an OFFER we made on a
486
+ # recent turn. Re-rendering the same template at this point
487
+ # was the bug: the user already consented, the planner
488
+ # should advance. Two-stage advance:
489
+ # 1st affirm -> acknowledge consent + naturalize the
490
+ # binary disambiguation that the OFFER asked
491
+ # 2nd affirm -> stop asking, deliver a concrete starter
492
+ # appropriate to the route
493
+ offer_state = dict(self.session_open_offer[session_id])
494
+ if offer_state.get("stage") == "initial":
495
+ template_response = _render_consent_acknowledged(
496
+ offer_state.get("route", ""),
497
+ offer_state.get("question", ""),
498
+ )
499
+ offer_state["stage"] = "acknowledged"
500
+ offer_state["acknowledged_turn"] = turn_index
501
+ self.session_open_offer[session_id] = offer_state
502
+ stage = OFFER
503
+ else:
504
+ template_response = _render_consent_delivered(
505
+ offer_state.get("route", "")
506
+ )
507
+ offer_state["stage"] = "delivered"
508
+ offer_state["delivered_turn"] = turn_index
509
+ self.session_open_offer[session_id] = offer_state
510
+ stage = OFFER
511
+ recommended_action = response_plan.recommended_action
512
  elif minimal_kind:
513
  template_response = _render_minimal_followup(minimal_kind, intl_session)
514
  stage = CLARIFY
 
527
  template_response = response_plan.render(stage)
528
  recommended_action = response_plan.recommended_action
529
 
530
+ # Capture an open-offer slot whenever this turn rendered an OFFER
531
+ # with a follow-up question, so the next turn can recognize a
532
+ # bare "yes" / "ok" as consent and advance the conversation.
533
+ # Skip when the consent flow itself just rendered this turn β€”
534
+ # the slot has already been updated above and overwriting it
535
+ # would reset the consent progression.
536
+ existing_offer = self.session_open_offer.get(session_id) or {}
537
+ advanced_this_turn = (
538
+ existing_offer.get("acknowledged_turn") == turn_index
539
+ or existing_offer.get("delivered_turn") == turn_index
540
+ )
541
+ if (
542
+ stage == OFFER
543
+ and not should_intercept
544
+ and response_plan.follow_up_question
545
+ and not advanced_this_turn
546
+ ):
547
+ self.session_open_offer[session_id] = {
548
+ "route": route_label,
549
+ "question": response_plan.follow_up_question.strip(),
550
+ "offer_turn": turn_index,
551
+ "stage": "initial",
552
+ }
553
+ elif (
554
+ stage not in (OFFER, CLARIFY)
555
+ and not should_intercept
556
+ and not advanced_this_turn
557
+ ):
558
+ # LISTEN / PERMISSION turns reset any stale open-offer so we
559
+ # don't bind a much-later "yes" to an offer the user already
560
+ # moved past.
561
+ self.session_open_offer.pop(session_id, None)
562
+
563
  return _TurnPlan(
564
  message=message,
565
  session_id=session_id,
 
1124
  )
1125
 
1126
 
1127
+ def _open_offer_recent(state: dict | None, current_turn: int) -> bool:
1128
+ """Is there an OFFER on the table that the user could be saying yes to?
1129
+
1130
+ True if an OFFER was rendered on the previous or current turn (current
1131
+ can match in unusual harness flows where turn_index is not strictly
1132
+ incrementing) and we haven't already delivered the concrete starter.
1133
+ """
1134
+ if not state:
1135
+ return False
1136
+ offer_turn = state.get("offer_turn", 0)
1137
+ if not isinstance(offer_turn, int):
1138
+ return False
1139
+ # Allow consent on the immediately following turn or two β€” leaves a
1140
+ # small margin for the second affirm in a row, which is the bug we're
1141
+ # fixing. After "delivered" we stop binding new affirms here.
1142
+ if state.get("stage") == "delivered":
1143
+ return False
1144
+ return current_turn - offer_turn in (1, 2)
1145
+
1146
+
1147
+ def _render_consent_acknowledged(route: str, prior_question: str) -> str:
1148
+ """First minimal-affirm after an OFFER. Acknowledge the consent and
1149
+ naturalize the binary disambiguation that the OFFER asked for. The
1150
+ point is to *advance* the turn: the user said yes, the system should
1151
+ acknowledge that yes and ask a more direct, conversational pick-one
1152
+ question instead of repeating the OFFER template verbatim."""
1153
+ question = (prior_question or "").strip().rstrip("?")
1154
+ if question:
1155
+ return (
1156
+ "Glad you're up for it. To land in the right place β€” "
1157
+ f"{question}? You can answer in a sentence, or just name the "
1158
+ "part you want to start with."
1159
+ )
1160
+ return (
1161
+ "Glad you're up for it. Could you say in your own words which "
1162
+ "piece you'd like to start with? Even one sentence is enough."
1163
+ )
1164
+
1165
+
1166
+ def _render_consent_delivered(route: str) -> str:
1167
+ """Second minimal-affirm in a row after an OFFER. Stop asking the
1168
+ student to choose and deliver a concrete starter scoped to the route.
1169
+ Keeps the conversation moving and gives the student something usable
1170
+ to take with them."""
1171
+ if route == "academic_setback":
1172
+ return (
1173
+ "Okay, let's just start. Here's a short email you can adapt "
1174
+ "for your professor or TA β€” change the bracketed bits:\n\n"
1175
+ "Subject: Quick check-in during office hours\n\n"
1176
+ "Hi Professor [Last Name], I'm in your [Course]. I've been "
1177
+ "having a hard time keeping up the last few weeks and I'd like "
1178
+ "to use a few minutes of office hours to figure out the best "
1179
+ "way forward. Would [day/time] work, or is there another time "
1180
+ "you'd suggest?\n\n"
1181
+ "Thanks for your time, [Your Name]\n\n"
1182
+ "Send that, then come back and we can talk through anything "
1183
+ "else that's been weighing on you."
1184
+ )
1185
+ if route == "exam_stress":
1186
+ return (
1187
+ "Okay, small concrete start. Tonight: pick two topics from the "
1188
+ "exam that you actually want to be solid on, set a 25-minute "
1189
+ "timer for the first one, then take 5. That's it. The rest of "
1190
+ "the prep can wait until after a real reset.\n\n"
1191
+ "If the stress spikes during the reset, the UMD Counseling "
1192
+ "Center has same-day phone consultations at counseling.umd.edu."
1193
+ )
1194
+ if route == "anxiety_panic":
1195
+ return (
1196
+ "Okay, let's start with a short grounding reset. Try this:\n\n"
1197
+ "1. Name five things you can see right now.\n"
1198
+ "2. Name four things you can hear.\n"
1199
+ "3. Three slow breaths β€” in for four counts, out for six.\n\n"
1200
+ "Take it slow. When you're done, tell me how it landed."
1201
+ )
1202
+ if route == "low_mood":
1203
+ return (
1204
+ "Okay, small start. Pick one thing today that feels almost "
1205
+ "too easy β€” drink a glass of water, step outside for two "
1206
+ "minutes, message one person to say hi. Just pick the one. "
1207
+ "Then come back and tell me how it went.\n\n"
1208
+ "If anything tips into not feeling safe, UMD Counseling "
1209
+ "Center is at counseling.umd.edu and 988 is always there."
1210
+ )
1211
+ if route == "loneliness_isolation":
1212
+ return (
1213
+ "Okay, one small reach. Think of one person you used to talk "
1214
+ "to who wouldn't feel hard to message. The text doesn't have "
1215
+ "to be deep β€” \"hey, been thinking about you, how are you?\" "
1216
+ "is enough. You don't have to send it now; just pick the "
1217
+ "person.\n\n"
1218
+ "If you want, name them and we can plan what to say."
1219
+ )
1220
+ if route in ("advisor_conflict", "authority_misconduct"):
1221
+ return (
1222
+ "Okay, let's start by writing it down. A short factual "
1223
+ "timeline β€” date, who was there, what was said, in your own "
1224
+ "words. Even three or four lines is enough for now. That "
1225
+ "gives you something concrete to bring to whichever office "
1226
+ "fits, without committing to anything yet.\n\n"
1227
+ "Want to draft those few lines together?"
1228
+ )
1229
+ if route == "accessibility_ads":
1230
+ return (
1231
+ "Okay, one small step: open accessibility.umd.edu and start "
1232
+ "the ADS intake form β€” you don't have to finish it tonight, "
1233
+ "just open it and save your name. The form asks about the "
1234
+ "specific course and barrier, which is easier to fill out "
1235
+ "with the syllabus in front of you.\n\n"
1236
+ "When you're ready, we can talk through what to put in the "
1237
+ "barrier section."
1238
+ )
1239
+ if route == "basic_needs":
1240
+ return (
1241
+ "Okay, one concrete first step: today, contact UMD's Dean of "
1242
+ "Students Office (dos.umd.edu) and tell them plainly what "
1243
+ "you need help with β€” food, housing, or money. They route "
1244
+ "students to Campus Pantry and emergency funds and they're "
1245
+ "used to getting these calls.\n\n"
1246
+ "Want help drafting a short message to send them?"
1247
+ )
1248
+ if route == "counseling_navigation":
1249
+ return (
1250
+ "Okay, one concrete step: open counseling.umd.edu and book "
1251
+ "the brief 20-minute initial consultation. That's the "
1252
+ "intake β€” it's not therapy yet, just a quick conversation "
1253
+ "where they listen and tell you what fits (group, "
1254
+ "individual, off-campus referral). You can do it by phone.\n\n"
1255
+ "If a deadline or finals window is making this urgent, say "
1256
+ "that on the call β€” they have same-day options."
1257
+ )
1258
+ # Default β€” generic small concrete starter
1259
+ return (
1260
+ "Okay, let's just start small. In one sentence, what feels most "
1261
+ "pressing right now β€” not the whole picture, just whatever's "
1262
+ "loudest today? We'll work from there, one step at a time."
1263
+ )
1264
+
1265
+
1266
  def _render_minimal_followup(kind: str, intl_session: bool) -> str:
1267
  """Short clarifying response when the user's reply was just yes/no/maybe.
1268
 
src/pipeline/safety_policy.py CHANGED
@@ -141,6 +141,11 @@ EXPLICIT_CRISIS_PATTERNS = tuple(
141
  r"\bsuicide plan\b",
142
  r"\bwant to die\b",
143
  r"\bdon'?t want to be alive\b",
 
 
 
 
 
144
  r"\bhurt myself\b",
145
  r"\bhurt themselves\b",
146
  r"\bharming themselves\b",
 
141
  r"\bsuicide plan\b",
142
  r"\bwant to die\b",
143
  r"\bdon'?t want to be alive\b",
144
+ # "I don't want to be here anymore" / "do not want to be here anymore"
145
+ # β€” natural phrasing that the original "not be here anymore" regex
146
+ # doesn't catch because the negation lives one token earlier.
147
+ r"\b(don'?t|do not) want to be here anymore\b",
148
+ r"\b(don'?t|do not) wanna be here anymore\b",
149
  r"\bhurt myself\b",
150
  r"\bhurt themselves\b",
151
  r"\bharming themselves\b",
src/pipeline/v2_schema.py CHANGED
@@ -91,7 +91,14 @@ def classify_route(
91
  if _has_any(text, ("accommodation", "disability", "504", "extended time", "assistive tech", "paratransit")) or _has_word(text, "ads"):
92
  return RouteDecision(SupportRoute.ACCESSIBILITY_ADS, safety_tier, "accessibility_language", audience_mode)
93
 
94
- if _has_any(text, ("advisor", "ombuds", "funding threatened", "threatened my funding", "funding might disappear", "pi is", "my pi", "committee feedback", "retaliatory", "neutral process", "power dynamics")):
 
 
 
 
 
 
 
95
  return RouteDecision(SupportRoute.ADVISOR_CONFLICT, safety_tier, "advisor_or_ombuds_language", audience_mode)
96
 
97
  if _has_any(text, ("no food", "out of money", "hungry because", "food rent", "food or rent", "can't afford food", "cannot afford food", "nowhere to sleep")):
@@ -215,8 +222,16 @@ _AUTHORITY_MISCONDUCT_CONTENT = (
215
  # Confidentiality breach
216
  "told my parents", "told everyone", "broke confidentiality",
217
  "outed me", "betrayed my trust",
218
- # Faux authority validation
 
 
219
  "told me to give up", "told me to drop out",
 
 
 
 
 
 
220
  )
221
 
222
 
 
91
  if _has_any(text, ("accommodation", "disability", "504", "extended time", "assistive tech", "paratransit")) or _has_word(text, "ads"):
92
  return RouteDecision(SupportRoute.ACCESSIBILITY_ADS, safety_tier, "accessibility_language", audience_mode)
93
 
94
+ # ADVISOR_CONFLICT requires explicit conflict / power-imbalance signals.
95
+ # The bare word "advisor" is too broad β€” undergrads mention their academic
96
+ # advisor in any context β€” so this rule fires only on phrases that
97
+ # actually carry the advisor-conflict frame (Ombuds, funding threats,
98
+ # retaliation, named PI dynamics). When someone says "my advisor and I
99
+ # had a hard meeting", we let the planner fall through to the academic /
100
+ # general route rather than dropping a grad-Ombuds template on them.
101
+ if _has_any(text, ("ombuds", "funding threatened", "threatened my funding", "funding might disappear", "pi is", "my pi", "committee feedback", "retaliatory", "neutral process", "power dynamics")):
102
  return RouteDecision(SupportRoute.ADVISOR_CONFLICT, safety_tier, "advisor_or_ombuds_language", audience_mode)
103
 
104
  if _has_any(text, ("no food", "out of money", "hungry because", "food rent", "food or rent", "can't afford food", "cannot afford food", "nowhere to sleep")):
 
222
  # Confidentiality breach
223
  "told my parents", "told everyone", "broke confidentiality",
224
  "outed me", "betrayed my trust",
225
+ # Faux authority validation. Variants cover the common phrasings of
226
+ # authority figures suggesting the student give up / drop out, with or
227
+ # without "to" / "i should" / "just" hedges in between.
228
  "told me to give up", "told me to drop out",
229
+ "told me i should give up", "told me i should drop out",
230
+ "told me to just give up", "told me to just drop out",
231
+ "told me to just drop", "told me i should just drop",
232
+ "said i should drop out", "said i should give up",
233
+ "said to drop out", "said to give up",
234
+ "should give up on my degree", "should drop out of",
235
  )
236
 
237