Spaces:
Sleeping
fix(safety): KI-104 — strip chain-of-thought / instruction-echo leakage from replies
Browse filesLive 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 +14 -0
- backend/voice_format.py +198 -1
- tests/test_voice_format.py +255 -0
|
@@ -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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
@@ -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()
|