rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
407f2a1
·
1 Parent(s): 8ef5c43

fix(safety): KI-104 — strip chain-of-thought / instruction-echo leakage from replies

Browse files

Live smoke test caught NIM reasoning models (Qwen3-Next 80B) and the
faithfulness judge leaking internal reasoning into user-visible
reply_text. Examples seen verbatim in production:
- "We need to respond to user question..."
- "We must ground every factual claim..."
- "We need to follow instructions. The user asks... According to
conversation rules..."

The existing persona.strip_think_tags only handled balanced
<think>...</think> tags. Bare instruction-echo and stray scratchpad
labels (**Reasoning:**, [INTERNAL]) slipped past every gate.

Adds backend/voice_format.strip_cot_preamble — a conservative
sentence-level stripper that:

1. Removes labelled reasoning lines (**Reasoning:**, **Thought:**,
**Plan:**, [INTERNAL]...[/INTERNAL]) — same-line only, never
consumes the next line (which is typically the answer).
2. Drops everything before a stray </think> tag (defense-in-depth for
unbalanced cases that strip_think_tags lets through).
3. Sentence-walks the first ~600 chars and drops leading sentences
matching a CoT starter pattern ("We need to...", "We must...",
"Let me think...", "According to conversation rules...",
"Step N:", "To answer this...", "Following the instructions...",
etc.) until the first substantive sentence.
4. Returns a safe emergency reply if the entire output was CoT.

Wired into persona.strip_think_tags so every reply path that already
runs the <think> strip — orchestrator.py (text replies), translator.py
(Indic), tts_preprocess (voice) — gets the preamble strip for free.
No orchestrator.py changes (owned by another lane).

Conservative design — mid-reply phrases like "We have three options"
or "Let me think about your needs" beyond the scan window survive.
24 unit tests in tests/test_voice_format.py cover both bug repros
and false-positive guards.

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

backend/persona.py CHANGED
@@ -138,6 +138,13 @@ def strip_think_tags(text: str) -> str:
138
  - truncated reasoning (no </think>): <think>... cut off → fallback message
139
  - reasoning followed by clean answer: <think>...</think> answer → answer
140
  - well-formed with extra text after close: take only text after </think>
 
 
 
 
 
 
 
141
  """
142
  if "<think>" in text.lower() and "</think>" not in text.lower():
143
  # Reasoning was truncated mid-thought — no final answer was produced.
@@ -148,4 +155,11 @@ def strip_think_tags(text: str) -> str:
148
  # If anything else got truncated, fall back gracefully.
149
  if not cleaned:
150
  return "I'm thinking through that. Could you rephrase or ask a follow-up?"
 
 
 
 
 
 
 
151
  return cleaned
 
138
  - truncated reasoning (no </think>): <think>... cut off → fallback message
139
  - reasoning followed by clean answer: <think>...</think> answer → answer
140
  - well-formed with extra text after close: take only text after </think>
141
+
142
+ KI-104 (2026-05-15) — after the <think> strip, also run
143
+ `strip_cot_preamble` to kill instruction-echo / scratchpad lines that
144
+ leaked outside of `<think>` tags. Live smoke caught Qwen3-Next 80B and
145
+ the judge model leaking "We need to respond to user question…",
146
+ "We must ground every factual claim…", and bare reasoning labels
147
+ (`**Reasoning:**`, `[INTERNAL]…`) into user-visible reply_text.
148
  """
149
  if "<think>" in text.lower() and "</think>" not in text.lower():
150
  # Reasoning was truncated mid-thought — no final answer was produced.
 
155
  # If anything else got truncated, fall back gracefully.
156
  if not cleaned:
157
  return "I'm thinking through that. Could you rephrase or ask a follow-up?"
158
+
159
+ # KI-104 — second-layer strip for CoT / instruction-echo leakage that
160
+ # didn't come wrapped in <think> tags. Imported locally to avoid an
161
+ # import cycle (voice_format has no persona deps; persona has no
162
+ # voice_format deps at module level).
163
+ from backend.voice_format import strip_cot_preamble
164
+ cleaned = strip_cot_preamble(cleaned)
165
  return cleaned
backend/voice_format.py CHANGED
@@ -6,6 +6,17 @@ Why this exists: an unprocessed LLM reply with markdown bold, inline
6
  hear "asterisk asterisk bold asterisk asterisk A-Y-U-S-H pp dot 1 dash 2".
7
  That's a UX-killing bug — not a Sarvam limitation, a *us* bug.
8
 
 
 
 
 
 
 
 
 
 
 
 
9
  The function turns text like:
10
 
11
  "**Direct answer:**
@@ -161,11 +172,197 @@ def _truncate_for_voice(text: str, max_words: int = 60) -> str:
161
  return truncated + " More details are on screen."
162
 
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  def tts_preprocess(text: str, language: str = "en", max_words: int = 60) -> str:
165
  """Public entry — turn an LLM reply into spoken-language text for TTS."""
166
  if not text:
167
  return ""
168
- cleaned = _strip_markdown(text)
 
 
 
 
 
169
  # KI-066 (2026-05-15) — currency/range shorthand expansion before
170
  # acronym handling so ₹5L becomes "5 lakhs" instead of getting caught
171
  # by the bare-L acronym path.
 
6
  hear "asterisk asterisk bold asterisk asterisk A-Y-U-S-H pp dot 1 dash 2".
7
  That's a UX-killing bug — not a Sarvam limitation, a *us* bug.
8
 
9
+ KI-104 (2026-05-15) — this module also exposes `strip_cot_preamble`, the
10
+ chain-of-thought / instruction-echo stripper that runs on TEXT replies
11
+ (not just TTS). Live smoke tests caught NIM reasoning models (e.g.,
12
+ Qwen3-Next 80B) and the judge model leaking internal reasoning into
13
+ `reply_text`. Examples: "We need to respond to user question…", "We must
14
+ ground every factual claim…", "<think>...</think>The answer is X."
15
+ `strip_cot_preamble` is called from `persona.strip_think_tags` so every
16
+ reply path that already goes through the <think>-tag strip also gets the
17
+ preamble strip — no orchestrator.py changes needed (that file is owned
18
+ by another lane / KI-101).
19
+
20
  The function turns text like:
21
 
22
  "**Direct answer:**
 
172
  return truncated + " More details are on screen."
173
 
174
 
175
+ # ============================================================================
176
+ # KI-104 (2026-05-15) — chain-of-thought / instruction-echo strip
177
+ # ============================================================================
178
+ # Live smoke test caught LLM brain replies leaking internal reasoning into
179
+ # user-visible reply_text. Three failure modes:
180
+ # 1. NIM reasoning models (Qwen3-Next 80B) emit a <think>...</think> block
181
+ # followed by the answer — the <think> tag was sometimes missing /
182
+ # malformed so the existing strip_think_tags in persona.py let it through.
183
+ # 2. The faithfulness JUDGE model occasionally returns its own reasoning
184
+ # instead of a clean rescue reply.
185
+ # 3. The brain model misunderstands the system prompt and echoes the
186
+ # instruction prose ("We need to respond to user question…").
187
+ #
188
+ # The strip below is CONSERVATIVE — it only kills CoT preamble lines that
189
+ # appear BEFORE the first natural-sounding sentence (within the first ~6
190
+ # lines / first 600 chars), so substantive mid-reply content like
191
+ # "We have three options: A, B, C" is preserved.
192
+
193
+ # ---- Sentence-level preamble patterns (KI-104) ----
194
+ #
195
+ # A CoT preamble can appear as:
196
+ # (a) a full line of its own: "We need to respond carefully.\n<answer>"
197
+ # (b) a leading sentence INSIDE the first line: "We need to respond to
198
+ # user question. Here's the actual answer."
199
+ #
200
+ # We handle both by sentence-splitting the top of the reply and dropping
201
+ # leading sentences that match a CoT-starter pattern, until we hit a
202
+ # substantive sentence.
203
+ #
204
+ # Sentence-starter patterns. These match from the START of a sentence
205
+ # (no MULTILINE anchor — we apply them sentence-by-sentence). Keep these
206
+ # specific enough to avoid false positives on legitimate prose.
207
+ # NOTE: don't append a trailing `\b` to the alternation — `\b` after `:` or
208
+ # after a digit followed by `:` is NOT a word boundary, which silently
209
+ # breaks `Step \d+\s*:`. Each alternative carries its own anchor where one
210
+ # is needed.
211
+ _COT_SENTENCE_STARTERS = re.compile(
212
+ r"^\s*(?:"
213
+ r"We need to(?:\s+respond|\s+answer|\s+follow|\s+ground|\s+check|\s+ensure|\s+make sure|\s+consider|\s+think|\s+address)\b"
214
+ r"|We must\b"
215
+ r"|We should (?:respond|answer|follow|ground|check|ensure|make sure|consider|think|address|cite)\b"
216
+ r"|According to (?:conversation rules|the instructions|the guidelines|the system prompt|the rules|policy guidelines)\b"
217
+ r"|The user (?:asks|is asking|wants|needs|wants to know)\b"
218
+ r"|Let me (?:think|consider|analyze|break this down|work through)\b"
219
+ r"|I (?:will|need to|should|must) (?:think|consider|analyze|respond|answer|check|ground|follow)\b"
220
+ r"|First,?\s+I(?:'ll| will| need to| should| must)\b"
221
+ r"|To answer this(?:\s+question)?\b"
222
+ r"|Step \d+\s*:"
223
+ r"|Following the instructions\b"
224
+ r"|As per the (?:guidelines|instructions|rules|system prompt)\b"
225
+ r"|Per the (?:guidelines|instructions|rules)\b"
226
+ r"|Okay,?\s+(?:let me|so the user|so I)\b"
227
+ r"|Alright,?\s+(?:let me|so the user|so I)\b"
228
+ r"|So,?\s+the user\b"
229
+ r"|Thinking about this\b"
230
+ r"|My (?:thought|reasoning|plan|approach) (?:process )?(?:is|here)\b"
231
+ r")",
232
+ flags=re.IGNORECASE,
233
+ )
234
+
235
+ # Sentence splitter — split on ". " / "! " / "? " / "\n" but keep the
236
+ # delimiter attached to the preceding sentence so we can rejoin losslessly.
237
+ _SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+|\n+")
238
+
239
+ # Labelled reasoning blocks. Match only the SAME-LINE label content; do
240
+ # not consume the next line (which is usually the real answer).
241
+ _LABELLED_REASONING_LINE = re.compile(
242
+ r"^[ \t]*(?:\*\*)?(?:Reasoning|Thought|Plan|Internal|Scratch(?:pad)?|Chain[- ]of[- ]thought|CoT)(?:\*\*)?\s*:\s*[^\n]*$",
243
+ flags=re.IGNORECASE | re.MULTILINE,
244
+ )
245
+ _BRACKET_INTERNAL = re.compile(
246
+ r"\[(?:INTERNAL|REASONING|THOUGHT|PLAN|CoT)\].*?\[/(?:INTERNAL|REASONING|THOUGHT|PLAN|CoT)\]",
247
+ flags=re.IGNORECASE | re.DOTALL,
248
+ )
249
+
250
+ # Stray, unbalanced <think> tags that persona.strip_think_tags doesn't
251
+ # already handle (it requires both open and close in the same blob).
252
+ # If we see an isolated </think> mid-reply, drop everything before it.
253
+ _STRAY_CLOSE_THINK = re.compile(r"^.*?</think>", flags=re.DOTALL | re.IGNORECASE)
254
+
255
+ # Maximum scan window for preamble. Beyond this, content is treated as
256
+ # substantive prose even if it matches a starter pattern — protects
257
+ # legitimate mid-reply phrasing like "Let me think about your three options".
258
+ _PREAMBLE_SCAN_LINES = 6
259
+ _PREAMBLE_SCAN_CHARS = 600
260
+
261
+ # Fallback when stripping removes the entire reply — better than empty.
262
+ _EMERGENCY_REPLY = (
263
+ "Let me think about this — could you ask me again in a moment?"
264
+ )
265
+
266
+
267
+ def _drop_leading_cot_sentences(text: str) -> str:
268
+ """Sentence-by-sentence strip of CoT preamble at the top of a reply.
269
+
270
+ Split the first ~600 chars into sentences. Drop leading sentences that
271
+ match a CoT starter pattern. Stop at the first substantive sentence.
272
+ Rejoin and prepend to whatever's left of the reply.
273
+ """
274
+ if not text:
275
+ return text
276
+
277
+ # Only walk the first window — anything beyond is presumed substantive.
278
+ head = text[:_PREAMBLE_SCAN_CHARS]
279
+ tail = text[_PREAMBLE_SCAN_CHARS:]
280
+
281
+ # Track delimiters so we rejoin without losing them.
282
+ sentences: list[str] = []
283
+ last_end = 0
284
+ for m in _SENTENCE_SPLIT.finditer(head):
285
+ sentence = head[last_end : m.start()]
286
+ delim = m.group(0)
287
+ sentences.append(sentence + delim)
288
+ last_end = m.end()
289
+ # Final trailing chunk (no terminating delimiter).
290
+ if last_end < len(head):
291
+ sentences.append(head[last_end:])
292
+
293
+ # Walk and drop CoT starters.
294
+ drop_index = 0
295
+ while drop_index < len(sentences) and drop_index < _PREAMBLE_SCAN_LINES:
296
+ s = sentences[drop_index]
297
+ stripped = s.strip()
298
+ if not stripped:
299
+ drop_index += 1
300
+ continue
301
+ if _COT_SENTENCE_STARTERS.match(stripped):
302
+ drop_index += 1
303
+ continue
304
+ break
305
+
306
+ if drop_index == 0:
307
+ return text
308
+
309
+ rebuilt_head = "".join(sentences[drop_index:])
310
+ return (rebuilt_head + tail).lstrip()
311
+
312
+
313
+ def strip_cot_preamble(text: str) -> str:
314
+ """Strip chain-of-thought / instruction-echo leakage from a model reply.
315
+
316
+ Conservative rules (in order):
317
+ 1. Drop labelled reasoning lines (`**Reasoning:** …`, `[INTERNAL]…[/INTERNAL]`).
318
+ These are SAME-LINE strips — we never consume the next line, which
319
+ is typically the real answer.
320
+ 2. If a stray `</think>` appears (no opening `<think>`), drop
321
+ everything up to and including it.
322
+ 3. Sentence-walk the first ~600 chars; drop leading sentences that
323
+ match a CoT starter pattern. Stop at the first substantive sentence
324
+ — substantive content is preserved verbatim.
325
+ 4. If the whole reply gets stripped, return `_EMERGENCY_REPLY`.
326
+
327
+ Args:
328
+ text: Raw model output (post-<think>-strip but pre-user-display).
329
+
330
+ Returns:
331
+ Cleaned reply with internal reasoning removed. Never empty.
332
+ """
333
+ if not text or not str(text).strip():
334
+ return _EMERGENCY_REPLY
335
+
336
+ cleaned = text
337
+
338
+ # Rule 1 — kill labelled reasoning blocks. Same-line only.
339
+ cleaned = _LABELLED_REASONING_LINE.sub("", cleaned)
340
+ cleaned = _BRACKET_INTERNAL.sub("", cleaned)
341
+
342
+ # Rule 2 — stray close-think tag: drop everything before it.
343
+ if "</think>" in cleaned.lower() and "<think>" not in cleaned.lower():
344
+ cleaned = _STRAY_CLOSE_THINK.sub("", cleaned, count=1).lstrip()
345
+
346
+ # Rule 3 — sentence-level CoT preamble strip.
347
+ cleaned = _drop_leading_cot_sentences(cleaned)
348
+
349
+ # Rule 4 — emergency fallback if the whole reply was CoT.
350
+ if not cleaned or not cleaned.strip():
351
+ return _EMERGENCY_REPLY
352
+
353
+ return cleaned
354
+
355
+
356
  def tts_preprocess(text: str, language: str = "en", max_words: int = 60) -> str:
357
  """Public entry — turn an LLM reply into spoken-language text for TTS."""
358
  if not text:
359
  return ""
360
+ # KI-104 — defense in depth: even if the reply went through
361
+ # persona.strip_think_tags upstream, run the preamble strip again here
362
+ # in case it's called on a path that bypasses persona (e.g., direct
363
+ # TTS of a cached reply).
364
+ cleaned = strip_cot_preamble(text)
365
+ cleaned = _strip_markdown(cleaned)
366
  # KI-066 (2026-05-15) — currency/range shorthand expansion before
367
  # acronym handling so ₹5L becomes "5 lakhs" instead of getting caught
368
  # by the bare-L acronym path.
tests/test_voice_format.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """KI-104 (2026-05-15) — chain-of-thought / instruction-echo strip tests.
2
+
3
+ Live smoke caught the brain LLM leaking internal reasoning into the
4
+ user-visible reply_text. Examples seen verbatim in production:
5
+ - "We need to respond to user question..."
6
+ - "We must ground every factual claim..."
7
+ - "We need to follow instructions. The user asks... According to
8
+ conversation rules..."
9
+
10
+ The faithfulness judge passed these through. Users would see them as
11
+ broken/embarrassing output.
12
+
13
+ These tests lock in:
14
+ 1. `strip_cot_preamble` removes CoT preamble before substantive content.
15
+ 2. `<think>...</think>` blocks are removed (defense-in-depth — the
16
+ primary strip is in persona.strip_think_tags).
17
+ 3. Labelled reasoning blocks (`**Reasoning:**`, `[INTERNAL]`) are removed.
18
+ 4. Substantive content starting with a CoT-like phrase ("Sure, here are
19
+ 3 plans...") is NOT a false positive.
20
+ 5. tts_preprocess invokes the strip end-to-end.
21
+ 6. persona.strip_think_tags invokes the strip end-to-end.
22
+
23
+ Run as a script (no pytest dep):
24
+ cd /Users/rohitsar/Developer/Insurance\\ Sales\\ Bot
25
+ .venv/bin/python -m unittest tests.test_voice_format -v
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import unittest
31
+
32
+ from backend.voice_format import (
33
+ strip_cot_preamble,
34
+ tts_preprocess,
35
+ _EMERGENCY_REPLY,
36
+ )
37
+ from backend.persona import strip_think_tags
38
+
39
+
40
+ class StripCotPreambleTests(unittest.TestCase):
41
+ """Direct unit tests on strip_cot_preamble."""
42
+
43
+ # ---------- core production-bug repros (verbatim from smoke test) ----------
44
+
45
+ def test_we_need_to_respond_preamble_dropped(self):
46
+ """The headline production bug — kill the 'We need to respond' line."""
47
+ inp = "We need to respond to user question. Here's the actual answer."
48
+ out = strip_cot_preamble(inp)
49
+ self.assertTrue(
50
+ out.startswith("Here's the actual answer"),
51
+ f"Expected actual answer at start, got: {out!r}",
52
+ )
53
+ self.assertNotIn("We need to respond", out)
54
+
55
+ def test_we_must_ground_preamble_dropped(self):
56
+ """Second observed leak — 'We must ground every factual claim'."""
57
+ inp = (
58
+ "We must ground every factual claim in retrieved chunks.\n"
59
+ "HDFC ERGO Optima Secure has a 36-month waiting period for "
60
+ "pre-existing diseases."
61
+ )
62
+ out = strip_cot_preamble(inp)
63
+ self.assertNotIn("We must ground", out)
64
+ self.assertIn("HDFC ERGO", out)
65
+
66
+ def test_we_need_follow_instructions_multi_line_preamble(self):
67
+ """Third observed leak — multiple CoT lines stacked."""
68
+ inp = (
69
+ "We need to follow instructions.\n"
70
+ "The user asks about pre-existing disease waiting periods.\n"
71
+ "According to conversation rules, we cite policy text.\n"
72
+ "The waiting period is 36 months under Optima Secure."
73
+ )
74
+ out = strip_cot_preamble(inp)
75
+ self.assertNotIn("We need to follow", out)
76
+ self.assertNotIn("The user asks", out)
77
+ self.assertNotIn("According to conversation rules", out)
78
+ self.assertIn("36 months", out)
79
+
80
+ # ---------- <think> tag handling (defense in depth) ----------
81
+
82
+ def test_stray_close_think_tag_dropped(self):
83
+ """If a stray </think> appears with no opening tag, drop everything before it."""
84
+ inp = "foo bar baz</think>The answer is X."
85
+ out = strip_cot_preamble(inp)
86
+ self.assertEqual(out, "The answer is X.")
87
+
88
+ # ---------- labelled reasoning blocks ----------
89
+
90
+ def test_reasoning_label_block_dropped(self):
91
+ inp = (
92
+ "**Reasoning:** I need to check the retrieved chunks.\n"
93
+ "The deductible is ₹5,000 per claim."
94
+ )
95
+ out = strip_cot_preamble(inp)
96
+ self.assertNotIn("Reasoning:", out)
97
+ self.assertIn("deductible", out)
98
+
99
+ def test_bracket_internal_block_dropped(self):
100
+ inp = (
101
+ "[INTERNAL]Let me check the chunks first[/INTERNAL]\n"
102
+ "Yes, dental is covered up to ₹10,000."
103
+ )
104
+ out = strip_cot_preamble(inp)
105
+ self.assertNotIn("INTERNAL", out)
106
+ self.assertNotIn("Let me check the chunks", out)
107
+ self.assertIn("dental is covered", out)
108
+
109
+ def test_plan_label_block_dropped(self):
110
+ inp = (
111
+ "**Plan:** Answer briefly, cite policy clause 3.2.\n"
112
+ "The maximum sum insured is ₹1 crore."
113
+ )
114
+ out = strip_cot_preamble(inp)
115
+ self.assertNotIn("Plan:", out)
116
+ self.assertIn("1 crore", out)
117
+
118
+ # ---------- starter-phrase preamble (Step 1, To answer this, etc.) ----------
119
+
120
+ def test_step_numbered_preamble_dropped(self):
121
+ inp = (
122
+ "Step 1: Identify the policy.\n"
123
+ "Step 2: Find the clause.\n"
124
+ "Maternity waiting period is 24 months."
125
+ )
126
+ out = strip_cot_preamble(inp)
127
+ self.assertNotIn("Step 1:", out)
128
+ self.assertNotIn("Step 2:", out)
129
+ self.assertIn("Maternity", out)
130
+
131
+ def test_to_answer_this_preamble_dropped(self):
132
+ inp = "To answer this question, I'll check the chunks.\nDental is covered."
133
+ out = strip_cot_preamble(inp)
134
+ self.assertTrue(out.startswith("Dental"))
135
+
136
+ def test_first_ill_preamble_dropped(self):
137
+ inp = "First, I'll review the retrieved policy text.\nThe waiting period is 36 months."
138
+ out = strip_cot_preamble(inp)
139
+ self.assertNotIn("First, I'll", out)
140
+ self.assertIn("36 months", out)
141
+
142
+ def test_let_me_think_preamble_dropped(self):
143
+ inp = "Let me think about this carefully.\nThe answer is yes — OPD is included."
144
+ out = strip_cot_preamble(inp)
145
+ self.assertNotIn("Let me think about", out)
146
+ self.assertIn("OPD is included", out)
147
+
148
+ def test_following_instructions_dropped(self):
149
+ inp = "Following the instructions, I will cite each claim.\nCoverage is comprehensive."
150
+ out = strip_cot_preamble(inp)
151
+ self.assertNotIn("Following the instructions", out)
152
+ self.assertIn("Coverage is comprehensive", out)
153
+
154
+ def test_as_per_guidelines_dropped(self):
155
+ inp = "As per the guidelines, citations are required.\nThe premium is ₹15,000."
156
+ out = strip_cot_preamble(inp)
157
+ self.assertNotIn("As per the guidelines", out)
158
+ self.assertIn("premium", out)
159
+
160
+ # ---------- false-positive guards ----------
161
+
162
+ def test_legit_reply_unchanged_sure_here_are_3_plans(self):
163
+ """Don't false-positive a substantive opener that starts with 'Sure'."""
164
+ inp = "Sure, here are 3 plans to consider:\n1. HDFC ERGO Optima Secure\n2. Star Comprehensive\n3. Niva Bupa ReAssure"
165
+ out = strip_cot_preamble(inp)
166
+ self.assertEqual(out, inp)
167
+
168
+ def test_legit_reply_unchanged_yes_dental_is_covered(self):
169
+ inp = "Yes, dental treatment is covered under Optima Secure subject to a sub-limit of ₹10,000 per year."
170
+ out = strip_cot_preamble(inp)
171
+ self.assertEqual(out, inp)
172
+
173
+ def test_legit_reply_unchanged_we_have_three_options_midreply(self):
174
+ """The phrase 'We have three options:' is substantive content, not CoT."""
175
+ inp = "Based on your needs profile, we have three options: A, B, and C."
176
+ out = strip_cot_preamble(inp)
177
+ self.assertEqual(out, inp)
178
+
179
+ def test_legit_reply_starting_with_the_user(self):
180
+ """A reply that legitimately begins 'The user manual says...' must survive."""
181
+ # Note: 'The user manual' does NOT match the starter regex (which
182
+ # requires 'The user asks/is asking/wants/needs').
183
+ inp = "The user manual for Optima Secure is available at hdfcergo.com."
184
+ out = strip_cot_preamble(inp)
185
+ self.assertEqual(out, inp)
186
+
187
+ def test_legit_reply_with_inline_let_me_think(self):
188
+ """'Let me think' beyond the scan window must not be stripped."""
189
+ inp = (
190
+ "Optima Secure offers a sum insured of ₹1 crore with "
191
+ "unlimited restore. It covers daycare, road ambulance, and "
192
+ "ayurveda. Let me think about which plan suits you best — "
193
+ "I'll need your age and city to recommend."
194
+ )
195
+ out = strip_cot_preamble(inp)
196
+ # The 'Let me think' is mid-reply, beyond line 1, so it survives.
197
+ self.assertIn("Let me think", out)
198
+ self.assertIn("Optima Secure", out)
199
+
200
+ # ---------- empty / edge cases ----------
201
+
202
+ def test_empty_input_returns_emergency_reply(self):
203
+ self.assertEqual(strip_cot_preamble(""), _EMERGENCY_REPLY)
204
+ self.assertEqual(strip_cot_preamble(" \n "), _EMERGENCY_REPLY)
205
+
206
+ def test_all_cot_returns_emergency_reply(self):
207
+ """If the WHOLE reply is CoT and nothing substantive remains."""
208
+ inp = (
209
+ "We need to respond to user question.\n"
210
+ "We must check the chunks.\n"
211
+ "Let me think.\n"
212
+ "Step 1: identify policy."
213
+ )
214
+ out = strip_cot_preamble(inp)
215
+ self.assertEqual(out, _EMERGENCY_REPLY)
216
+
217
+ def test_none_input_safe(self):
218
+ """Conservative: None / falsy inputs should not crash."""
219
+ self.assertEqual(strip_cot_preamble(None), _EMERGENCY_REPLY) # type: ignore[arg-type]
220
+
221
+
222
+ class TtsPreprocessIntegrationTests(unittest.TestCase):
223
+ """End-to-end: tts_preprocess invokes strip_cot_preamble."""
224
+
225
+ def test_tts_strips_cot_before_markdown(self):
226
+ inp = "We need to respond to user question.\n**Yes**, OPD is covered."
227
+ out = tts_preprocess(inp, language="en")
228
+ self.assertNotIn("We need to respond", out)
229
+ self.assertIn("Yes", out)
230
+ # Markdown bold was also stripped:
231
+ self.assertNotIn("**", out)
232
+
233
+
234
+ class PersonaStripThinkTagsIntegrationTests(unittest.TestCase):
235
+ """End-to-end: persona.strip_think_tags invokes strip_cot_preamble too."""
236
+
237
+ def test_think_block_plus_cot_preamble_both_stripped(self):
238
+ inp = (
239
+ "<think>I need to check the chunks first.</think>\n"
240
+ "We need to respond carefully.\n"
241
+ "The waiting period is 36 months."
242
+ )
243
+ out = strip_think_tags(inp)
244
+ self.assertNotIn("<think>", out)
245
+ self.assertNotIn("We need to respond", out)
246
+ self.assertIn("36 months", out)
247
+
248
+ def test_clean_reply_passes_through_unchanged(self):
249
+ inp = "Dental treatment is covered up to ₹10,000 per year under Optima Secure."
250
+ out = strip_think_tags(inp)
251
+ self.assertEqual(out, inp)
252
+
253
+
254
+ if __name__ == "__main__":
255
+ unittest.main()