MukulRay commited on
Commit
bc03d8b
·
1 Parent(s): 6067738

Robust natural-language affirm/negate/unsure detection + advance-after-PERMISSION

Browse files

Two 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.

Files changed (1) hide show
  1. src/pipeline/core.py +136 -16
src/pipeline/core.py CHANGED
@@ -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
- Single-word affirmations are ambiguous — without context, re-rendering the
1032
- same OFFER template is the worst possible response. We catch them here and
1033
- redirect to a clarifying-question response instead.
 
 
 
 
 
 
 
 
 
 
 
 
 
1034
  """
1035
- text = (message or "").strip().lower()
1036
- if not text or len(text) > 24:
1037
  return ""
1038
- # Strip trailing punctuation; collapse internal whitespace.
1039
- clean = " ".join(text.replace(".", " ").replace("!", " ").replace("?", " ").replace(",", " ").split())
1040
- if clean in _MINIMAL_AFFIRM:
1041
- return "affirm"
1042
- if clean in _MINIMAL_NEGATE:
1043
- return "negate"
1044
- if clean in _MINIMAL_UNSURE:
1045
- return "unsure"
1046
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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