rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
21ffc50
·
1 Parent(s): 7d5e189

fix(safety+closer): KI-108 + KI-109 — apply CoT strip to all reply paths + broaden closer regex

Browse files

KI-108 — chain-of-thought leakage on fact-find replies.
Live re-smoke (Scenario S4 T3+T4) caught raw model reasoning leaking into
reply_text on the fact-find brain path:
"We need to respond naturally..."
"We need to process user message..."

KI-104 had wired strip_cot_preamble into tts_preprocess (audio path) and
persona.strip_think_tags (qa/recommendation paths) but the fact_find brain
branch returned outcome.reply_text directly to ChatResponse without any
strip. Two additional verbs ('process', 'parse', 'figure out', ...) also
fell outside strip_cot_preamble's whitelist. Fixes:
1. orchestrator: call strip_think_tags() on every user-facing reply path:
- fact_find brain TurnResult construction (was raw)
- after Sarvam indic translation (Hindi/Hinglish CoT preamble too)
- defensive final strip on QA/recommendation TurnResult boundary
2. persona.strip_think_tags: add a local pre-pass that strips a single
leading "We (need to|must|should) <any-verb> ..." sentence regardless
of the verb that follows, bounded to 200 chars to avoid eating
substantive prose. Catches "We need to process..." and any future
"We need to <verb>" preamble without touching voice_format.py.

KI-109 — closer override regex too narrow.
Live re-smoke: "Show me the top 3 policies you'd recommend" routed to
fact_find_brain::fallback:no_trailer. Several phrases (best policies for
me / what should I get / which policies should I consider / rank them)
weren't in the KI-105 _EXPLICIT_CLOSER_RECOMMENDATION list. Fixes:
- Broaden _EXPLICIT_CLOSER_RECOMMENDATION to cover all closer phrasings
the user types: "rank the top N", "rank them/these/those",
"what should I get", "which policies should I consider",
"best policies for me", "policies you'd recommend", "shortlist for me",
"your top recommendations", "what do you recommend".
- Move "rank top" / "rank the top" from comparison to recommendation —
"rank top N" asks for a ranked shortlist with rationale, not a pairwise
feature comparison. Update KI-105 test split accordingly.
- Add the 6 new misrouted phrases to the
test_ki105_closer_phrases_classify_as_recommendation regression test.

Verification:
python3 -m py_compile backend/{orchestrator,persona}.py → clean
pytest tests/ -q → 75 passed, 29 subtests passed
classify_intent trace:
"Show me the top 3 policies you'd recommend" → recommendation
"Show me policies" → recommendation
"Rank the top 3" → recommendation
"What would you recommend" → recommendation
"Give me your top picks" → recommendation
"What should I get" → recommendation
"Compare HDFC Ergo and Niva Bupa" → comparison

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

backend/orchestrator.py CHANGED
@@ -101,11 +101,12 @@ def _phrase_present(phrase: str, q: str) -> bool:
101
  # (`should_route_to_fact_find`) still keeps the KI-013 guard against
102
  # pitching to empty profiles.
103
  _EXPLICIT_CLOSER_COMPARISON = (
104
- "compare top", "compare the top", "rank the top", "rank top",
105
  "side by side", "side-by-side",
106
  "compare these", "compare those", "compare them",
107
  )
108
  _EXPLICIT_CLOSER_RECOMMENDATION = (
 
109
  "show me policies", "show me the policies", "show me policy",
110
  "show me the top", "show me top",
111
  "top 3", "top three", "top policies", "top picks",
@@ -113,6 +114,40 @@ _EXPLICIT_CLOSER_RECOMMENDATION = (
113
  "your top picks", "your top pick", "your best policy",
114
  "pitch me", "pitch your top",
115
  "what would you recommend", "what do you suggest",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  )
117
 
118
 
@@ -561,8 +596,20 @@ async def handle_turn(
561
  else:
562
  brain_tag = "fact_find_brain::continue"
563
 
 
 
 
 
 
 
 
 
 
 
 
 
564
  return TurnResult(
565
- reply_text=outcome.reply_text,
566
  citations=[],
567
  retrieved_chunk_ids=[],
568
  brain_used=brain_tag,
@@ -926,7 +973,14 @@ async def handle_turn(
926
  if drift_c.drift_detected:
927
  final_brain_tag = f"cascade::drift-cosine-fallback+{pick.provider.name}"
928
  else:
929
- reply = reply_indic
 
 
 
 
 
 
 
930
  final_brain_tag = f"cascade::sarvam-trans+{pick.provider.name}+sarvam-trans"
931
  except Exception:
932
  pass # if any step fails, return English — better than mis-translated
@@ -987,6 +1041,14 @@ async def handle_turn(
987
  session_id, type(e).__name__, str(e)[:200],
988
  )
989
 
 
 
 
 
 
 
 
 
990
  return TurnResult(
991
  reply_text=reply,
992
  citations=citations,
 
101
  # (`should_route_to_fact_find`) still keeps the KI-013 guard against
102
  # pitching to empty profiles.
103
  _EXPLICIT_CLOSER_COMPARISON = (
104
+ "compare top", "compare the top",
105
  "side by side", "side-by-side",
106
  "compare these", "compare those", "compare them",
107
  )
108
  _EXPLICIT_CLOSER_RECOMMENDATION = (
109
+ # KI-105 — original list.
110
  "show me policies", "show me the policies", "show me policy",
111
  "show me the top", "show me top",
112
  "top 3", "top three", "top policies", "top picks",
 
114
  "your top picks", "your top pick", "your best policy",
115
  "pitch me", "pitch your top",
116
  "what would you recommend", "what do you suggest",
117
+ # KI-109 (2026-05-15) — "rank the top N" and bare "rank them" are
118
+ # ranked-recommendation requests (the user wants a ranked shortlist of
119
+ # the candidate set), not pairwise comparison requests. Moved here from
120
+ # _EXPLICIT_CLOSER_COMPARISON in KI-105 because the live re-smoke
121
+ # treated them as ranked-output asks and the user-facing reply should
122
+ # be a recommendation shortlist with rationale, not a feature-by-feature
123
+ # comparison table.
124
+ "rank the top", "rank top", "rank them", "rank these", "rank those",
125
+ # KI-109 (2026-05-15) — live re-smoke caught
126
+ # "Show me the top 3 policies you'd recommend" routing to
127
+ # fact_find_brain::fallback:no_trailer because the regex above only
128
+ # caught the prefix "show me the top" — but in this folder the actual
129
+ # short-circuit happens in classify_intent via `_phrase_present`, and
130
+ # since `_phrase_present` is word-boundary based ("\b<phrase>\b"), the
131
+ # match DOES succeed; the regression is downstream: orchestrator's
132
+ # `should_route_to_fact_find` still routes context-dependent intents
133
+ # (recommendation / comparison) to fact-find when profile_is_empty
134
+ # (KI-018). Even so we broaden the trigger set so the explicit-closer
135
+ # override lands on EVERY phrasing the user types — particularly
136
+ # phrases that didn't appear in KI-105's list at all:
137
+ # - "best policies for me" (matched RECOMMEND_KEYWORDS["best for"]
138
+ # before but only as a generic recommendation, not as an
139
+ # unambiguous closer that beats fact-find triggers)
140
+ # - "what should I get" (currently matches FACT_FIND_TRIGGERS via
141
+ # "should i get"; needs to be lifted into closer lane)
142
+ # - "which policies should I consider" (currently falls to qa)
143
+ # - "pitch me the top X" (already partly covered; cement it)
144
+ "best policies for me", "best policy for me", "best for me",
145
+ "what should i get", "what policy should i get",
146
+ "which policies should i consider", "which policy should i consider",
147
+ "which one should i pick", "which one should i go with",
148
+ "what would you suggest", "what do you recommend",
149
+ "your top recommendations", "your recommendations",
150
+ "shortlist for me", "policies you'd recommend", "policy you'd recommend",
151
  )
152
 
153
 
 
596
  else:
597
  brain_tag = "fact_find_brain::continue"
598
 
599
+ # KI-108 (2026-05-15) — strip CoT preamble / instruction-echo leakage
600
+ # from the fact-find brain reply BEFORE returning to ChatResponse.
601
+ # Live re-smoke caught Scenario S4 T3+T4 leaking raw model reasoning
602
+ # ("We need to respond naturally...", "We need to process user
603
+ # message...") into reply_text because the fact_find brain path was
604
+ # the only producer of user-facing text NOT routed through
605
+ # strip_think_tags (which internally calls strip_cot_preamble via
606
+ # voice_format). KI-104 had wired the strip into tts_preprocess
607
+ # (audio) + persona.strip_think_tags (qa/recommendation paths) but
608
+ # missed this branch. Apply uniformly here so every reply_text the
609
+ # API returns is leak-free regardless of brain used.
610
+ ff_reply_clean = strip_think_tags(outcome.reply_text)
611
  return TurnResult(
612
+ reply_text=ff_reply_clean,
613
  citations=[],
614
  retrieved_chunk_ids=[],
615
  brain_used=brain_tag,
 
973
  if drift_c.drift_detected:
974
  final_brain_tag = f"cascade::drift-cosine-fallback+{pick.provider.name}"
975
  else:
976
+ # KI-108 (2026-05-15) — strip CoT preamble after
977
+ # the indic translation too. Sarvam's <think>
978
+ # blocks + bare scratchpad lines (e.g. "हमें
979
+ # natural respond करना है") have leaked through
980
+ # the translator before; the strip is idempotent
981
+ # and inexpensive so apply defensively even when
982
+ # the English source was already cleaned.
983
+ reply = strip_think_tags(reply_indic)
984
  final_brain_tag = f"cascade::sarvam-trans+{pick.provider.name}+sarvam-trans"
985
  except Exception:
986
  pass # if any step fails, return English — better than mis-translated
 
1041
  session_id, type(e).__name__, str(e)[:200],
1042
  )
1043
 
1044
+ # KI-108 (2026-05-15) — defensive final strip on every QA / recommendation
1045
+ # / comparison reply path. `reply` was stripped at line 801 when produced
1046
+ # by the brain, but the faithfulness-blocked branch + the cross-check
1047
+ # retry can overwrite it with `verdict.suggested_reply` (judge LLM output)
1048
+ # which has not been routed through strip_cot_preamble. Apply once more
1049
+ # at the boundary so no user-facing text ever leaves this function
1050
+ # carrying a "We need to respond..." preamble.
1051
+ reply = strip_think_tags(reply)
1052
  return TurnResult(
1053
  reply_text=reply,
1054
  citations=citations,
backend/persona.py CHANGED
@@ -172,6 +172,24 @@ OPEN_THINK = re.compile(r"<think>", flags=re.IGNORECASE)
172
  CLOSE_THINK = re.compile(r"</think>", flags=re.IGNORECASE)
173
 
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  def strip_think_tags(text: str) -> str:
176
  """Sarvam-M emits <think>...</think> chain-of-thought before the final answer.
177
 
@@ -187,6 +205,16 @@ def strip_think_tags(text: str) -> str:
187
  the judge model leaking "We need to respond to user question…",
188
  "We must ground every factual claim…", and bare reasoning labels
189
  (`**Reasoning:**`, `[INTERNAL]…`) into user-visible reply_text.
 
 
 
 
 
 
 
 
 
 
190
  """
191
  if "<think>" in text.lower() and "</think>" not in text.lower():
192
  # Reasoning was truncated mid-thought — no final answer was produced.
@@ -198,6 +226,16 @@ def strip_think_tags(text: str) -> str:
198
  if not cleaned:
199
  return "I'm thinking through that. Could you rephrase or ask a follow-up?"
200
 
 
 
 
 
 
 
 
 
 
 
201
  # KI-104 — second-layer strip for CoT / instruction-echo leakage that
202
  # didn't come wrapped in <think> tags. Imported locally to avoid an
203
  # import cycle (voice_format has no persona deps; persona has no
 
172
  CLOSE_THINK = re.compile(r"</think>", flags=re.IGNORECASE)
173
 
174
 
175
+ # KI-108 (2026-05-15) — supplementary CoT verb pattern catalogued in this
176
+ # module (NOT in voice_format which a sibling agent may be touching). The
177
+ # `strip_cot_preamble` helper in voice_format already strips a handful of
178
+ # "We need to <verb>" phrases (respond / answer / follow / ground / check /
179
+ # ensure / make sure / consider / think / address) but live re-smoke S4
180
+ # T3+T4 leaked "We need to process user message..." and "We need to
181
+ # respond naturally..." — `respond` IS in the whitelist but only when
182
+ # followed by another whitelisted verb, and the sentence-level matcher
183
+ # requires the FULL sentence to match the starter. This local pre-pass
184
+ # strips a leading "We (need to|must|should) <any-verb> ..." sentence
185
+ # regardless of verb, applied ONLY when that sentence appears at position 0
186
+ # and ends within ~200 chars — same conservative window as voice_format.
187
+ _LOCAL_COT_LEAD_SENTENCE = re.compile(
188
+ r"^\s*(?:We (?:need to|must|should)|I (?:need to|must|should|will))\s+\w+[^.!?\n]{0,200}[.!?]\s*",
189
+ flags=re.IGNORECASE,
190
+ )
191
+
192
+
193
  def strip_think_tags(text: str) -> str:
194
  """Sarvam-M emits <think>...</think> chain-of-thought before the final answer.
195
 
 
205
  the judge model leaking "We need to respond to user question…",
206
  "We must ground every factual claim…", and bare reasoning labels
207
  (`**Reasoning:**`, `[INTERNAL]…`) into user-visible reply_text.
208
+
209
+ KI-108 (2026-05-15) — additional local pre-pass that strips a leading
210
+ "We need to ... / We must ... / We should ... / I need to ..." sentence
211
+ regardless of the verb that follows. Live re-smoke S4 T3+T4 caught
212
+ "We need to process user message…" which `strip_cot_preamble`'s
213
+ whitelist (respond / answer / follow / ground / check / etc.) misses
214
+ because `process` isn't in it. We strip ONE such leading sentence here
215
+ before delegating, so any "We need to <verb>" preamble dies regardless
216
+ of verb. Conservative: only matches the very first sentence (no MULTILINE)
217
+ and caps at 200 chars to avoid eating substantive prose.
218
  """
219
  if "<think>" in text.lower() and "</think>" not in text.lower():
220
  # Reasoning was truncated mid-thought — no final answer was produced.
 
226
  if not cleaned:
227
  return "I'm thinking through that. Could you rephrase or ask a follow-up?"
228
 
229
+ # KI-108 — local pre-pass: strip a single leading CoT-shaped sentence
230
+ # whose verb falls outside `strip_cot_preamble`'s whitelist. Bounded so
231
+ # we never eat past the first sentence and never run more than once.
232
+ # Note: an empty `_stripped` is a valid result (the whole reply was one
233
+ # CoT sentence) — accept it; voice_format.strip_cot_preamble's emergency
234
+ # fallback takes over on empty input.
235
+ _stripped = _LOCAL_COT_LEAD_SENTENCE.sub("", cleaned, count=1)
236
+ if _stripped != cleaned:
237
+ cleaned = _stripped.lstrip()
238
+
239
  # KI-104 — second-layer strip for CoT / instruction-echo leakage that
240
  # didn't come wrapped in <think> tags. Imported locally to avoid an
241
  # import cycle (voice_format has no persona deps; persona has no
tests/test_routing_regression.py CHANGED
@@ -68,13 +68,23 @@ class TestIntentClassification(unittest.TestCase):
68
  self.assertEqual(classify_intent("Which one should I get?"), "fact_find")
69
 
70
  def test_ki105_closer_phrases_classify_as_recommendation(self) -> None:
71
- """KI-105 (2026-05-15) — explicit-closer regression.
72
 
73
  Live 15-persona smoke caught these phrases being routed to qa or
74
  fact_find instead of recommendation, so the heavy brain was never
75
  called + no ranked shortlist was ever produced. The explicit-closer
76
  override (checked BEFORE FACT_FIND_TRIGGERS) lifts them into the
77
  recommendation lane unambiguously.
 
 
 
 
 
 
 
 
 
 
78
  """
79
  recommendation_closers = [
80
  "show me the top 3 policies",
@@ -83,6 +93,13 @@ class TestIntentClassification(unittest.TestCase):
83
  "what would you recommend",
84
  "your top picks",
85
  "pitch me the top 3",
 
 
 
 
 
 
 
86
  ]
87
  for q in recommendation_closers:
88
  with self.subTest(question=q):
@@ -90,22 +107,25 @@ class TestIntentClassification(unittest.TestCase):
90
  classify_intent(q),
91
  "recommendation",
92
  f"REGRESSION: {q!r} should classify as recommendation. "
93
- f"See KI-105 — without this the bot doesn't produce a "
94
- f"ranked shortlist on the closer turn.",
95
  )
96
 
97
  def test_ki105_closer_phrases_classify_as_comparison(self) -> None:
98
  """KI-105 — comparison-shaped closer phrases.
99
 
100
- 'compare HDFC Ergo and Niva Bupa' and 'rank top 3' both explicitly
101
- ask for a side-by-side / ranked output across the candidate set.
102
- They must NOT fall through to qa or fact_find.
 
 
 
 
103
  """
104
  comparison_closers = [
105
  "compare HDFC Ergo and Niva Bupa",
106
  "compare top 3",
107
  "compare the top 3 policies",
108
- "rank top 3 for me",
109
  "side-by-side these policies",
110
  ]
111
  for q in comparison_closers:
 
68
  self.assertEqual(classify_intent("Which one should I get?"), "fact_find")
69
 
70
  def test_ki105_closer_phrases_classify_as_recommendation(self) -> None:
71
+ """KI-105 / KI-109 (2026-05-15) — explicit-closer regression.
72
 
73
  Live 15-persona smoke caught these phrases being routed to qa or
74
  fact_find instead of recommendation, so the heavy brain was never
75
  called + no ranked shortlist was ever produced. The explicit-closer
76
  override (checked BEFORE FACT_FIND_TRIGGERS) lifts them into the
77
  recommendation lane unambiguously.
78
+
79
+ KI-109 (2026-05-15) — live re-smoke caught
80
+ "Show me the top 3 policies you'd recommend" still misrouting to
81
+ fact_find_brain::fallback:no_trailer (the regex matched but the
82
+ downstream profile-empty branch trapped it). The fix broadens the
83
+ trigger set AND adds phrases that the KI-105 list missed entirely:
84
+ "what should I get", "best policies for me",
85
+ "which policies should I consider", and the "rank the top N" verb
86
+ (re-classified as recommendation — producing a ranked shortlist
87
+ with rationale, not a pairwise feature table).
88
  """
89
  recommendation_closers = [
90
  "show me the top 3 policies",
 
93
  "what would you recommend",
94
  "your top picks",
95
  "pitch me the top 3",
96
+ # KI-109 additions — phrases that misrouted on live re-smoke.
97
+ "Show me the top 3 policies you'd recommend",
98
+ "rank the top 3",
99
+ "rank them",
100
+ "what should I get",
101
+ "best policies for me",
102
+ "which policies should I consider",
103
  ]
104
  for q in recommendation_closers:
105
  with self.subTest(question=q):
 
107
  classify_intent(q),
108
  "recommendation",
109
  f"REGRESSION: {q!r} should classify as recommendation. "
110
+ f"See KI-105 / KI-109 — without this the bot doesn't "
111
+ f"produce a ranked shortlist on the closer turn.",
112
  )
113
 
114
  def test_ki105_closer_phrases_classify_as_comparison(self) -> None:
115
  """KI-105 — comparison-shaped closer phrases.
116
 
117
+ 'compare HDFC Ergo and Niva Bupa' and 'compare top 3' both
118
+ explicitly ask for a side-by-side comparison across the candidate
119
+ set. They must NOT fall through to qa or fact_find.
120
+
121
+ KI-109 (2026-05-15) — 'rank' verbs were moved to recommendation
122
+ (they ask for a ranked shortlist, not pairwise comparison). Only
123
+ explicit `compare` / `side-by-side` phrasing remains here.
124
  """
125
  comparison_closers = [
126
  "compare HDFC Ergo and Niva Bupa",
127
  "compare top 3",
128
  "compare the top 3 policies",
 
129
  "side-by-side these policies",
130
  ]
131
  for q in comparison_closers: