Spaces:
Sleeping
fix(safety+closer): KI-108 + KI-109 — apply CoT strip to all reply paths + broaden closer regex
Browse filesKI-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 +65 -3
- backend/persona.py +38 -0
- tests/test_routing_regression.py +27 -7
|
@@ -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",
|
| 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=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
@@ -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
|
|
@@ -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
|
| 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 '
|
| 101 |
-
ask for a side-by-side
|
| 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:
|