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 +218 -0
- src/pipeline/safety_policy.py +5 -0
- src/pipeline/v2_schema.py +17 -2
|
@@ -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 |
|
|
@@ -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",
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|