Robust natural-language affirm/negate/unsure detection + advance-after-PERMISSION
Browse filesTwo compounding fixes for natural language robustness:
1. _minimal_response_kind no longer requires an exact substring match
against a 13-token affirm set. It now matches a leading-affirm
regex (yes/yeah/ok/sure/alright/sounds good/that works/let's do
it/that would help/i'd like that, etc.) followed by short
confirmatory expansion. A pivot mid-affirm ('yeah but i'm
actually struggling with...', 'sure, also i'm an F-1 student')
correctly returns to substantive content and falls back to the
planner. Same shape applies to negate ('no thanks', 'i don't
think so', 'not right now') and unsure ('i don't know', 'hard
to say').
2. session_last_stage tracking + a new branch: when a minimal-affirm
follows a PERMISSION turn (no open_offer captured yet), advance
to OFFER for the current turn instead of falling to the generic
minimal-followup clarifier. The clarifier was effectively saying
'which part of what we just talked about feels most useful' to a
student who literally just said 'yeah do that' — feels
regressive. After OFFER, the existing consent flow
(initial -> acknowledged -> delivered) handles further affirms.
Verified 36 phrasing cases (17 affirm variants, 6 negate, 5 unsure,
8 pivots) plus the full 5-turn Conv 1 flow with both single-word and
multi-word affirms producing identical stage progression.
- src/pipeline/core.py +136 -16
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
| 8 |
from dataclasses import asdict, dataclass
|
| 9 |
import os
|
| 10 |
from pathlib import Path
|
|
|
|
| 11 |
from typing import Literal
|
| 12 |
import sqlite3
|
| 13 |
import time
|
|
@@ -173,6 +174,12 @@ class EmpathRAGCore:
|
|
| 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"}.
|
|
@@ -186,6 +193,7 @@ class EmpathRAGCore:
|
|
| 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()
|
|
@@ -194,6 +202,7 @@ class EmpathRAGCore:
|
|
| 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,6 +488,22 @@ class EmpathRAGCore:
|
|
| 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 |
):
|
|
@@ -560,6 +585,12 @@ class EmpathRAGCore:
|
|
| 560 |
# moved past.
|
| 561 |
self.session_open_offer.pop(session_id, None)
|
| 562 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
return _TurnPlan(
|
| 564 |
message=message,
|
| 565 |
session_id=session_id,
|
|
@@ -1024,26 +1055,115 @@ _MINIMAL_AFFIRM = frozenset({
|
|
| 1024 |
_MINIMAL_NEGATE = frozenset({"no", "nope", "nah", "not really", "nuh"})
|
| 1025 |
_MINIMAL_UNSURE = frozenset({"maybe", "idk", "dunno", "unsure", "not sure", "perhaps"})
|
| 1026 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1027 |
|
| 1028 |
-
def _minimal_response_kind(message: str) -> str:
|
| 1029 |
-
"""Return 'affirm' / 'negate' / 'unsure' / '' for very short reply-only turns.
|
| 1030 |
|
| 1031 |
-
|
| 1032 |
-
|
| 1033 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1034 |
"""
|
| 1035 |
-
text = (message or "").strip()
|
| 1036 |
-
if not text
|
| 1037 |
return ""
|
| 1038 |
-
#
|
| 1039 |
-
|
| 1040 |
-
if
|
| 1041 |
-
return "
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
|
| 1045 |
-
|
| 1046 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1047 |
|
| 1048 |
|
| 1049 |
# Conversation openers / closers / meta — these are NOT student-support
|
|
|
|
| 8 |
from dataclasses import asdict, dataclass
|
| 9 |
import os
|
| 10 |
from pathlib import Path
|
| 11 |
+
import re
|
| 12 |
from typing import Literal
|
| 13 |
import sqlite3
|
| 14 |
import time
|
|
|
|
| 174 |
# "stage": "initial" | "acknowledged" | "delivered",
|
| 175 |
# }
|
| 176 |
self.session_open_offer: dict[str, dict] = {}
|
| 177 |
+
# The stage we rendered on the previous turn for this session.
|
| 178 |
+
# Used to interpret a minimal-affirm response correctly: an affirm
|
| 179 |
+
# after PERMISSION should *advance* to OFFER (the user consented
|
| 180 |
+
# to widen things out), while an affirm after OFFER should go
|
| 181 |
+
# into the consent flow (initial -> acknowledged -> delivered).
|
| 182 |
+
self.session_last_stage: dict[str, str] = {}
|
| 183 |
# Rolling per-session message log so the rephraser has continuity.
|
| 184 |
# Only the last few user-assistant pairs are passed to the LLM; full
|
| 185 |
# history is kept here for diagnostics. Each entry: {"role", "content"}.
|
|
|
|
| 193 |
self.session_turns_since_intl.pop(session_id, None)
|
| 194 |
self.session_last_specific_route.pop(session_id, None)
|
| 195 |
self.session_open_offer.pop(session_id, None)
|
| 196 |
+
self.session_last_stage.pop(session_id, None)
|
| 197 |
self.session_message_history.pop(session_id, None)
|
| 198 |
else:
|
| 199 |
self.tier_history.clear()
|
|
|
|
| 202 |
self.session_turns_since_intl.clear()
|
| 203 |
self.session_last_specific_route.clear()
|
| 204 |
self.session_open_offer.clear()
|
| 205 |
+
self.session_last_stage.clear()
|
| 206 |
self.session_message_history.clear()
|
| 207 |
|
| 208 |
# Input length cap: defends against (1) accidental wall-of-text pastes
|
|
|
|
| 488 |
template_response = _render_meta()
|
| 489 |
stage = CLARIFY
|
| 490 |
recommended_action = response_plan.recommended_action
|
| 491 |
+
elif (
|
| 492 |
+
minimal_kind == "affirm"
|
| 493 |
+
and self.session_last_stage.get(session_id) == "permission"
|
| 494 |
+
and not _open_offer_recent(
|
| 495 |
+
self.session_open_offer.get(session_id), turn_index
|
| 496 |
+
)
|
| 497 |
+
):
|
| 498 |
+
# The student said yes to widening out after a PERMISSION
|
| 499 |
+
# turn. Advance to OFFER — render the full plan with
|
| 500 |
+
# named resources and a follow-up question. Without this
|
| 501 |
+
# branch the system would fall to the generic clarifier
|
| 502 |
+
# ("Which part of what we just talked about...") which
|
| 503 |
+
# ignores the explicit consent and feels regressive.
|
| 504 |
+
template_response = response_plan.render(OFFER)
|
| 505 |
+
recommended_action = response_plan.recommended_action
|
| 506 |
+
stage = OFFER
|
| 507 |
elif minimal_kind == "affirm" and _open_offer_recent(
|
| 508 |
self.session_open_offer.get(session_id), turn_index
|
| 509 |
):
|
|
|
|
| 585 |
# moved past.
|
| 586 |
self.session_open_offer.pop(session_id, None)
|
| 587 |
|
| 588 |
+
# Record the stage rendered this turn so the next turn can
|
| 589 |
+
# interpret a minimal-affirm correctly (advance after
|
| 590 |
+
# PERMISSION, consent-flow after OFFER).
|
| 591 |
+
if not should_intercept:
|
| 592 |
+
self.session_last_stage[session_id] = stage
|
| 593 |
+
|
| 594 |
return _TurnPlan(
|
| 595 |
message=message,
|
| 596 |
session_id=session_id,
|
|
|
|
| 1055 |
_MINIMAL_NEGATE = frozenset({"no", "nope", "nah", "not really", "nuh"})
|
| 1056 |
_MINIMAL_UNSURE = frozenset({"maybe", "idk", "dunno", "unsure", "not sure", "perhaps"})
|
| 1057 |
|
| 1058 |
+
# Leading-affirm patterns: a turn that begins with one of these and is
|
| 1059 |
+
# followed by light confirmatory content ("yeah that would help", "sure
|
| 1060 |
+
# let's do it", "yes please") should be treated as advancing consent,
|
| 1061 |
+
# not as substantive new content. Patterns are ordered by specificity so
|
| 1062 |
+
# longer multi-word affirms are tried before single-token ones.
|
| 1063 |
+
_AFFIRM_LEADING_RE = re.compile(
|
| 1064 |
+
r"^\s*("
|
| 1065 |
+
r"sounds good|that works|that would help|that helps|"
|
| 1066 |
+
r"that would be helpful|that'?d be helpful|that'?d help|"
|
| 1067 |
+
r"i'?d like that|i would like that|let'?s do it|let'?s start|"
|
| 1068 |
+
r"let'?s try|please do|"
|
| 1069 |
+
r"yes please|yeah please|ok please|sure please|"
|
| 1070 |
+
r"yes|yeah|yep|yup|sure|ok|okay|kk|"
|
| 1071 |
+
r"alright|fine|definitely|absolutely|right"
|
| 1072 |
+
r")\b",
|
| 1073 |
+
re.IGNORECASE,
|
| 1074 |
+
)
|
| 1075 |
+
|
| 1076 |
+
# Leading-negate patterns. Same shape as affirm: a leading negate token
|
| 1077 |
+
# followed by light content ("no thanks", "nah i'm good") still reads as
|
| 1078 |
+
# declining, not as new substantive content.
|
| 1079 |
+
_NEGATE_LEADING_RE = re.compile(
|
| 1080 |
+
r"^\s*("
|
| 1081 |
+
r"not really|not right now|no thanks|no thank you|"
|
| 1082 |
+
r"i'?d rather not|i don'?t think so|i don'?t want to|"
|
| 1083 |
+
r"no|nope|nah|nuh"
|
| 1084 |
+
r")\b",
|
| 1085 |
+
re.IGNORECASE,
|
| 1086 |
+
)
|
| 1087 |
+
|
| 1088 |
+
# Leading-unsure patterns. "i don't know" / "not sure yet" lead into a
|
| 1089 |
+
# clarify branch — the user is consenting to listen but not picking yet.
|
| 1090 |
+
_UNSURE_LEADING_RE = re.compile(
|
| 1091 |
+
r"^\s*("
|
| 1092 |
+
r"i don'?t know|i'?m not sure|not sure|not quite sure|"
|
| 1093 |
+
r"hard to say|hard to pick|"
|
| 1094 |
+
r"maybe|idk|dunno|unsure|perhaps"
|
| 1095 |
+
r")\b",
|
| 1096 |
+
re.IGNORECASE,
|
| 1097 |
+
)
|
| 1098 |
+
|
| 1099 |
+
# After-the-affirm signals that flip the turn back to substantive. If
|
| 1100 |
+
# any of these appear AFTER the lead affirm token, the user is not just
|
| 1101 |
+
# saying yes — they're either pivoting away from the offer or
|
| 1102 |
+
# introducing new content that the planner needs to process. Examples:
|
| 1103 |
+
# "yeah but i'm scared about my visa too"
|
| 1104 |
+
# "ok actually, my advisor also said something weird"
|
| 1105 |
+
# "sure, also i'm an F-1 student"
|
| 1106 |
+
_AFFIRM_THEN_SUBSTANTIVE_RE = re.compile(
|
| 1107 |
+
r"\b("
|
| 1108 |
+
r"but|actually|though|however|wait|except|although|"
|
| 1109 |
+
r"on second thought|on the other hand|"
|
| 1110 |
+
r"also|and also|plus|another thing|one more thing|"
|
| 1111 |
+
r"i'?m an? |i have |i'?m dealing|i'?m struggling|"
|
| 1112 |
+
r"i'?ve been|i'?m feeling|my \w+|the thing is"
|
| 1113 |
+
r")\b",
|
| 1114 |
+
re.IGNORECASE,
|
| 1115 |
+
)
|
| 1116 |
|
|
|
|
|
|
|
| 1117 |
|
| 1118 |
+
def _minimal_response_kind(message: str) -> str:
|
| 1119 |
+
"""Classify a turn as 'affirm' / 'negate' / 'unsure' / '' based on
|
| 1120 |
+
intent, not just exact-string match.
|
| 1121 |
+
|
| 1122 |
+
A turn is a minimal-affirm when it LEADS with an affirm token and
|
| 1123 |
+
everything after is light confirmatory expansion ("yeah", "yes
|
| 1124 |
+
please", "sure let's do it", "that would help"). It is NOT a
|
| 1125 |
+
minimal-affirm when the user introduces new content or pivots
|
| 1126 |
+
("yeah but i'm scared", "sure, also i'm an F-1 student"). Same
|
| 1127 |
+
shape for negate and unsure.
|
| 1128 |
+
|
| 1129 |
+
The old exact-set match treated "yeah that would help" as
|
| 1130 |
+
substantive content because the string wasn't literally in the set.
|
| 1131 |
+
That was brittle. This function asks the right question: did the
|
| 1132 |
+
student lead with an affirmation, and is the rest of the message
|
| 1133 |
+
just expansion, not new content?
|
| 1134 |
"""
|
| 1135 |
+
text = (message or "").strip()
|
| 1136 |
+
if not text:
|
| 1137 |
return ""
|
| 1138 |
+
# Pre-cap: very long messages are almost always substantive even if
|
| 1139 |
+
# they happen to start with "yeah" — keep the planner in charge.
|
| 1140 |
+
if len(text) > 80:
|
| 1141 |
+
return ""
|
| 1142 |
+
# Question-ended messages are not minimal — they want an answer.
|
| 1143 |
+
if text.rstrip().endswith("?"):
|
| 1144 |
+
return ""
|
| 1145 |
+
|
| 1146 |
+
affirm_match = _AFFIRM_LEADING_RE.match(text)
|
| 1147 |
+
negate_match = _NEGATE_LEADING_RE.match(text)
|
| 1148 |
+
unsure_match = _UNSURE_LEADING_RE.match(text)
|
| 1149 |
+
|
| 1150 |
+
# Prefer the longest leading match to disambiguate cases like
|
| 1151 |
+
# "i don't know" (unsure) vs a bare "i" / "i'm" prefix.
|
| 1152 |
+
candidates = [
|
| 1153 |
+
("affirm", affirm_match.end() if affirm_match else 0),
|
| 1154 |
+
("negate", negate_match.end() if negate_match else 0),
|
| 1155 |
+
("unsure", unsure_match.end() if unsure_match else 0),
|
| 1156 |
+
]
|
| 1157 |
+
kind, end = max(candidates, key=lambda kv: kv[1])
|
| 1158 |
+
if end == 0:
|
| 1159 |
+
return ""
|
| 1160 |
+
|
| 1161 |
+
remainder = text[end:].strip(" \t,.!?-")
|
| 1162 |
+
if remainder and _AFFIRM_THEN_SUBSTANTIVE_RE.search(remainder):
|
| 1163 |
+
# User pivoted away from the affirm — let the planner handle the
|
| 1164 |
+
# substantive content instead of binding to the prior offer.
|
| 1165 |
+
return ""
|
| 1166 |
+
return kind
|
| 1167 |
|
| 1168 |
|
| 1169 |
# Conversation openers / closers / meta — these are NOT student-support
|