rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
8828a2e
Β·
1 Parent(s): 6e0462a

fix(batch): voice interim, TTS truncation, RULE2.5 re-ask, faithfulness regression, markdown, compare-reviews, policy-name

Browse files

Eight user-reported defects fixed together (all root-caused, all verified):

#17 page.tsx PTT live transcript: separate inline SpeechRecognition had
continuous=false + no-op onend (died after first pause, never
restarted) + replace-not-accumulate. Added cross-session final
accumulator, want-gated onend restart + 3s heartbeat, trailing flush.
#19 sarvam_tts.py: bulbul:v2 silently voices only the first sentence of
an over-real-limit single request; hard-coded 1500 ceiling meant the
chunker never engaged for 370-720-char replies. Ceiling 1500->500,
dropped undocumented enable_preprocessing, added a WAV-duration
truncation guard (fail-loud). RED->GREEN proven.
#18 single_brain.py: _profile_to_snapshot never exported smoker/
copay_pct/family_medical_history/parents_age_max, so the model
couldn't know they were captured -> RULE 2.5 re-asked them. Snapshot
widened + RULE 2.5 'omit captured items' directive.
#23 single_brain.py: get_policy_facts policies now registered into
retrieved_chunks_all so _verify_prose_grounding stops false-flagging
the UIN warning (regression from the get_policy_facts tool). Control
test proves the guard is not weakened.
#22 page.tsx: stray sentence-final numbers (15. 43. 52.) no longer
parsed as <ol>; genuine 1./2./3. lists preserved.
#21 PolicyCompareModal.tsx + page.tsx: Reviews & claim-settlement
section added to the in-chat compare modal (Bug 3 only covered the
inline cited cards).
#24 policy_identity.clean_display_policy_name: targeted display-only
cleanup ('HDFC ERGO my:Optima Secure (older variant)' ->
'Optima Secure (older variant)'); my:health & other names untouched;
policy_id/UIN/dedup unchanged. Applied at scorecard, brain_tools x4,
main.py citations+coverage.

Verified: full pytest suite exit 0 (no regression); TTS RED->GREEN;
#24 + grounding tests pass; frontend tsc clean, eslint 0-new.

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

backend/brain_tools.py CHANGED
@@ -66,6 +66,7 @@ from pathlib import Path
66
  from typing import Any, Optional
67
 
68
  from backend.config import settings
 
69
 
70
  _log = logging.getLogger(__name__)
71
 
@@ -713,7 +714,9 @@ def _quality_seed_candidates(profile, limit: int = 25) -> list[dict]:
713
  ch = {
714
  "chunk_id": f"{pid}__qseed",
715
  "policy_id": pid,
716
- "policy_name": data.get("policy_name", pid),
 
 
717
  "insurer_slug": data.get("insurer_slug")
718
  or (pid.split("__", 1)[0] if "__" in pid else ""),
719
  "doc_type": "policy",
@@ -802,7 +805,9 @@ async def _retrieve_uploaded_only(
802
  uploaded.append({
803
  "chunk_id": getattr(c, "chunk_id", ""),
804
  "policy_id": getattr(c, "policy_id", ""),
805
- "policy_name": getattr(c, "policy_name", ""),
 
 
806
  "insurer_slug": getattr(c, "insurer_slug", "") or "user-upload",
807
  "doc_type": "user_upload",
808
  "source_url": getattr(c, "source_url", ""),
@@ -1154,7 +1159,9 @@ async def retrieve_policies(
1154
  chunk_dict = {
1155
  "chunk_id": getattr(c, "chunk_id", ""),
1156
  "policy_id": pid,
1157
- "policy_name": getattr(c, "policy_name", ""),
 
 
1158
  "insurer_slug": getattr(c, "insurer_slug", ""),
1159
  "doc_type": doc_type,
1160
  "source_url": getattr(c, "source_url", ""),
@@ -1320,7 +1327,9 @@ def get_policy_facts(session, policy_ids: Optional[list[str]] = None) -> dict:
1320
  for pid in ids:
1321
  cur = curated.get(pid) or {}
1322
  slug = (slug_map.get(pid) or cur.get("insurer_slug") or "").strip()
1323
- pname = name_by_id.get(pid) or cur.get("policy_name") or pid
 
 
1324
  rv = _insurer_reviews(slug) or {}
1325
  cm = rv.get("claim_metrics") or {}
1326
  agg = rv.get("aggregate_score") or {}
 
66
  from typing import Any, Optional
67
 
68
  from backend.config import settings
69
+ from backend.policy_identity import clean_display_policy_name
70
 
71
  _log = logging.getLogger(__name__)
72
 
 
714
  ch = {
715
  "chunk_id": f"{pid}__qseed",
716
  "policy_id": pid,
717
+ "policy_name": clean_display_policy_name(
718
+ data.get("policy_name", pid)
719
+ ),
720
  "insurer_slug": data.get("insurer_slug")
721
  or (pid.split("__", 1)[0] if "__" in pid else ""),
722
  "doc_type": "policy",
 
805
  uploaded.append({
806
  "chunk_id": getattr(c, "chunk_id", ""),
807
  "policy_id": getattr(c, "policy_id", ""),
808
+ "policy_name": clean_display_policy_name(
809
+ getattr(c, "policy_name", "")
810
+ ),
811
  "insurer_slug": getattr(c, "insurer_slug", "") or "user-upload",
812
  "doc_type": "user_upload",
813
  "source_url": getattr(c, "source_url", ""),
 
1159
  chunk_dict = {
1160
  "chunk_id": getattr(c, "chunk_id", ""),
1161
  "policy_id": pid,
1162
+ "policy_name": clean_display_policy_name(
1163
+ getattr(c, "policy_name", "")
1164
+ ),
1165
  "insurer_slug": getattr(c, "insurer_slug", ""),
1166
  "doc_type": doc_type,
1167
  "source_url": getattr(c, "source_url", ""),
 
1327
  for pid in ids:
1328
  cur = curated.get(pid) or {}
1329
  slug = (slug_map.get(pid) or cur.get("insurer_slug") or "").strip()
1330
+ pname = clean_display_policy_name(
1331
+ name_by_id.get(pid) or cur.get("policy_name") or pid
1332
+ )
1333
  rv = _insurer_reviews(slug) or {}
1334
  cm = rv.get("claim_metrics") or {}
1335
  agg = rv.get("aggregate_score") or {}
backend/main.py CHANGED
@@ -1355,6 +1355,7 @@ async def chat(req: ChatRequest, request: Request):
1355
  # an explicit try/except so a malformed citation can never silently
1356
  # bypass our envelope.
1357
  try:
 
1358
  safe_citations: list[CitationOut] = []
1359
  for c in turn.citations or []:
1360
  if not isinstance(c, dict):
@@ -1363,7 +1364,9 @@ async def chat(req: ChatRequest, request: Request):
1363
  safe_citations.append(
1364
  CitationOut(
1365
  policy_id=str(c.get("policy_id", "") or ""),
1366
- policy_name=str(c.get("policy_name", "") or ""),
 
 
1367
  insurer_slug=str(c.get("insurer_slug", "") or ""),
1368
  page_start=int(c.get("page_start", 0) or 0),
1369
  page_end=int(c.get("page_end", 0) or 0),
@@ -1480,6 +1483,7 @@ async def coverage():
1480
  # this refactor: badge = cards = 158 / 19.
1481
  # KI-129 + KI-130 invariants still hold (profile + regulatory excluded).
1482
  import json as _json
 
1483
  _DOCTYPE_RANK_COV = {"wordings": 0, "prospectus": 1, "cis": 2, "brochure": 3}
1484
  _doctype_of_cov = lambda stem: stem.rsplit("__", 1)[1] if "__" in stem else ""
1485
  _product_key_of_cov = lambda pid: pid.rsplit("__", 1)[0] if "__" in pid else pid
@@ -1638,7 +1642,7 @@ async def coverage():
1638
  if pkey in seen_product_keys:
1639
  continue
1640
  seen_product_keys.add(pkey)
1641
- name = data.get("policy_name", "") or pid
1642
  url = data.get("source_pdf_url", "")
1643
  if slug not in by_insurer:
1644
  by_insurer[slug] = {"products": set(), "names": [], "chunks": 0, "aliases": 0}
@@ -1680,7 +1684,9 @@ async def coverage():
1680
  if pkey in seen_product_keys and curated_pid not in ki145_variant_curated_ids_cov:
1681
  continue
1682
  seen_product_keys.add(pkey)
1683
- name = data.get("policy_name", "") or curated_pid
 
 
1684
  url = data.get("source_pdf_url", "")
1685
  if slug not in by_insurer:
1686
  by_insurer[slug] = {"products": set(), "names": [], "chunks": 0, "aliases": 0}
@@ -3100,6 +3106,7 @@ def _marketplace_catalogue(user_profile_dict):
3100
  """
3101
  import json as _json
3102
  from backend.scorecard import build_scorecard
 
3103
  corpus_url_index = _build_corpus_url_index()
3104
  curated_facts = _load_curated_facts()
3105
 
@@ -3332,8 +3339,10 @@ def _marketplace_catalogue(user_profile_dict):
3332
  terminal_pkey = _product_key_of(terminal)
3333
  else:
3334
  terminal_pkey = terminal
3335
- alias_name = (curated_facts.get(curated_policy_id, {}).get("policy_name")
3336
- or curated_policy_id)
 
 
3337
  parent_pkey_aliases.setdefault(terminal_pkey, []).append(alias_name)
3338
  aliased_curated_ids.add(curated_policy_id)
3339
 
@@ -3428,7 +3437,9 @@ def _marketplace_catalogue(user_profile_dict):
3428
  )
3429
  out.append(MarketplacePolicy(
3430
  policy_id=policy_id,
3431
- policy_name=data.get("policy_name", fp.stem),
 
 
3432
  insurer_slug=slug,
3433
  insurer_name=name,
3434
  insurer_home_url=home,
@@ -3554,7 +3565,9 @@ def _marketplace_catalogue(user_profile_dict):
3554
  )
3555
  out.append(MarketplacePolicy(
3556
  policy_id=curated_policy_id,
3557
- policy_name=data.get("policy_name", curated_policy_id),
 
 
3558
  insurer_slug=slug,
3559
  insurer_name=name,
3560
  insurer_home_url=home,
@@ -3664,6 +3677,7 @@ async def compare_policies(policy_ids: list[str] = None):
3664
  """Side-by-side comparison of 2-4 policies with their scorecards + field diffs."""
3665
  import json as _json
3666
  from backend.scorecard import build_scorecard
 
3667
 
3668
  if not policy_ids:
3669
  from fastapi import Query
@@ -3717,7 +3731,9 @@ async def compare_policies(policy_ids: list[str] = None):
3717
  sc = build_scorecard(data, insurer_reviews=ir)
3718
  entries.append(CompareEntry(
3719
  policy_id=pid,
3720
- policy_name=data.get("policy_name", pid),
 
 
3721
  insurer_slug=slug or "?",
3722
  fields=data,
3723
  scorecard=ScorecardResponse(
 
1355
  # an explicit try/except so a malformed citation can never silently
1356
  # bypass our envelope.
1357
  try:
1358
+ from backend.policy_identity import clean_display_policy_name
1359
  safe_citations: list[CitationOut] = []
1360
  for c in turn.citations or []:
1361
  if not isinstance(c, dict):
 
1364
  safe_citations.append(
1365
  CitationOut(
1366
  policy_id=str(c.get("policy_id", "") or ""),
1367
+ policy_name=clean_display_policy_name(
1368
+ str(c.get("policy_name", "") or "")
1369
+ ),
1370
  insurer_slug=str(c.get("insurer_slug", "") or ""),
1371
  page_start=int(c.get("page_start", 0) or 0),
1372
  page_end=int(c.get("page_end", 0) or 0),
 
1483
  # this refactor: badge = cards = 158 / 19.
1484
  # KI-129 + KI-130 invariants still hold (profile + regulatory excluded).
1485
  import json as _json
1486
+ from backend.policy_identity import clean_display_policy_name
1487
  _DOCTYPE_RANK_COV = {"wordings": 0, "prospectus": 1, "cis": 2, "brochure": 3}
1488
  _doctype_of_cov = lambda stem: stem.rsplit("__", 1)[1] if "__" in stem else ""
1489
  _product_key_of_cov = lambda pid: pid.rsplit("__", 1)[0] if "__" in pid else pid
 
1642
  if pkey in seen_product_keys:
1643
  continue
1644
  seen_product_keys.add(pkey)
1645
+ name = clean_display_policy_name(data.get("policy_name", "") or pid)
1646
  url = data.get("source_pdf_url", "")
1647
  if slug not in by_insurer:
1648
  by_insurer[slug] = {"products": set(), "names": [], "chunks": 0, "aliases": 0}
 
1684
  if pkey in seen_product_keys and curated_pid not in ki145_variant_curated_ids_cov:
1685
  continue
1686
  seen_product_keys.add(pkey)
1687
+ name = clean_display_policy_name(
1688
+ data.get("policy_name", "") or curated_pid
1689
+ )
1690
  url = data.get("source_pdf_url", "")
1691
  if slug not in by_insurer:
1692
  by_insurer[slug] = {"products": set(), "names": [], "chunks": 0, "aliases": 0}
 
3106
  """
3107
  import json as _json
3108
  from backend.scorecard import build_scorecard
3109
+ from backend.policy_identity import clean_display_policy_name
3110
  corpus_url_index = _build_corpus_url_index()
3111
  curated_facts = _load_curated_facts()
3112
 
 
3339
  terminal_pkey = _product_key_of(terminal)
3340
  else:
3341
  terminal_pkey = terminal
3342
+ alias_name = clean_display_policy_name(
3343
+ curated_facts.get(curated_policy_id, {}).get("policy_name")
3344
+ or curated_policy_id
3345
+ )
3346
  parent_pkey_aliases.setdefault(terminal_pkey, []).append(alias_name)
3347
  aliased_curated_ids.add(curated_policy_id)
3348
 
 
3437
  )
3438
  out.append(MarketplacePolicy(
3439
  policy_id=policy_id,
3440
+ policy_name=clean_display_policy_name(
3441
+ data.get("policy_name", fp.stem)
3442
+ ),
3443
  insurer_slug=slug,
3444
  insurer_name=name,
3445
  insurer_home_url=home,
 
3565
  )
3566
  out.append(MarketplacePolicy(
3567
  policy_id=curated_policy_id,
3568
+ policy_name=clean_display_policy_name(
3569
+ data.get("policy_name", curated_policy_id)
3570
+ ),
3571
  insurer_slug=slug,
3572
  insurer_name=name,
3573
  insurer_home_url=home,
 
3677
  """Side-by-side comparison of 2-4 policies with their scorecards + field diffs."""
3678
  import json as _json
3679
  from backend.scorecard import build_scorecard
3680
+ from backend.policy_identity import clean_display_policy_name
3681
 
3682
  if not policy_ids:
3683
  from fastapi import Query
 
3731
  sc = build_scorecard(data, insurer_reviews=ir)
3732
  entries.append(CompareEntry(
3733
  policy_id=pid,
3734
+ policy_name=clean_display_policy_name(
3735
+ data.get("policy_name", pid)
3736
+ ),
3737
  insurer_slug=slug or "?",
3738
  fields=data,
3739
  scorecard=ScorecardResponse(
backend/policy_identity.py CHANGED
@@ -28,6 +28,15 @@ Public API:
28
  normalize_uin(raw) -> str
29
  Upper-cased, whitespace/punctuation-collapsed UIN, or "" when the
30
  value is absent / not a plausible UIN.
 
 
 
 
 
 
 
 
 
31
  """
32
 
33
  from __future__ import annotations
@@ -123,4 +132,65 @@ def canonical_key(chunk_or_meta: Any) -> str:
123
  return f"nm:{name}" if name else "pk:"
124
 
125
 
126
- __all__ = ["canonical_key", "product_key", "normalize_uin"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  normalize_uin(raw) -> str
29
  Upper-cased, whitespace/punctuation-collapsed UIN, or "" when the
30
  value is absent / not a plausible UIN.
31
+ clean_display_policy_name(name) -> str
32
+ Display-only cleanup of the user-facing product name (BUG #24).
33
+ Strips the typo-looking lowercase `my:` prefix from HDFC ERGO's
34
+ Optima family ("my:Optima Secure (older variant)" ->
35
+ "Optima Secure (older variant)") and a duplicated leading
36
+ "HDFC ERGO " insurer label (the card shows the insurer
37
+ separately). TARGETED β€” `my:health Suraksha` etc. are legitimate
38
+ HDFC ERGO brand names and are left untouched. Never alters the
39
+ policy_id / UIN / dedup key (name is display-only).
40
  """
41
 
42
  from __future__ import annotations
 
132
  return f"nm:{name}" if name else "pk:"
133
 
134
 
135
+ # BUG #24 β€” display-name cleanup. The user-facing name for HDFC ERGO's
136
+ # Optima Secure carries a typo-looking lowercase `my:` prefix
137
+ # ("my:Optima Secure (older variant)") that reads as a formatting error.
138
+ # It originates in the corpus manifest / extracted JSON / curated facts
139
+ # and flows to every surface (marketplace card, chat citations,
140
+ # get_policy_facts, scorecard, retrieve_policies chunks). We normalise it
141
+ # once, centrally, at every user-facing surface.
142
+ #
143
+ # TARGETED β€” NOT a blanket `my:` strip:
144
+ # * `my:Optima ...` -> `Optima ...` (the BUG β€” fixed)
145
+ # * `my:health Suraksha` / `my:health Medisure Prime` / ...
146
+ # -> UNCHANGED (legitimate HDFC ERGO
147
+ # brand names β€” KEEP)
148
+ # We also drop a duplicated leading "HDFC ERGO " insurer label because
149
+ # the card/citation shows the insurer separately
150
+ # ("HDFC ERGO my:Optima Secure" -> "Optima Secure").
151
+ #
152
+ # The "(older variant)" suffix is an intentional KI-145 dedup
153
+ # disambiguator (1 IRDAI UIN = 1 card) and is preserved verbatim.
154
+ #
155
+ # This is display-only: policy_id / UIN / canonical_key are never derived
156
+ # from the cleaned name, so dedup + policy_id resolution are unchanged.
157
+
158
+ # Only the leading "<duplicate insurer> " prefix on THIS product family is
159
+ # stripped β€” scoped so we never touch an insurer token inside another
160
+ # insurer's product name.
161
+ _DUP_INSURER_PREFIX_RE = re.compile(
162
+ r"^\s*HDFC\s*ERGO\s+(?=my:\s*Optima\b|Optima\s+Secure\b)",
163
+ re.IGNORECASE,
164
+ )
165
+ # `my:Optima` (any case, optional space after the colon) -> `Optima`.
166
+ # Bounded to the literal word "Optima" so `my:health ...` never matches.
167
+ _MY_OPTIMA_PREFIX_RE = re.compile(r"\bmy:\s*(?=Optima\b)", re.IGNORECASE)
168
+
169
+
170
+ def clean_display_policy_name(name: Any) -> str:
171
+ """Return the user-facing product name with the BUG #24 typo-looking
172
+ `my:` prefix removed for the HDFC ERGO Optima family ONLY, plus any
173
+ duplicated leading "HDFC ERGO " insurer label stripped.
174
+
175
+ Conservative and idempotent: a name with no `my:Optima` / leading
176
+ "HDFC ERGO " is returned unchanged (so `my:health Suraksha`,
177
+ `Optima Secure (older variant)`, every non-HDFC name pass through
178
+ untouched). Never mutates identity β€” callers pass the display string
179
+ only; policy_id / UIN / dedup keys are computed elsewhere from the
180
+ raw id, not from this value.
181
+ """
182
+ if not isinstance(name, str):
183
+ return name if name is not None else ""
184
+ out = _DUP_INSURER_PREFIX_RE.sub("", name)
185
+ out = _MY_OPTIMA_PREFIX_RE.sub("", out)
186
+ # Collapse any double space the prefix removals may have left and trim.
187
+ out = re.sub(r"\s{2,}", " ", out).strip()
188
+ return out or name
189
+
190
+
191
+ __all__ = [
192
+ "canonical_key",
193
+ "product_key",
194
+ "normalize_uin",
195
+ "clean_display_policy_name",
196
+ ]
backend/providers/sarvam_tts.py CHANGED
@@ -50,11 +50,21 @@ logger = logging.getLogger(__name__)
50
  # Ceiling is set below the documented cap so per-language preprocessing
51
  # expansion + minor jitter never pushes a chunk back over Sarvam's real
52
  # limit.
 
 
 
 
 
 
 
 
 
 
53
  _TTS_CHAR_LIMIT_BY_MODEL = {
54
- "bulbul:v2": 1500,
55
- "bulbul:v3": 2500,
56
  }
57
- _TTS_CHAR_LIMIT_DEFAULT = 1500 # safest assumption for unknown models
58
  _TTS_SAFETY_MARGIN = 200 # headroom under the documented hard cap
59
 
60
 
@@ -394,7 +404,10 @@ class SarvamTTS(TTSProvider):
394
  "target_language_code": language_code,
395
  "speaker": speaker or self.default_speaker,
396
  "model": self.model,
397
- "enable_preprocessing": True,
 
 
 
398
  "speech_sample_rate": 22050,
399
  "pitch": 0.0,
400
  "pace": 1.0,
@@ -413,7 +426,29 @@ class SarvamTTS(TTSProvider):
413
  if not audios:
414
  raise RuntimeError(f"Sarvam TTS returned no audio: {payload}")
415
  # Sarvam returns base64-encoded WAV in `audios[0]`
416
- return base64.b64decode(audios[0])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
 
418
  async def synthesize_with_mime(
419
  self,
 
50
  # Ceiling is set below the documented cap so per-language preprocessing
51
  # expansion + minor jitter never pushes a chunk back over Sarvam's real
52
  # limit.
53
+ # BUG #19 (2026-05-19): Sarvam bulbul:v2 silently returns HTTP 200 but
54
+ # voices ONLY THE FIRST SENTENCE of any single request over its REAL
55
+ # (small) limit β€” far below the 1500-char documented cap. A normal
56
+ # 370–720-char recommendation reply was sent whole, so the user heard
57
+ # only "Thanks for providing all those details, Rohit!" (~2s) and never
58
+ # the policy list. The documented cap is NOT the operative limit; lower
59
+ # the per-model ceiling so the existing sentence-seam chunker engages for
60
+ # normal replies (effective v2 ceiling = 500 - 200 = 300 β†’ a 370–720
61
+ # char reply splits into 2–3 chunks, each synthesized and concatenated by
62
+ # the already-correct `_concat_wav_bytes`).
63
  _TTS_CHAR_LIMIT_BY_MODEL = {
64
+ "bulbul:v2": 500,
65
+ "bulbul:v3": 1500,
66
  }
67
+ _TTS_CHAR_LIMIT_DEFAULT = 500 # safest assumption for unknown models
68
  _TTS_SAFETY_MARGIN = 200 # headroom under the documented hard cap
69
 
70
 
 
404
  "target_language_code": language_code,
405
  "speaker": speaker or self.default_speaker,
406
  "model": self.model,
407
+ # BUG #19: `enable_preprocessing` is NOT in Sarvam's documented
408
+ # TTS schema; it triggers undocumented server-side first-sentence
409
+ # segmentation (HTTP 200 + only the first sentence voiced).
410
+ # Removed so the full chunk text is synthesized.
411
  "speech_sample_rate": 22050,
412
  "pitch": 0.0,
413
  "pace": 1.0,
 
426
  if not audios:
427
  raise RuntimeError(f"Sarvam TTS returned no audio: {payload}")
428
  # Sarvam returns base64-encoded WAV in `audios[0]`
429
+ wav_bytes = base64.b64decode(audios[0])
430
+
431
+ # BUG #19 β€” defensive truncation guard. Sarvam can return HTTP 200
432
+ # with audio that voices only the FIRST SENTENCE (the silent-
433
+ # truncation symptom). Decode the WAV and, if the playback duration
434
+ # is confidently too short for the text we sent (~30ms/char floor),
435
+ # raise so `classify_tts_exception` surfaces it LOUDLY instead of
436
+ # silently shipping a ~2s clip. The wave decode is wrapped so a
437
+ # decode failure (unexpected container) never masks the real audio;
438
+ # we ONLY raise on a confidently-short, successfully-decoded result.
439
+ try:
440
+ with wave.open(io.BytesIO(wav_bytes), "rb") as w:
441
+ framerate = w.getframerate()
442
+ n_frames = w.getnframes()
443
+ duration_s = (n_frames / framerate) if framerate else None
444
+ except Exception: # pragma: no cover β€” never raise on decode failure
445
+ duration_s = None
446
+ if duration_s is not None and duration_s < 0.030 * len(text):
447
+ raise RuntimeError(
448
+ f"sarvam tts returned truncated audio: {duration_s:.2f}s "
449
+ f"for {len(text)} chars"
450
+ )
451
+ return wav_bytes
452
 
453
  async def synthesize_with_mime(
454
  self,
backend/scorecard.py CHANGED
@@ -892,7 +892,14 @@ def build_scorecard(policy: dict, insurer_reviews: Optional[dict] = None, profil
892
  if not isinstance(policy, dict):
893
  policy = {}
894
  pid = policy.get("policy_id", "") or ""
895
- pname = policy.get("policy_name", "") or ""
 
 
 
 
 
 
 
896
  pslug = policy.get("insurer_slug", "") or ""
897
 
898
  completeness = compute_data_completeness(policy)
 
892
  if not isinstance(policy, dict):
893
  policy = {}
894
  pid = policy.get("policy_id", "") or ""
895
+ # BUG #24 β€” clean the typo-looking lowercase `my:` prefix off the
896
+ # user-facing name (HDFC ERGO Optima family only) at the scorecard
897
+ # chokepoint, so every scorecard-derived surface (compare, single &
898
+ # bulk /api/scorecard) shows "Optima Secure (older variant)" not
899
+ # "my:Optima Secure (older variant)". Display-only β€” policy_id below
900
+ # is untouched, so dedup / resolution are unchanged.
901
+ from backend.policy_identity import clean_display_policy_name
902
+ pname = clean_display_policy_name(policy.get("policy_name", "") or "")
903
  pslug = policy.get("insurer_slug", "") or ""
904
 
905
  completeness = compute_data_completeness(policy)
backend/single_brain.py CHANGED
@@ -208,6 +208,9 @@ If the first call returns 0 or 1 chunk, retry ONCE with a broader query (drop th
208
  RULE 2.5 β€” Pricing inputs (SOFT capture, post-recap)
209
  ═══════════════════════════════════
210
  After all 7 slots are saved AND the user has confirmed the recap (RULE 4 implicit confirmation or explicit yes), BEFORE you call retrieve_policies, ask β€” in ONE compact prompt:
 
 
 
211
  "A few quick pricing inputs (you can skip any):
212
  1. How much sum insured? (e.g., β‚Ή5L / β‚Ή10L / β‚Ή25L / β‚Ή1Cr)
213
  2. Premium budget? (e.g., β‚Ή10–15K/year, or β‚Ή50K+ for premium covers)
@@ -218,6 +221,7 @@ After all 7 slots are saved AND the user has confirmed the recap (RULE 4 implici
218
  7. Smoking status: Do you smoke or use tobacco products? (yes / no)
219
  Save: save_profile_field(field='smoker', value='yes' or 'no')
220
  Smokers face 30-50% premium loading; capturing this gives an accurate band."
 
221
 
222
  When the user answers, call save_profile_field once per provided value:
223
  save_profile_field(field="desired_sum_insured_inr", value="1000000") # β‚Ή10L
@@ -620,10 +624,20 @@ def _profile_to_snapshot(profile) -> dict:
620
  fields it already has access to.
621
  """
622
  snap: dict[str, Any] = {}
 
 
 
 
 
 
 
 
 
623
  for fld in (
624
  "name", "age", "dependents", "location_tier", "income_band",
625
  "primary_goal", "health_conditions", "existing_cover_inr",
626
  "budget_band", "desired_sum_insured_inr",
 
627
  ):
628
  try:
629
  v = getattr(profile, fld, None)
@@ -631,6 +645,12 @@ def _profile_to_snapshot(profile) -> dict:
631
  v = None
632
  if v not in (None, "", []):
633
  snap[fld] = v
 
 
 
 
 
 
634
  return snap
635
 
636
 
@@ -2015,6 +2035,60 @@ async def handle_turn(
2015
  elif name == "retrieve_policies":
2016
  for c in result.get("chunks") or []:
2017
  retrieved_chunks_all.append(c)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2018
  elif name == "mark_recommendation" and result.get("recorded"):
2019
  last_marked_policy_ids = list(result.get("policy_ids") or [])
2020
 
 
208
  RULE 2.5 β€” Pricing inputs (SOFT capture, post-recap)
209
  ═══════════════════════════════════
210
  After all 7 slots are saved AND the user has confirmed the recap (RULE 4 implicit confirmation or explicit yes), BEFORE you call retrieve_policies, ask β€” in ONE compact prompt:
211
+
212
+ CHECK THE PROFILE SNAPSHOT FIRST (Bug #FIX18 β€” DO NOT re-ask captured pricing slots): Before sending the pricing bundle, OMIT every item already present in the profile snapshot above (e.g. if `smoker` is set, do NOT ask smoking; if `existing_cover_inr` set, skip existing cover; if `family_medical_history` set, skip it; if `desired_sum_insured_inr`/`budget_band`/`copay_pct` set, skip those; if `parents_age_max` set, skip the parent-age item). Ask ONLY the genuinely missing ones, renumbered from 1 with no gaps. If ALL bundle items are already captured, SKIP the bundle entirely and proceed straight to retrieve_policies. The bundle items map 1:1 to these snapshot keys: 1β†’desired_sum_insured_inr, 2β†’budget_band, 3β†’existing_cover_inr, 4β†’copay_pct, 5β†’family_medical_history, 6β†’parents_age_max, 7β†’smoker. The verbatim template below is the FULL bundle β€” only emit the subset that is genuinely ABSENT.
213
+
214
  "A few quick pricing inputs (you can skip any):
215
  1. How much sum insured? (e.g., β‚Ή5L / β‚Ή10L / β‚Ή25L / β‚Ή1Cr)
216
  2. Premium budget? (e.g., β‚Ή10–15K/year, or β‚Ή50K+ for premium covers)
 
221
  7. Smoking status: Do you smoke or use tobacco products? (yes / no)
222
  Save: save_profile_field(field='smoker', value='yes' or 'no')
223
  Smokers face 30-50% premium loading; capturing this gives an accurate band."
224
+ (Renumber whatever subset you actually ask starting from 1 β€” never show gaps like "1, 3, 6".)
225
 
226
  When the user answers, call save_profile_field once per provided value:
227
  save_profile_field(field="desired_sum_insured_inr", value="1000000") # β‚Ή10L
 
624
  fields it already has access to.
625
  """
626
  snap: dict[str, Any] = {}
627
+ # FIX #18 (2026-05-19) β€” the RULE 2.5 pricing-bundle fields
628
+ # (copay_pct / family_medical_history / smoker / parents_age_max) were
629
+ # NOT exported here, so the SYSTEM_PROMPT snapshot never showed the
630
+ # model that e.g. `smoker` was already captured (confirmed via
631
+ # save_profile_field + user "yes, correct"). RULE 2.5 then re-asked
632
+ # "Smoking status: ..." verbatim. They are now included so RULE 2.5's
633
+ # "OMIT every item already present in the profile snapshot" instruction
634
+ # has the data to act on. `smoker` is a bool β†’ render as "yes"/"no" so
635
+ # the model reads it the same way it would write it.
636
  for fld in (
637
  "name", "age", "dependents", "location_tier", "income_band",
638
  "primary_goal", "health_conditions", "existing_cover_inr",
639
  "budget_band", "desired_sum_insured_inr",
640
+ "copay_pct", "family_medical_history", "parents_age_max",
641
  ):
642
  try:
643
  v = getattr(profile, fld, None)
 
645
  v = None
646
  if v not in (None, "", []):
647
  snap[fld] = v
648
+ try:
649
+ _sm = getattr(profile, "smoker", None)
650
+ except Exception:
651
+ _sm = None
652
+ if _sm is not None:
653
+ snap["smoker"] = "yes" if _sm else "no"
654
  return snap
655
 
656
 
 
2035
  elif name == "retrieve_policies":
2036
  for c in result.get("chunks") or []:
2037
  retrieved_chunks_all.append(c)
2038
+ elif name == "get_policy_facts" and result.get("ok"):
2039
+ # FIX #23 (2026-05-19) β€” REGRESSION from the get_policy_facts
2040
+ # tool. _verify_prose_grounding only treated retrieve_policies
2041
+ # chunks as grounding, so policy NAMES the model legitimately
2042
+ # stated FROM get_policy_facts (claim/denial/comparison
2043
+ # answers) tripped the no-invented-numbers / UIN guard and the
2044
+ # reply got the false "⚠️ … could not be verified against our
2045
+ # records" caveat appended. Register each policy
2046
+ # get_policy_facts actually returned as a synthetic grounding
2047
+ # entry mirroring the retrieve_policies chunk shape
2048
+ # ({policy_id, policy_name, insurer_slug, uin_code,
2049
+ # source_url, chunk_text}) so the verifier sees it as
2050
+ # grounded. We register ONLY what the tool returned β€” the
2051
+ # guard is NOT weakened for genuinely ungrounded names.
2052
+ for _p in result.get("policies") or []:
2053
+ if not isinstance(_p, dict):
2054
+ continue
2055
+ _pid = (_p.get("policy_id") or "").strip()
2056
+ _pname = (_p.get("policy_name") or "").strip()
2057
+ if not (_pid or _pname):
2058
+ continue
2059
+ _facts_bits = []
2060
+ for _k in (
2061
+ "claim_settlement_ratio_pct",
2062
+ "three_year_avg_csr_pct",
2063
+ "complaints_per_10k_policies",
2064
+ "incurred_claim_ratio_pct",
2065
+ "scorecard_grade",
2066
+ "reputation_headline",
2067
+ ):
2068
+ _v = _p.get(_k)
2069
+ if _v not in (None, "", []):
2070
+ _facts_bits.append(f"{_k}={_v}")
2071
+ retrieved_chunks_all.append(
2072
+ {
2073
+ "policy_id": _pid,
2074
+ "policy_name": _pname,
2075
+ "insurer_slug": (_p.get("insurer_slug") or ""),
2076
+ "uin_code": _p.get("uin_code") or "",
2077
+ "source_url": _p.get("claim_data_source_url")
2078
+ or "",
2079
+ "doc_type": "policy_facts",
2080
+ "chunk_text": (
2081
+ f"{_pname} ({_pid}) β€” "
2082
+ + (
2083
+ "; ".join(_facts_bits)
2084
+ if _facts_bits
2085
+ else "get_policy_facts record"
2086
+ )
2087
+ ),
2088
+ "score": 1.0,
2089
+ "_synthetic_get_policy_facts": True,
2090
+ }
2091
+ )
2092
  elif name == "mark_recommendation" and result.get("recorded"):
2093
  last_marked_policy_ids = list(result.get("policy_ids") or [])
2094
 
frontend/src/app/page.tsx CHANGED
@@ -258,6 +258,27 @@ export default function Page() {
258
  type PTTSpeechRecognitionCtor = new () => PTTSpeechRecognitionInstance;
259
  const pttRecognitionRef = useRef<PTTSpeechRecognitionInstance | null>(null);
260
  const pttFinalTranscriptRef = useRef<string>("");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
262
  // KI-168 (2026-05-15) β€” streaming-voice path replaces the legacy
263
  // useLiveConversation full-duplex VAD machinery. Interim transcript shows
@@ -1022,6 +1043,11 @@ export default function Page() {
1022
  // the user speaks). Best-effort: if SR is unsupported or start() throws
1023
  // we silently continue with the existing Sarvam-only flow.
1024
  pttFinalTranscriptRef.current = "";
 
 
 
 
 
1025
  // V4 FIX 1 / FIX 3 β€” reset both the visible interim strip AND the
1026
  // throttle ref each new PTT cycle so a stale gray-italic transcript
1027
  // from the previous turn doesn't leak through.
@@ -1046,28 +1072,55 @@ export default function Page() {
1046
  // default fallback per the spec.
1047
  rec.lang = ttsLang || "en-IN";
1048
  rec.onresult = (ev: PTTSpeechRecognitionEventLike) => {
1049
- // Assemble interim transcript from ALL results (finals + currently
1050
- // in-progress interim). Mirror useStreamingVoice's pattern.
 
 
 
 
 
 
 
 
1051
  let interim = "";
1052
- let final = "";
1053
  for (let i = 0; i < ev.results.length; i++) {
1054
  const r = ev.results[i];
1055
  const alt = r[0];
1056
  if (!alt) continue;
1057
- if (r.isFinal) final += alt.transcript;
1058
- else interim += alt.transcript;
 
 
 
 
 
 
 
 
 
 
 
 
1059
  }
1060
- // V4 FIX 2 β€” dedup repeated finals within 500ms.
1061
- if (final) {
1062
- const trimmedFinal = final.trim();
 
 
1063
  const { text: prevText, at: prevAt } = lastFinalTextRef.current;
1064
  const now = Date.now();
1065
- if (trimmedFinal && (trimmedFinal !== prevText || now - prevAt > 500)) {
1066
- pttFinalTranscriptRef.current = final;
1067
- lastFinalTextRef.current = { text: trimmedFinal, at: now };
1068
  }
 
 
 
1069
  }
1070
- const display = (final + interim).trim();
 
 
 
1071
  // V4 FIX 4 β€” interim transcript flows into the input as a
1072
  // transcript-sourced fragment so Backspace can word-erase.
1073
  if (display) setInputFromTranscript(display);
@@ -1082,10 +1135,55 @@ export default function Page() {
1082
  }, 200);
1083
  }
1084
  };
1085
- rec.onerror = () => { /* best-effort β€” Sarvam is the source of truth */ };
1086
- rec.onend = () => { /* nothing β€” recorder.onstop drives the submit */ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1087
  pttRecognitionRef.current = rec;
 
 
 
1088
  rec.start();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1089
  }
1090
  } catch {
1091
  // SR unavailable or already running β€” fall through, Sarvam still works.
@@ -1099,11 +1197,26 @@ export default function Page() {
1099
  // event firing AFTER we've already set the input to the Sarvam
1100
  // transcript (which would clobber it). The final transcript captured
1101
  // so far is preserved in pttFinalTranscriptRef as a Sarvam fallback.
 
 
 
 
 
 
 
 
1102
  const sr = pttRecognitionRef.current;
1103
  pttRecognitionRef.current = null;
1104
  if (sr) {
1105
  try { sr.abort(); } catch { /* already stopped */ }
1106
  }
 
 
 
 
 
 
 
1107
  // V4 FIX 3 β€” atomically clear the interim ghost text (both the
1108
  // pending throttled update AND any visible state). Without this,
1109
  // the gray-italic strip below the mic can keep showing the last
@@ -1113,6 +1226,11 @@ export default function Page() {
1113
  pttInterimTimerRef.current = null;
1114
  }
1115
  pttInterimLatestRef.current = "";
 
 
 
 
 
1116
  setPttInterim("");
1117
  const srFallback = pttFinalTranscriptRef.current.trim();
1118
  const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
@@ -1276,6 +1394,20 @@ export default function Page() {
1276
  }
1277
  } catch (e) {
1278
  console.error(e);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1279
  // W2 (2026-05-15) β€” route to the structured banner the same way the
1280
  // live-voice path does, so PTT denials / stalls / fake-stream failures
1281
  // surface in the same red banner (not just an in-chat assistant
@@ -3358,6 +3490,15 @@ function parseInline(src: string): MdInlineToken[] {
3358
  };
3359
  while (i < src.length) {
3360
  const rest = src.slice(i);
 
 
 
 
 
 
 
 
 
3361
  // Links: [label](http(s)://… or relative). Only http/https/mailto or
3362
  // root-relative hrefs are kept; anything else (javascript:, data:) is
3363
  // rendered as plain text so there's no injection vector.
@@ -3442,6 +3583,82 @@ function renderInline(tokens: MdInlineToken[], keyPrefix: string): React.ReactNo
3442
  });
3443
  }
3444
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3445
  // Block-level grouping: paragraphs, ordered lists, unordered lists,
3446
  // headings. Consecutive list lines coalesce into one <ol>/<ul> so a
3447
  // "1. … 2. … 3. …" run renders as a real numbered list, not a wall.
@@ -3452,10 +3669,15 @@ type MdBlock =
3452
  | { t: "h"; level: number; text: string };
3453
 
3454
  function parseBlocks(src: string): MdBlock[] {
 
 
 
 
 
3455
  // Normalise newlines; also break a run-on "1. a 2. b 3. c" that arrived
3456
  // on a single physical line into separate list lines so it still renders
3457
  // as an ordered list (the model sometimes streams without hard breaks).
3458
- const normalised = src
3459
  .replace(/\r\n?/g, "\n")
3460
  .replace(/\s(?=\d{1,2}\.\s)/g, "\n")
3461
  .replace(/\s(?=[β€’]\s)/g, "\n");
@@ -3763,6 +3985,145 @@ function gradeColor(grade: string): string {
3763
  return map[grade] || "bg-stone-400 text-white";
3764
  }
3765
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3766
  // CitedPolicyCards β€” structured per-policy cards rendered BELOW the
3767
  // assistant's prose reply. One card per cited policy with insurer logo,
3768
  // policy name, scorecard grade + one-liner, source-PDF link, and a
@@ -4074,6 +4435,9 @@ function CitedPolicyCards({
4074
  profile={scorecardProfile}
4075
  />
4076
  )}
 
 
 
4077
  onOpenMarketplace={onOpenMarketplace}
4078
  />
4079
  )}
 
258
  type PTTSpeechRecognitionCtor = new () => PTTSpeechRecognitionInstance;
259
  const pttRecognitionRef = useRef<PTTSpeechRecognitionInstance | null>(null);
260
  const pttFinalTranscriptRef = useRef<string>("");
261
+ // FIX #17 (A) β€” accumulator of finalized SR segments across the WHOLE PTT
262
+ // cycle (continuous=false makes Web Speech end+restart on every <1.5s
263
+ // pause; each restart begins a fresh `ev.results` list). Without this the
264
+ // running display rebuilt from only the current session's results loses
265
+ // every earlier finalized word. Mirrors useStreamingVoice's finalsRef.
266
+ const pttFinalSegmentsRef = useRef<string[]>([]);
267
+ // FIX #17 (A) β€” per-session cursor of how many of THIS recognition
268
+ // session's finals we've already pushed onto pttFinalSegmentsRef, so a
269
+ // re-fired onresult for the same session doesn't double-push the same
270
+ // final index. Reset to 0 on every onstart (new session). Mirrors the
271
+ // hook's finalsConsumedRef intent at session granularity.
272
+ const pttSessionFinalsConsumedRef = useRef<number>(0);
273
+ // FIX #17 (B) β€” intent flag mirroring useStreamingVoice's wantRunningRef.
274
+ // `true` from just-before rec.start() until any teardown / abort / error
275
+ // early-return. The onend auto-restart and the heartbeat both gate on it
276
+ // so recognition only restarts while we genuinely still want it running.
277
+ const pttSrWantRunningRef = useRef(false);
278
+ // FIX #17 (B) β€” KI-173-style heartbeat interval handle. Browser
279
+ // SpeechRecognition occasionally enters a stopped state without onend
280
+ // firing; this 3s watchdog re-issues rec.start() while we still want it.
281
+ const pttSrHeartbeatRef = useRef<number | null>(null);
282
 
283
  // KI-168 (2026-05-15) β€” streaming-voice path replaces the legacy
284
  // useLiveConversation full-duplex VAD machinery. Interim transcript shows
 
1043
  // the user speaks). Best-effort: if SR is unsupported or start() throws
1044
  // we silently continue with the existing Sarvam-only flow.
1045
  pttFinalTranscriptRef.current = "";
1046
+ // FIX #17 (A) β€” reset the cross-session final accumulator + session
1047
+ // cursor at the start of every PTT cycle alongside pttFinalTranscriptRef
1048
+ // so finalized words from a previous utterance don't leak forward.
1049
+ pttFinalSegmentsRef.current = [];
1050
+ pttSessionFinalsConsumedRef.current = 0;
1051
  // V4 FIX 1 / FIX 3 β€” reset both the visible interim strip AND the
1052
  // throttle ref each new PTT cycle so a stale gray-italic transcript
1053
  // from the previous turn doesn't leak through.
 
1072
  // default fallback per the spec.
1073
  rec.lang = ttsLang || "en-IN";
1074
  rec.onresult = (ev: PTTSpeechRecognitionEventLike) => {
1075
+ // FIX #17 (A) β€” accumulate finals across the WHOLE PTT cycle.
1076
+ // continuous=false ends+restarts SR on every <1.5s pause, and
1077
+ // each restart begins a fresh `ev.results` list. The old code
1078
+ // rebuilt the display from ONLY the current session's results
1079
+ // and OVERWROTE pttFinalTranscriptRef with `final`, so every
1080
+ // earlier finalized word was lost. Mirror useStreamingVoice:
1081
+ // walk this session's results, push NOT-YET-CONSUMED finals
1082
+ // onto the cross-session accumulator (tracking a per-session
1083
+ // cursor so a re-fired onresult can't double-push), and build
1084
+ // the display from accumulator + live interim.
1085
  let interim = "";
1086
+ let sessionFinalCount = 0;
1087
  for (let i = 0; i < ev.results.length; i++) {
1088
  const r = ev.results[i];
1089
  const alt = r[0];
1090
  if (!alt) continue;
1091
+ if (r.isFinal) {
1092
+ // The i-th final result of THIS session. Only push it once:
1093
+ // sessionFinalCount counts finals seen so far in this event;
1094
+ // pttSessionFinalsConsumedRef is how many of this session's
1095
+ // finals are already in the accumulator.
1096
+ if (sessionFinalCount >= pttSessionFinalsConsumedRef.current) {
1097
+ const t = alt.transcript.trim();
1098
+ if (t) pttFinalSegmentsRef.current.push(t);
1099
+ pttSessionFinalsConsumedRef.current = sessionFinalCount + 1;
1100
+ }
1101
+ sessionFinalCount += 1;
1102
+ } else {
1103
+ interim += alt.transcript;
1104
+ }
1105
  }
1106
+ const accumulated = pttFinalSegmentsRef.current.join(" ").trim();
1107
+ // V4 FIX 2 β€” dedup repeated finals within 500ms (kept for the
1108
+ // Safari double-final quirk; now compares the ACCUMULATED text
1109
+ // rather than only the last session's final).
1110
+ if (accumulated) {
1111
  const { text: prevText, at: prevAt } = lastFinalTextRef.current;
1112
  const now = Date.now();
1113
+ if (accumulated !== prevText || now - prevAt > 500) {
1114
+ lastFinalTextRef.current = { text: accumulated, at: now };
 
1115
  }
1116
+ // FIX #17 (A) β€” Sarvam fallback transcript is the ACCUMULATED
1117
+ // text (all finalized words this cycle), not last-session-only.
1118
+ pttFinalTranscriptRef.current = accumulated;
1119
  }
1120
+ const display = [accumulated, interim.trim()]
1121
+ .filter(Boolean)
1122
+ .join(" ")
1123
+ .trim();
1124
  // V4 FIX 4 β€” interim transcript flows into the input as a
1125
  // transcript-sourced fragment so Backspace can word-erase.
1126
  if (display) setInputFromTranscript(display);
 
1135
  }, 200);
1136
  }
1137
  };
1138
+ // FIX #17 (A) β€” each recognition session restarts with a fresh
1139
+ // `ev.results` list, so the per-session consumed cursor must reset
1140
+ // to 0 at the start of every session (mirrors the hook resetting
1141
+ // its session view on restart). The cross-session accumulator
1142
+ // (pttFinalSegmentsRef) is NOT reset here β€” only on PTT-cycle start.
1143
+ rec.onstart = () => { pttSessionFinalsConsumedRef.current = 0; };
1144
+ rec.onerror = (ev: PTTSpeechRecognitionErrorEventLike) => {
1145
+ // FIX #17 (B) β€” on a terminal permission/device error, stop
1146
+ // wanting to run so the onend auto-restart + heartbeat don't
1147
+ // fight a dead device. `no-speech` / `aborted` are routine in
1148
+ // continuous=false restart mode β€” keep wanting, onend revives.
1149
+ const code = ev.error;
1150
+ if (code === "not-allowed" || code === "service-not-allowed" || code === "audio-capture") {
1151
+ pttSrWantRunningRef.current = false;
1152
+ }
1153
+ /* otherwise best-effort β€” Sarvam remains the source of truth */
1154
+ };
1155
+ // FIX #17 (B) β€” continuous=false makes Web Speech END after the
1156
+ // first ~1.5s pause. The old no-op onend meant recognition was
1157
+ // NEVER restarted, so the live interim froze mid-utterance. Port
1158
+ // useStreamingVoice's restart contract: only restart while we
1159
+ // still WANT to be running; swallow InvalidStateError (already
1160
+ // running) since the heartbeat covers genuinely-dead states.
1161
+ rec.onend = () => {
1162
+ if (!pttSrWantRunningRef.current) return;
1163
+ try { rec.start(); } catch { /* InvalidState β€” heartbeat covers */ }
1164
+ };
1165
  pttRecognitionRef.current = rec;
1166
+ // FIX #17 (B) β€” declare intent to be running BEFORE start() so a
1167
+ // synchronous onend (some engines) sees want=true and restarts.
1168
+ pttSrWantRunningRef.current = true;
1169
  rec.start();
1170
+ // FIX #17 (B) β€” KI-173-style 3s heartbeat. SpeechRecognition can
1171
+ // enter a stopped state without onend firing (transient OS audio
1172
+ // interruption, tab visibility edge cases); the onend restart
1173
+ // never gets a chance to run and the mic stays silently dead.
1174
+ // While we're recording AND still want SR running, re-issue
1175
+ // start() unconditionally β€” InvalidStateError (already running)
1176
+ // is swallowed, otherwise this revives the dead state.
1177
+ if (pttSrHeartbeatRef.current !== null) {
1178
+ clearInterval(pttSrHeartbeatRef.current);
1179
+ pttSrHeartbeatRef.current = null;
1180
+ }
1181
+ pttSrHeartbeatRef.current = window.setInterval(() => {
1182
+ if (!pttSrWantRunningRef.current) return;
1183
+ const r = pttRecognitionRef.current;
1184
+ if (!r) return;
1185
+ try { r.start(); } catch { /* already running β€” fine */ }
1186
+ }, 3000);
1187
  }
1188
  } catch {
1189
  // SR unavailable or already running β€” fall through, Sarvam still works.
 
1197
  // event firing AFTER we've already set the input to the Sarvam
1198
  // transcript (which would clobber it). The final transcript captured
1199
  // so far is preserved in pttFinalTranscriptRef as a Sarvam fallback.
1200
+ // FIX #17 (B) β€” stop WANTING recognition to run BEFORE abort() so
1201
+ // the onend auto-restart (which fires on abort) and the heartbeat
1202
+ // both no-op instead of reviving a recognition we're tearing down.
1203
+ pttSrWantRunningRef.current = false;
1204
+ if (pttSrHeartbeatRef.current !== null) {
1205
+ clearInterval(pttSrHeartbeatRef.current);
1206
+ pttSrHeartbeatRef.current = null;
1207
+ }
1208
  const sr = pttRecognitionRef.current;
1209
  pttRecognitionRef.current = null;
1210
  if (sr) {
1211
  try { sr.abort(); } catch { /* already stopped */ }
1212
  }
1213
+ // FIX #17 (C) β€” trailing flush. The 200ms throttle only SCHEDULES a
1214
+ // setPttInterim when no timer is pending; the last burst of words
1215
+ // arriving while a timer is still pending (or right at release) was
1216
+ // dropped because the block below clears it. Flush the latest value
1217
+ // once more so the final spoken words are visible until the Sarvam
1218
+ // authoritative transcript replaces them.
1219
+ const lastInterim = pttInterimLatestRef.current;
1220
  // V4 FIX 3 β€” atomically clear the interim ghost text (both the
1221
  // pending throttled update AND any visible state). Without this,
1222
  // the gray-italic strip below the mic can keep showing the last
 
1226
  pttInterimTimerRef.current = null;
1227
  }
1228
  pttInterimLatestRef.current = "";
1229
+ // FIX #17 (C) β€” push the last burst into the input as a
1230
+ // transcript-sourced fragment (Backspace word-erase still works)
1231
+ // so it isn't lost; it is replaced by the Sarvam authoritative
1232
+ // text below the moment transcription completes.
1233
+ if (lastInterim) setInputFromTranscript(lastInterim);
1234
  setPttInterim("");
1235
  const srFallback = pttFinalTranscriptRef.current.trim();
1236
  const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
 
1394
  }
1395
  } catch (e) {
1396
  console.error(e);
1397
+ // FIX #17 (B) β€” if we threw AFTER wiring up the parallel SR (e.g. the
1398
+ // W2 `recorder.state !== "recording"` hard-fail), the SR + 3s
1399
+ // heartbeat were already started. Stop wanting it, clear the
1400
+ // heartbeat, and abort recognition so the mic doesn't stay silently
1401
+ // alive behind a failed recorder.
1402
+ pttSrWantRunningRef.current = false;
1403
+ if (pttSrHeartbeatRef.current !== null) {
1404
+ clearInterval(pttSrHeartbeatRef.current);
1405
+ pttSrHeartbeatRef.current = null;
1406
+ }
1407
+ if (pttRecognitionRef.current) {
1408
+ try { pttRecognitionRef.current.abort(); } catch { /* already stopped */ }
1409
+ pttRecognitionRef.current = null;
1410
+ }
1411
  // W2 (2026-05-15) β€” route to the structured banner the same way the
1412
  // live-voice path does, so PTT denials / stalls / fake-stream failures
1413
  // surface in the same red banner (not just an in-chat assistant
 
3490
  };
3491
  while (i < src.length) {
3492
  const rest = src.slice(i);
3493
+ // FIX #22 β€” unescape the `\.` emitted by sanitizeStrayNumberedLines so
3494
+ // a guarded sentence-final number renders as a normal "15." with no
3495
+ // visible backslash. Scoped to `\.` only (the single escape we emit)
3496
+ // so we don't accidentally swallow legitimate backslashes elsewhere.
3497
+ if (rest.startsWith("\\.")) {
3498
+ buf += ".";
3499
+ i += 2;
3500
+ continue;
3501
+ }
3502
  // Links: [label](http(s)://… or relative). Only http/https/mailto or
3503
  // root-relative hrefs are kept; anything else (javascript:, data:) is
3504
  // rendered as plain text so there's no injection vector.
 
3583
  });
3584
  }
3585
 
3586
+ // FIX #22 β€” sentence-final number guard. A bot reply like
3587
+ // "...complaints per 10,000 policies are low at 15. Niva Bupa is..."
3588
+ // has "15." followed by whitespace + more prose. parseBlocks' run-on
3589
+ // splitter (`/\s(?=\d{1,2}\.\s)/ β†’ "\n"`) turns the space before "15."
3590
+ // into a hard break, then the ol matcher `/^(\d{1,2})[.)]\s+/` renders
3591
+ // it as `<ol start=15>` β€” breaking the sentence into a bogus list item
3592
+ // ("15." / "43." / "52." were the live repros).
3593
+ //
3594
+ // A GENUINE recommendation list is small sequential numbers (1, 2, 3)
3595
+ // each at the START of its own line / preceded by blank or list context.
3596
+ // So the SAFE rule: a number is NOT an intended list marker when EITHER
3597
+ // (a) the number is >= 10 (recommendation lists never start at 10+;
3598
+ // they enumerate 1,2,3…), OR
3599
+ // (b) it appears MID-LINE after sentence prose (text precedes it on the
3600
+ // same physical line) β€” that is unambiguously a sentence wrapping.
3601
+ // In those cases we escape the dot as `\.` so neither the run-on splitter
3602
+ // nor the ol matcher fires; renderInline unescapes `\.`β†’`.` so the user
3603
+ // still sees a normal "15." with no backslash. Single small digits at a
3604
+ // real line start (a true "1. …" / "2. …" list) are left untouched.
3605
+ function sanitizeStrayNumberedLines(src: string): string {
3606
+ // Walk physical lines so we never touch a number that legitimately
3607
+ // starts its own line as item 1..9 of a real list.
3608
+ const lines = src.replace(/\r\n?/g, "\n").split("\n");
3609
+ const isListLine = (s: string) =>
3610
+ /^\s*(\d+)[.)]\s+/.test(s) || /^\s*[-*β€’]\s+/.test(s);
3611
+ let prevNonEmpty = "";
3612
+ // A run-on list the model streamed on ONE physical line looks like
3613
+ // "...: 1. A 2. B 3. C" β€” small SEQUENTIAL numbers. parseBlocks already
3614
+ // splits those into a real <ol> via its `/\s(?=\d{1,2}\.\s)/β†’"\n"` rule,
3615
+ // so we must NOT escape them. We treat a line as a run-on list when it
3616
+ // contains a "1." AND a "2." marker (sequential small markers); only
3617
+ // then do we leave its small-number dots alone.
3618
+ const looksLikeRunOnList = (s: string) =>
3619
+ /(^|\s)1\.\s/.test(s) && /(^|\s)2\.\s/.test(s);
3620
+ return lines
3621
+ .map((rawLine) => {
3622
+ const runOn = looksLikeRunOnList(rawLine);
3623
+ // (b) MID-LINE: a "<digits>. " with non-space text before it on the
3624
+ // same line is a wrapped sentence ("...are low at 15. Niva..."),
3625
+ // NOT a list. Escape it so the run-on splitter + ol matcher skip it.
3626
+ // Scope: numbers >= 10 are ALWAYS a wrapped sentence here (a real
3627
+ // list never enumerates a bare 10+ mid-line); numbers 1-9 are only
3628
+ // escaped when the line is NOT a streamed run-on list (so a genuine
3629
+ // "Options: 1. A 2. B 3. C" still becomes a real <ol>). Negative
3630
+ // lookbehind for `\` so we don't double-escape on a re-run.
3631
+ let out = rawLine.replace(
3632
+ /(\S[^\n]*?)(?<!\\)\b(\d+)\.(\s)/g,
3633
+ (_m, pre: string, num: string, ws: string) => {
3634
+ const n = parseInt(num, 10);
3635
+ if (n >= 10) return `${pre}${num}\\.${ws}`;
3636
+ if (runOn) return _m; // genuine streamed list marker β€” keep
3637
+ return `${pre}${num}\\.${ws}`;
3638
+ },
3639
+ );
3640
+ // (a) LINE-START with a number >= 10 (e.g. a hard-wrapped "15." that
3641
+ // landed at column 0 after the model wrapped a sentence). A real
3642
+ // recommendation list enumerates 1,2,3… so it only reaches 10+ when
3643
+ // the IMMEDIATELY-PRECEDING non-empty line is itself a list item
3644
+ // (a genuine long list). If the previous line is prose / blank /
3645
+ // absent, a leading 10+ is a wrapped sentence number β†’ escape it.
3646
+ // Small line-start digits (1-9) are always left as a real list.
3647
+ out = out.replace(
3648
+ /^(\s*)(\d+)\.(\s)/,
3649
+ (m, lead: string, num: string, ws: string) => {
3650
+ const n = parseInt(num, 10);
3651
+ if (n < 10) return m; // genuine list item 1..9 β€” never touch
3652
+ const prevIsListContext = isListLine(prevNonEmpty);
3653
+ return prevIsListContext ? m : `${lead}${num}\\.${ws}`;
3654
+ },
3655
+ );
3656
+ if (rawLine.trim()) prevNonEmpty = rawLine;
3657
+ return out;
3658
+ })
3659
+ .join("\n");
3660
+ }
3661
+
3662
  // Block-level grouping: paragraphs, ordered lists, unordered lists,
3663
  // headings. Consecutive list lines coalesce into one <ol>/<ul> so a
3664
  // "1. … 2. … 3. …" run renders as a real numbered list, not a wall.
 
3669
  | { t: "h"; level: number; text: string };
3670
 
3671
  function parseBlocks(src: string): MdBlock[] {
3672
+ // FIX #22 β€” neutralise sentence-final / wrapped numbers ("...at 15.")
3673
+ // BEFORE the run-on splitter below converts the space before them into a
3674
+ // hard break and the ol matcher list-ifies them. Genuine 1./2./3.
3675
+ // recommendation lists are untouched (see sanitizeStrayNumberedLines).
3676
+ const guarded = sanitizeStrayNumberedLines(src);
3677
  // Normalise newlines; also break a run-on "1. a 2. b 3. c" that arrived
3678
  // on a single physical line into separate list lines so it still renders
3679
  // as an ordered list (the model sometimes streams without hard breaks).
3680
+ const normalised = guarded
3681
  .replace(/\r\n?/g, "\n")
3682
  .replace(/\s(?=\d{1,2}\.\s)/g, "\n")
3683
  .replace(/\s(?=[β€’]\s)/g, "\n");
 
3985
  return map[grade] || "bg-stone-400 text-white";
3986
  }
3987
 
3988
+ // FIX #21 β€” Reviews / claim-settlement cell for the in-chat COMPARE modal.
3989
+ // The compare modal previously showed POLICY DETAILS + premium but NO
3990
+ // reviews; the earlier Bug-3 fix only added the inline-card "Reviews:"
3991
+ // line. This cell is wired into PolicyCompareModal via renderReviewsFor
3992
+ // and reuses the SAME getInsurerReviews fetch + fields as the inline card
3993
+ // (letter_grade / value_0_100 / claim_settlement_ratio_pct / headline) so
3994
+ // the two surfaces stay consistent. Keyed per insurer_slug; one fetch per
3995
+ // column. ALWAYS renders a labelled state β€” loading / compiled / data β€”
3996
+ // never silently vanishes (#76 rule).
3997
+ function CompareReviewsCell({ insurerSlug }: { insurerSlug: string }) {
3998
+ // Keyed-by-slug result map (mirrors CitedPolicyCards' `reviews[slug]`
3999
+ // pattern, which is the lint-clean shape used elsewhere in this file):
4000
+ // the effect ONLY calls the setter from inside the async .then/.catch
4001
+ // callbacks β€” never synchronously in the effect body β€” so it does not
4002
+ // trip `react-hooks/set-state-in-effect`. The displayed state for the
4003
+ // current slug is derived from the map at render time.
4004
+ const [byslug, setBySlug] = useState<
4005
+ Record<string, InsurerReviews | null>
4006
+ >({});
4007
+ useEffect(() => {
4008
+ let alive = true;
4009
+ if (!insurerSlug) return;
4010
+ if (insurerSlug in byslug) return;
4011
+ getInsurerReviews(insurerSlug)
4012
+ .then((r) => {
4013
+ if (alive) setBySlug((p) => ({ ...p, [insurerSlug]: r }));
4014
+ })
4015
+ .catch(() => {
4016
+ if (alive) setBySlug((p) => ({ ...p, [insurerSlug]: null }));
4017
+ });
4018
+ return () => {
4019
+ alive = false;
4020
+ };
4021
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4022
+ }, [insurerSlug]);
4023
+
4024
+ // undefined β†’ not yet fetched (loading); null β†’ fetched-but-empty;
4025
+ // object β†’ data. Empty slug is treated as null (graceful missing).
4026
+ const rv: InsurerReviews | null | undefined = !insurerSlug
4027
+ ? null
4028
+ : insurerSlug in byslug
4029
+ ? byslug[insurerSlug]
4030
+ : undefined;
4031
+
4032
+ if (rv === undefined) {
4033
+ return (
4034
+ <div
4035
+ style={{
4036
+ fontSize: 12,
4037
+ color: "var(--muted-foreground)",
4038
+ fontStyle: "italic",
4039
+ }}
4040
+ >
4041
+ Loading insurer reputation…
4042
+ </div>
4043
+ );
4044
+ }
4045
+ if (rv === null) {
4046
+ return (
4047
+ <div
4048
+ style={{
4049
+ fontSize: 12,
4050
+ color: "var(--muted-foreground)",
4051
+ fontStyle: "italic",
4052
+ }}
4053
+ >
4054
+ Reputation data being compiled for this insurer.
4055
+ </div>
4056
+ );
4057
+ }
4058
+ const s = rv.aggregate_score || {};
4059
+ const cm = rv.claim_metrics || {};
4060
+ const csr = cm.claim_settlement_ratio_pct;
4061
+ const bits: string[] = [];
4062
+ if (s.letter_grade) bits.push(s.letter_grade);
4063
+ if (s.value_0_100 != null) bits.push(`${s.value_0_100}/100`);
4064
+ if (csr != null) bits.push(`${csr}% claims settled`);
4065
+ // If the reviews payload came back but every headline metric is empty,
4066
+ // still say so explicitly rather than rendering a blank box.
4067
+ if (bits.length === 0 && !s.headline) {
4068
+ return (
4069
+ <div
4070
+ style={{
4071
+ fontSize: 12,
4072
+ color: "var(--muted-foreground)",
4073
+ fontStyle: "italic",
4074
+ }}
4075
+ >
4076
+ No published reputation metrics for this insurer yet.
4077
+ </div>
4078
+ );
4079
+ }
4080
+ return (
4081
+ <div style={{ fontSize: 12.5, lineHeight: 1.5, color: "var(--foreground)" }}>
4082
+ {bits.length > 0 && (
4083
+ <div style={{ fontWeight: 600 }}>{bits.join(" Β· ")}</div>
4084
+ )}
4085
+ {s.headline && (
4086
+ <div
4087
+ style={{
4088
+ marginTop: bits.length > 0 ? 4 : 0,
4089
+ color: "var(--muted-foreground)",
4090
+ }}
4091
+ >
4092
+ {s.headline}
4093
+ </div>
4094
+ )}
4095
+ {csr != null && cm.claim_settlement_ratio_year && (
4096
+ <div
4097
+ style={{
4098
+ marginTop: 4,
4099
+ fontSize: 11,
4100
+ color: "var(--muted-foreground)",
4101
+ }}
4102
+ >
4103
+ Claim settlement ratio Β· FY {cm.claim_settlement_ratio_year}
4104
+ {cm.source_irdai_url ? (
4105
+ <>
4106
+ {" Β· "}
4107
+ <a
4108
+ href={cm.source_irdai_url}
4109
+ target="_blank"
4110
+ rel="noopener noreferrer"
4111
+ style={{
4112
+ color: "var(--primary)",
4113
+ textDecoration: "underline",
4114
+ textUnderlineOffset: 2,
4115
+ }}
4116
+ >
4117
+ IRDAI source
4118
+ </a>
4119
+ </>
4120
+ ) : null}
4121
+ </div>
4122
+ )}
4123
+ </div>
4124
+ );
4125
+ }
4126
+
4127
  // CitedPolicyCards β€” structured per-policy cards rendered BELOW the
4128
  // assistant's prose reply. One card per cited policy with insurer logo,
4129
  // policy name, scorecard grade + one-liner, source-PDF link, and a
 
4435
  profile={scorecardProfile}
4436
  />
4437
  )}
4438
+ renderReviewsFor={(insurerSlug) => (
4439
+ <CompareReviewsCell insurerSlug={insurerSlug} />
4440
+ )}
4441
  onOpenMarketplace={onOpenMarketplace}
4442
  />
4443
  )}
frontend/src/components/PolicyCompareModal.tsx CHANGED
@@ -233,6 +233,14 @@ export type PolicyCompareModalProps = {
233
  // B2 + B3 plug points. Both are optional; safe fallbacks render below.
234
  renderPremiumFor?: (policyId: string, policyName: string) => ReactNode;
235
  renderScorecardFor?: (policyId: string, policyName: string) => ReactNode;
 
 
 
 
 
 
 
 
236
  // Profile hint for downstream personalized widgets (unused by the shell
237
  // itself; pass-through so widgets opened via renderXxxFor can read it).
238
  // Typed loosely (any) by contract; harness should narrow when wiring.
@@ -259,6 +267,7 @@ export default function PolicyCompareModal({
259
  onClose,
260
  renderPremiumFor,
261
  renderScorecardFor,
 
262
  profile: _profile,
263
  policyDataFor,
264
  onOpenMarketplace,
@@ -436,6 +445,7 @@ export default function PolicyCompareModal({
436
  c.policy_id,
437
  c.policy_name,
438
  )}
 
439
  marketplacePolicy={policyDataFor?.(c.policy_id, c.policy_name)}
440
  profile={_profile as SnapProfile}
441
  />
@@ -489,12 +499,14 @@ function CompareColumn({
489
  citation,
490
  premiumSlot,
491
  scorecardSlot,
 
492
  marketplacePolicy,
493
  profile,
494
  }: {
495
  citation: Citation;
496
  premiumSlot?: ReactNode;
497
  scorecardSlot?: ReactNode;
 
498
  marketplacePolicy?: MarketplacePolicy;
499
  profile?: SnapProfile;
500
  }) {
@@ -678,6 +690,19 @@ function CompareColumn({
678
  {premiumSlot ?? <PlaceholderWidget label="Premium calculator coming soon" />}
679
  </Section>
680
 
 
 
 
 
 
 
 
 
 
 
 
 
 
681
  {/* POLICY DETAILS expandable */}
682
  <PolicyDetails citation={citation} policy={marketplacePolicy} />
683
  </div>
 
233
  // B2 + B3 plug points. Both are optional; safe fallbacks render below.
234
  renderPremiumFor?: (policyId: string, policyName: string) => ReactNode;
235
  renderScorecardFor?: (policyId: string, policyName: string) => ReactNode;
236
+ // FIX #21 β€” insurer reviews / claim-settlement plug point, keyed by the
237
+ // citation's insurer_slug (reviews are per-insurer, not per-policy). The
238
+ // inline cited cards + the full detail modal both show a reviews section;
239
+ // this compare modal previously did NOT (user-flagged: "no Reviews /
240
+ // claim-settlement section here"). Optional β€” when unwired, a graceful
241
+ // "reputation data unavailable" line renders so the section never
242
+ // silently vanishes.
243
+ renderReviewsFor?: (insurerSlug: string) => ReactNode;
244
  // Profile hint for downstream personalized widgets (unused by the shell
245
  // itself; pass-through so widgets opened via renderXxxFor can read it).
246
  // Typed loosely (any) by contract; harness should narrow when wiring.
 
267
  onClose,
268
  renderPremiumFor,
269
  renderScorecardFor,
270
+ renderReviewsFor,
271
  profile: _profile,
272
  policyDataFor,
273
  onOpenMarketplace,
 
445
  c.policy_id,
446
  c.policy_name,
447
  )}
448
+ reviewsSlot={renderReviewsFor?.(c.insurer_slug)}
449
  marketplacePolicy={policyDataFor?.(c.policy_id, c.policy_name)}
450
  profile={_profile as SnapProfile}
451
  />
 
499
  citation,
500
  premiumSlot,
501
  scorecardSlot,
502
+ reviewsSlot,
503
  marketplacePolicy,
504
  profile,
505
  }: {
506
  citation: Citation;
507
  premiumSlot?: ReactNode;
508
  scorecardSlot?: ReactNode;
509
+ reviewsSlot?: ReactNode;
510
  marketplacePolicy?: MarketplacePolicy;
511
  profile?: SnapProfile;
512
  }) {
 
690
  {premiumSlot ?? <PlaceholderWidget label="Premium calculator coming soon" />}
691
  </Section>
692
 
693
+ {/* FIX #21 β€” REVIEWS & CLAIM SETTLEMENT. The inline cited cards + the
694
+ full marketplace detail modal both surface insurer reputation /
695
+ claim-settlement; this compare modal omitted it (user-flagged).
696
+ The slot reuses the SAME getInsurerReviews fetch + fields as the
697
+ inline "Reviews:" line. Always render the Section so every column
698
+ carries it in the SAME position; the slot itself owns its
699
+ loading / missing state ("never silently vanish"). */}
700
+ <Section title="Reviews & claim settlement">
701
+ {reviewsSlot ?? (
702
+ <PlaceholderWidget label="Insurer reputation data unavailable" />
703
+ )}
704
+ </Section>
705
+
706
  {/* POLICY DETAILS expandable */}
707
  <PolicyDetails citation={citation} policy={marketplacePolicy} />
708
  </div>
tests/test_bug24_display_name_cleanup.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BUG #24 β€” user-facing policy_name cleanup for the HDFC ERGO Optima family.
2
+
3
+ SYMPTOM
4
+ -------
5
+ A policy card / chat prose showed the product name
6
+ `my:Optima Secure (older variant)` (insurer "HDFC ERGO"). The lowercase
7
+ `my:` prefix reads as a typo / formatting error to users. The official
8
+ product is "Optima Secure"; the "(older variant)" suffix is an INTENTIONAL
9
+ KI-145 dedup disambiguator (1 IRDAI UIN = 1 marketplace card) that MUST be
10
+ preserved.
11
+
12
+ FIX
13
+ ---
14
+ `backend.policy_identity.clean_display_policy_name()` β€” a small, central,
15
+ display-only normaliser applied at every user-facing surface that emits a
16
+ policy_name (marketplace card pass-1/pass-2 + aliases, /api/coverage
17
+ sample names, chat citations, retrieve_policies chunks, get_policy_facts,
18
+ build_scorecard β†’ compare / single & bulk scorecard).
19
+
20
+ INVARIANTS THIS LOCKS DOWN
21
+ --------------------------
22
+ 1. The cleaned display name for the older-variant product is
23
+ "Optima Secure (older variant)" β€” no "my:", no duplicated "HDFC ERGO "
24
+ insurer label (the insurer is shown separately on the card), and the
25
+ "(older variant)" disambiguator is kept verbatim.
26
+ 2. TARGETED, NOT a blanket `my:` strip β€” `my:health Suraksha` and the
27
+ rest of HDFC ERGO's legitimate `my:health …` brand family are
28
+ returned UNCHANGED.
29
+ 3. Display-only β€” `policy_id`, normalised UIN, `product_key` and
30
+ `canonical_key` (KI-145 dedup identity) are NEVER derived from the
31
+ cleaned name, so policy_id resolution + 1-UIN-1-card dedup still work
32
+ exactly as before and the newer vs older Optima Secure siblings stay
33
+ two distinct cards.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import sys
38
+ import unittest
39
+ from pathlib import Path
40
+
41
+ _REPO_ROOT = Path(__file__).resolve().parent.parent
42
+ if str(_REPO_ROOT) not in sys.path:
43
+ sys.path.insert(0, str(_REPO_ROOT))
44
+
45
+ from backend.policy_identity import ( # noqa: E402
46
+ canonical_key,
47
+ clean_display_policy_name,
48
+ normalize_uin,
49
+ product_key,
50
+ )
51
+
52
+ # The product at the centre of BUG #24.
53
+ _OLD_VARIANT_PID = "hdfc-ergo__my-optima-secure-older-variant__wordings"
54
+ _OLD_VARIANT_UIN = "HDFHLIP21016V012122"
55
+ _NEW_PID = "hdfc-ergo__optima-secure"
56
+ _NEW_UIN = "HDFHLIP25041V062425"
57
+
58
+
59
+ class TestDisplayNameCleanup(unittest.TestCase):
60
+ # ---- the BUG is fixed -------------------------------------------------
61
+ def test_older_variant_my_prefix_removed_disambiguator_kept(self):
62
+ self.assertEqual(
63
+ clean_display_policy_name("my:Optima Secure (older variant)"),
64
+ "Optima Secure (older variant)",
65
+ )
66
+
67
+ def test_plain_my_optima_secure_cleaned(self):
68
+ self.assertEqual(
69
+ clean_display_policy_name("my:Optima Secure"),
70
+ "Optima Secure",
71
+ )
72
+
73
+ def test_duplicated_insurer_label_stripped(self):
74
+ # The card shows the insurer ("HDFC ERGO") separately, so the name
75
+ # must not duplicate it.
76
+ self.assertEqual(
77
+ clean_display_policy_name("HDFC ERGO my:Optima Secure"),
78
+ "Optima Secure",
79
+ )
80
+ self.assertEqual(
81
+ clean_display_policy_name(
82
+ "HDFC ERGO Optima Secure (Older / Legacy Variant)"
83
+ ),
84
+ "Optima Secure (Older / Legacy Variant)",
85
+ )
86
+
87
+ def test_no_lowercase_my_colon_remains_for_optima_family(self):
88
+ for raw in (
89
+ "my:Optima Secure (older variant)",
90
+ "my:Optima Secure",
91
+ "HDFC ERGO my:Optima Secure",
92
+ ):
93
+ self.assertNotIn("my:", clean_display_policy_name(raw))
94
+
95
+ # ---- TARGETED: my:health family is LEGITIMATE and untouched ----------
96
+ def test_my_health_family_is_NOT_altered(self):
97
+ legit = [
98
+ "my:health Suraksha",
99
+ "my:health Medisure Prime",
100
+ "my:health Sampoorna Suraksha",
101
+ "my:health Women Suraksha",
102
+ "my:health Medisure Prime Insurance",
103
+ "HDFC ERGO my:health Suraksha", # not Optima β†’ prefix stays
104
+ ]
105
+ for name in legit:
106
+ self.assertEqual(
107
+ clean_display_policy_name(name),
108
+ name,
109
+ f"legitimate HDFC ERGO brand name altered: {name!r}",
110
+ )
111
+
112
+ # ---- conservative / idempotent --------------------------------------
113
+ def test_already_clean_and_other_products_unchanged(self):
114
+ for name in (
115
+ "Optima Secure (older variant)", # already clean
116
+ "Optima Restore",
117
+ "Optima Plus",
118
+ "Optima Enhance",
119
+ "Star Family Health Optima",
120
+ "Activ Secure - Cancer Secure",
121
+ ):
122
+ self.assertEqual(clean_display_policy_name(name), name)
123
+
124
+ def test_idempotent(self):
125
+ once = clean_display_policy_name("HDFC ERGO my:Optima Secure")
126
+ self.assertEqual(clean_display_policy_name(once), once)
127
+
128
+ def test_non_string_safe(self):
129
+ self.assertEqual(clean_display_policy_name(None), "")
130
+ self.assertEqual(clean_display_policy_name(123), 123)
131
+
132
+ # ---- HARD CONSTRAINT: identity / dedup UNCHANGED --------------------
133
+ def test_policy_id_uin_dedup_unchanged_by_display_cleanup(self):
134
+ chunk = {
135
+ "policy_id": _OLD_VARIANT_PID,
136
+ "policy_name": "my:Optima Secure (older variant)",
137
+ "uin_code": _OLD_VARIANT_UIN,
138
+ }
139
+ cleaned = dict(chunk)
140
+ cleaned["policy_name"] = clean_display_policy_name(
141
+ chunk["policy_name"]
142
+ )
143
+
144
+ # Dedup key is derived from UIN / policy_id, never from the name.
145
+ self.assertEqual(canonical_key(chunk), canonical_key(cleaned))
146
+ self.assertEqual(
147
+ canonical_key(cleaned), f"uin:{_OLD_VARIANT_UIN}"
148
+ )
149
+ self.assertEqual(
150
+ product_key(chunk["policy_id"]),
151
+ product_key(cleaned["policy_id"]),
152
+ )
153
+ self.assertEqual(
154
+ normalize_uin(chunk["uin_code"]), _OLD_VARIANT_UIN
155
+ )
156
+
157
+ def test_ki145_two_cards_preserved(self):
158
+ # The newer "Optima Secure" and the "(older variant)" have
159
+ # DIFFERENT UINs β†’ KI-145 must keep them as two distinct cards.
160
+ newer = {"policy_id": _NEW_PID, "uin_code": _NEW_UIN}
161
+ older = {"policy_id": _OLD_VARIANT_PID, "uin_code": _OLD_VARIANT_UIN}
162
+ self.assertNotEqual(canonical_key(newer), canonical_key(older))
163
+
164
+
165
+ if __name__ == "__main__":
166
+ unittest.main()
tests/test_get_policy_facts_grounding.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FIX #23 (2026-05-19) β€” faithfulness guard false "could not verify UIN"
2
+ on get_policy_facts answers (REGRESSION from the get_policy_facts tool).
3
+
4
+ SYMPTOM: a correct contextual comparison / recommendation that used
5
+ `get_policy_facts` ended with:
6
+
7
+ "⚠️ One or more policy identifiers above could not be verified against
8
+ our records β€” please confirm the UIN with the insurer before relying
9
+ on it."
10
+
11
+ …even though the answer was grounded.
12
+
13
+ ROOT: `single_brain._verify_prose_grounding(reply_text,
14
+ retrieved_chunks_all)` only treated `retrieve_policies` chunks as grounding.
15
+ `get_policy_facts` returns
16
+ `{"ok": True, "policies": [{policy_id, policy_name, insurer_slug, ...}]}`
17
+ but those policies were NEVER appended to `retrieved_chunks_all`, so a
18
+ policy NAME / UIN the model legitimately stated FROM get_policy_facts
19
+ tripped the no-invented-numbers / UIN guard.
20
+
21
+ FIX: in `handle_turn`'s `_execute_tool` loop, when the executed tool is
22
+ `get_policy_facts` and `ok` is True, append each returned policy as a
23
+ synthetic grounding entry into `retrieved_chunks_all`, mirroring the
24
+ retrieve_policies chunk shape ({policy_id, policy_name, insurer_slug,
25
+ uin_code, source_url, chunk_text}). The guard is NOT weakened for
26
+ genuinely ungrounded names.
27
+
28
+ These tests stub the two network seams (single_brain._gemini_call and
29
+ brain_tools.get_policy_facts) and drive the real handle_turn so the
30
+ grounding verifier runs for real.
31
+
32
+ Run:
33
+ .venv/bin/python -m pytest -q tests/test_get_policy_facts_grounding.py
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import asyncio
39
+ import os
40
+ import sys
41
+ import unittest
42
+ import uuid
43
+ from pathlib import Path
44
+ from unittest import mock
45
+
46
+ _REPO_ROOT = Path(__file__).resolve().parent.parent
47
+ if str(_REPO_ROOT) not in sys.path:
48
+ sys.path.insert(0, str(_REPO_ROOT))
49
+
50
+ from backend import brain_tools, single_brain # noqa: E402
51
+
52
+ # The exact transparency caveat handle_turn appends when
53
+ # _verify_prose_grounding fails β€” the string this regression must NOT see
54
+ # on a grounded get_policy_facts answer.
55
+ _CAVEAT = "could not be verified against our records"
56
+
57
+ # A UIN that matches single_brain._verify_prose_grounding's regex
58
+ # (\b[A-Z]{3,}[A-Z0-9]{2,}V\d{5,7}\b) β€” i.e. the exact fabrication-class
59
+ # string the guard flags when it is NOT present in any grounding entry.
60
+ _UIN = "MAXHLIP21177V032122"
61
+
62
+
63
+ def _run(coro):
64
+ return asyncio.new_event_loop().run_until_complete(coro)
65
+
66
+
67
+ def _fc_part(name, args):
68
+ return {"functionCall": {"name": name, "args": args}}
69
+
70
+
71
+ def _text_payload(text):
72
+ return {"candidates": [{"content": {"parts": [{"text": text}]}}]}
73
+
74
+
75
+ def _tool_payload(parts):
76
+ return {"candidates": [{"content": {"parts": parts}}]}
77
+
78
+
79
+ class _Harness(unittest.TestCase):
80
+ """Drives single_brain.handle_turn with _gemini_call scripted and
81
+ brain_tools.get_policy_facts stubbed to a deterministic payload."""
82
+
83
+ def setUp(self):
84
+ self._env = mock.patch.dict(
85
+ os.environ, {"GOOGLE_API_KEY": "test-key"}
86
+ )
87
+ self._env.start()
88
+ self._gemini_script: list = []
89
+ self._gpf_result: dict = {}
90
+
91
+ async def _fake_gemini(*_a, **_k):
92
+ if not self._gemini_script:
93
+ return _text_payload("(no more scripted turns)")
94
+ return self._gemini_script.pop(0)
95
+
96
+ def _fake_gpf(_session, policy_ids=None):
97
+ return self._gpf_result
98
+
99
+ self._gp = mock.patch.object(
100
+ single_brain, "_gemini_call", _fake_gemini
101
+ )
102
+ self._gpf = mock.patch.object(
103
+ brain_tools, "get_policy_facts", _fake_gpf
104
+ )
105
+ self._gp.start()
106
+ self._gpf.start()
107
+
108
+ def tearDown(self):
109
+ self._gpf.stop()
110
+ self._gp.stop()
111
+ self._env.stop()
112
+
113
+ def _session(self):
114
+ from backend.session_state import SessionState
115
+
116
+ return SessionState(session_id=f"t_{uuid.uuid4().hex[:8]}")
117
+
118
+
119
+ class TestGetPolicyFactsGrounding(_Harness):
120
+ def test_named_policy_from_get_policy_facts_is_not_falsely_flagged(self):
121
+ """The model calls get_policy_facts, then names that policy AND
122
+ states its UIN in prose. Pre-FIX #23 the UIN was ungrounded (the
123
+ get_policy_facts policy was never in retrieved_chunks_all) β†’ the
124
+ false ⚠️ caveat. Post-fix the policy is registered as grounding β†’
125
+ NO caveat."""
126
+ sess = self._session()
127
+ self._gpf_result = {
128
+ "ok": True,
129
+ "count": 1,
130
+ "policies": [
131
+ {
132
+ "policy_id": "niva-bupa__reassure-3",
133
+ "policy_name": "Niva Bupa ReAssure 3.0",
134
+ "insurer_slug": "niva-bupa",
135
+ "insurer_name": "Niva Bupa",
136
+ "uin_code": _UIN,
137
+ "claim_settlement_ratio_pct": 91.4,
138
+ "complaints_per_10k_policies": 5.2,
139
+ "scorecard_grade": "A",
140
+ "claim_data_source_url": "https://irdai.gov.in/x",
141
+ }
142
+ ],
143
+ }
144
+ self._gemini_script = [
145
+ _tool_payload(
146
+ [
147
+ _fc_part(
148
+ "get_policy_facts",
149
+ {"policy_ids": ["niva-bupa__reassure-3"]},
150
+ )
151
+ ]
152
+ ),
153
+ _text_payload(
154
+ "Niva Bupa ReAssure 3.0 (UIN " + _UIN + ") has a strong "
155
+ "91.4% claim-settlement ratio and low complaints β€” a solid "
156
+ "pick on claim record."
157
+ ),
158
+ ]
159
+
160
+ r = _run(
161
+ single_brain.handle_turn(
162
+ sess, "How good is the ReAssure plan's claim record?"
163
+ )
164
+ )
165
+
166
+ self.assertNotIn(
167
+ _CAVEAT,
168
+ r.reply_text,
169
+ "grounded get_policy_facts answer must NOT get the false "
170
+ "'could not be verified' caveat",
171
+ )
172
+ self.assertTrue(
173
+ r.faithfulness_passed,
174
+ "faithfulness_passed must be True for a grounded "
175
+ "get_policy_facts answer",
176
+ )
177
+ self.assertIn("ReAssure 3.0", r.reply_text)
178
+
179
+ def test_guard_still_flags_a_genuinely_ungrounded_uin(self):
180
+ """Control: FIX #23 must NOT weaken the guard. A UIN that
181
+ get_policy_facts did NOT return (and no retrieve_policies chunk
182
+ supports) must STILL trip the caveat."""
183
+ sess = self._session()
184
+ # get_policy_facts returns policy A with its own UIN…
185
+ self._gpf_result = {
186
+ "ok": True,
187
+ "count": 1,
188
+ "policies": [
189
+ {
190
+ "policy_id": "care__supreme",
191
+ "policy_name": "Care Supreme",
192
+ "insurer_slug": "care",
193
+ "uin_code": "CARHLIP21001V010001",
194
+ "claim_settlement_ratio_pct": 88.0,
195
+ }
196
+ ],
197
+ }
198
+ # …but the model invents a DIFFERENT, unreturned UIN in prose.
199
+ self._gemini_script = [
200
+ _tool_payload(
201
+ [
202
+ _fc_part(
203
+ "get_policy_facts",
204
+ {"policy_ids": ["care__supreme"]},
205
+ )
206
+ ]
207
+ ),
208
+ _text_payload(
209
+ "Care Supreme is great; also see plan UIN " + _UIN + " "
210
+ "which I recommend."
211
+ ),
212
+ ]
213
+
214
+ r = _run(
215
+ single_brain.handle_turn(sess, "Tell me about Care Supreme.")
216
+ )
217
+
218
+ self.assertIn(
219
+ _CAVEAT,
220
+ r.reply_text,
221
+ "a UIN that get_policy_facts did NOT return must still be "
222
+ "flagged β€” the guard must not be weakened",
223
+ )
224
+ self.assertFalse(r.faithfulness_passed)
225
+
226
+
227
+ if __name__ == "__main__":
228
+ unittest.main()
tests/test_tts_full_natural_readout.py CHANGED
@@ -209,17 +209,32 @@ class _FakeResp:
209
 
210
 
211
  class _FakeClient:
212
- """Fake Sarvam TTS endpoint.
213
-
214
- Records every chunk it is asked to synthesize and returns a WAV whose
215
- frame count == len(text) so the concatenated audio length is a direct
216
- proxy for 'how many characters were actually voiced'. Also asserts no
217
- single request exceeds Bulbul v2's hard 1500-char limit β€” a regression
218
- to single-shot (whole message in one call) fails LOUDLY here.
 
 
 
 
 
 
 
219
  """
220
 
221
  sent_texts: list[str] = []
222
- HARD_LIMIT = 1500
 
 
 
 
 
 
 
 
223
 
224
  def __init__(self, *a, **k):
225
  pass
@@ -232,12 +247,15 @@ class _FakeClient:
232
 
233
  async def post(self, url, headers=None, json=None, **k):
234
  text = json["text"]
235
- assert len(text) <= self.HARD_LIMIT, (
236
- f"chunk of {len(text)} chars exceeds Sarvam Bulbul v2's "
237
- f"1500-char hard limit β€” would be silently truncated"
238
- )
239
  _FakeClient.sent_texts.append(text)
240
- wav = _make_wav(max(1, len(text)))
 
 
 
 
 
 
 
241
  return _FakeResp({"audios": [base64.b64encode(wav).decode()]})
242
 
243
 
@@ -311,21 +329,123 @@ def test_end_to_end_overlong_message_chunks_and_concatenates(patch_httpx):
311
  assert re.sub(r"\s+", "", " ".join(_FakeClient.sent_texts)) == re.sub(
312
  r"\s+", "", spoken
313
  )
314
- # Gapless concatenated WAV: frames == sum of per-chunk frames.
 
 
315
  with wave.open(io.BytesIO(audio), "rb") as w:
316
  total = w.getnframes()
317
- assert total == sum(len(t) for t in _FakeClient.sent_texts)
 
 
318
 
319
 
320
  def test_short_reply_is_single_call_unchanged(patch_httpx):
321
- """A sub-ceiling reply must take exactly ONE Sarvam call (no behaviour
322
- change / no extra latency for the common case)."""
323
  spoken = tts_preprocess("Yes, that policy covers day-care procedures.")
 
324
  tts = SarvamTTS()
325
  asyncio.run(tts.synthesize_with_mime(spoken, language_code="en-IN"))
326
  assert len(_FakeClient.sent_texts) == 1, _FakeClient.sent_texts
327
 
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  def test_http_error_on_any_chunk_propagates_loudly(patch_httpx, monkeypatch):
330
  """No silent truncation: if any chunk's Sarvam call fails, the error
331
  propagates so the boundary classifier surfaces a real tts_error_code."""
 
209
 
210
 
211
  class _FakeClient:
212
+ """Fake Sarvam TTS endpoint that simulates REAL Bulbul v2 behaviour.
213
+
214
+ Records every chunk it is asked to synthesize. Returns a WAV whose
215
+ frame count == len(voiced text) so the concatenated audio length is a
216
+ direct proxy for 'how many characters were actually voiced'.
217
+
218
+ BUG #19 fidelity: Sarvam v2 does NOT 4xx on an over-limit request β€” it
219
+ returns HTTP 200 and voices ONLY THE FIRST SENTENCE of anything past
220
+ its real (small) per-request limit (the operative limit is far below
221
+ the 1500-char documented cap). This mirrors the STT test's hard-limit
222
+ simulation (tests/test_stt_long_audio_chunking.py SARVAM_HARD_LIMIT_MS).
223
+ So the fix MUST keep every single request at/under the effective
224
+ ceiling; if it doesn't, the audio frames here collapse to the first
225
+ sentence and the full-readout assertions fail LOUDLY.
226
  """
227
 
228
  sent_texts: list[str] = []
229
+ # The operative single-request limit. Anything longer is silently
230
+ # voiced as ONLY its first sentence (HTTP 200, partial audio).
231
+ EFFECTIVE_LIMIT = _tts_char_ceiling("bulbul:v2")
232
+ # Frames synthesized per voiced char. Picked > 0.030 * 22050 (= 661.5)
233
+ # so a FULLY-voiced chunk clears the provider's ~30ms/char truncation
234
+ # guard, while a first-sentence-only (silently truncated) chunk stays
235
+ # confidently under it and the guard fires LOUDLY β€” matching real
236
+ # Sarvam audio density (~45ms/char here).
237
+ FRAMES_PER_CHAR = 1000
238
 
239
  def __init__(self, *a, **k):
240
  pass
 
247
 
248
  async def post(self, url, headers=None, json=None, **k):
249
  text = json["text"]
 
 
 
 
250
  _FakeClient.sent_texts.append(text)
251
+ # Simulate Sarvam's silent first-sentence truncation: anything past
252
+ # the effective limit is voiced ONLY up to its first sentence end.
253
+ if len(text) > self.EFFECTIVE_LIMIT:
254
+ m = re.search(r"[.!?]\s", text)
255
+ voiced = text[: m.end()] if m else text[: self.EFFECTIVE_LIMIT]
256
+ else:
257
+ voiced = text
258
+ wav = _make_wav(max(1, len(voiced)) * self.FRAMES_PER_CHAR)
259
  return _FakeResp({"audios": [base64.b64encode(wav).decode()]})
260
 
261
 
 
329
  assert re.sub(r"\s+", "", " ".join(_FakeClient.sent_texts)) == re.sub(
330
  r"\s+", "", spoken
331
  )
332
+ # Gapless concatenated WAV: frames == sum of per-chunk frames (each
333
+ # chunk is <= the effective ceiling so it is FULLY voiced β€” the fake
334
+ # synthesizes FRAMES_PER_CHAR frames per voiced char).
335
  with wave.open(io.BytesIO(audio), "rb") as w:
336
  total = w.getnframes()
337
+ assert total == sum(
338
+ len(t) * _FakeClient.FRAMES_PER_CHAR for t in _FakeClient.sent_texts
339
+ )
340
 
341
 
342
  def test_short_reply_is_single_call_unchanged(patch_httpx):
343
+ """A genuinely short (<~300 char) reply must take exactly ONE Sarvam
344
+ call (no behaviour change / no extra latency for the common case)."""
345
  spoken = tts_preprocess("Yes, that policy covers day-care procedures.")
346
+ assert len(spoken) < 300, len(spoken)
347
  tts = SarvamTTS()
348
  asyncio.run(tts.synthesize_with_mime(spoken, language_code="en-IN"))
349
  assert len(_FakeClient.sent_texts) == 1, _FakeClient.sent_texts
350
 
351
 
352
+ # ---------------------------------------------------------------------------
353
+ # BUG #19 (2026-05-19) β€” the LIVE symptom: a ~700-char recommendation reply
354
+ # (preamble + 3 numbered policies + smoker caveat + closing question) was
355
+ # voiced as ONLY its first sentence ("Thanks for providing all those
356
+ # details, Rohit!", ~2s) because the code's 1500-char ceiling kept the
357
+ # sentence-seam chunker from EVER engaging on a normal reply. With v2's
358
+ # ceiling at 500 (effective ~300) it now splits into 2-3 chunks, each
359
+ # fully voiced and concatenated.
360
+ #
361
+ # This test FAILS against the old 1500 ceiling: the whole 700-char reply
362
+ # would be sent in ONE call, the fake (mirroring real Sarvam) voices only
363
+ # its first sentence, and (a) the per-chunk truncation guard in
364
+ # _synthesize_one raises, OR (b) the concatenated frame count collapses to
365
+ # the first sentence. It PASSES after change #1 (ceiling -> 500).
366
+ # ---------------------------------------------------------------------------
367
+ RECOMMENDATION_REPLY = (
368
+ "Thanks for providing all those details, Rohit! Here are three plans "
369
+ "that fit a non-smoker wanting comprehensive family floater cover.\n"
370
+ "1. HDFC ERGO Optima Secure: a base cover that doubles from year two, "
371
+ "with no room-rent cap and day-one cover for day-care procedures.\n"
372
+ "2. Care Supreme by Care Health: an unlimited automatic recharge, a "
373
+ "no-claim bonus, and worldwide emergency cover.\n"
374
+ "3. Niva Bupa ReAssure: a lock-the-clock feature that freezes your "
375
+ "entry age for pricing, plus a forever refill benefit.\n"
376
+ "One caveat: if you ever take up smoking or tobacco, declare it at "
377
+ "renewal, since a non-disclosure can void a future claim. "
378
+ "Would you like me to compare the maternity and OPD add-ons across "
379
+ "these three so you can pick the best fit for your family?"
380
+ )
381
+
382
+
383
+ def test_bug19_recommendation_reply_synthesized_in_full(patch_httpx):
384
+ """A realistic ~700-char recommendation reply must be voiced IN FULL:
385
+ >1 chunk, each <= the effective v2 ceiling, the decoded PCM
386
+ concatenated to ~full length, and the 3rd policy name + closing
387
+ question reaching Sarvam. This is RED against the old 1500 ceiling
388
+ (single over-limit call -> first-sentence-only audio / guard raises)
389
+ and GREEN after change #1."""
390
+ spoken = tts_preprocess(RECOMMENDATION_REPLY, language="en")
391
+ # Realistic recommendation length band (the live symptom was 370-720
392
+ # chars). Critically it is WELL under the OLD 1500 ceiling, so the
393
+ # pre-fix code sent it in ONE over-real-limit call -> first-sentence
394
+ # only audio. The first sentence is the exact ~2s clip the user heard.
395
+ assert 370 <= len(spoken) <= 760, len(spoken)
396
+ assert len(spoken) < 1500 # pre-fix code never chunked this reply
397
+
398
+ ceiling = _tts_char_ceiling("bulbul:v2")
399
+ assert ceiling < 1500 # the bug: old code never chunked under 1300
400
+
401
+ tts = SarvamTTS()
402
+ audio, mime = asyncio.run(
403
+ tts.synthesize_with_mime(spoken, language_code="en-IN")
404
+ )
405
+ assert mime == "audio/wav"
406
+
407
+ # The reply is split into MULTIPLE Sarvam calls (the bug sent ONE),
408
+ # each within the effective ceiling so the fake voices it in FULL.
409
+ assert len(_FakeClient.sent_texts) >= 2, _FakeClient.sent_texts
410
+ for t in _FakeClient.sent_texts:
411
+ assert len(t) <= ceiling, (
412
+ f"chunk of {len(t)} chars > effective ceiling {ceiling} β€” "
413
+ f"Sarvam would silently voice only its first sentence"
414
+ )
415
+
416
+ # Every char reached Sarvam (nothing dropped at the seams).
417
+ assert re.sub(r"\s+", "", " ".join(_FakeClient.sent_texts)) == re.sub(
418
+ r"\s+", "", spoken
419
+ )
420
+ # The 3rd policy name and the closing question β€” content the live bug
421
+ # NEVER voiced β€” must be present in the chunks actually sent.
422
+ joined_low = " ".join(_FakeClient.sent_texts).lower()
423
+ assert "niva bupa" in joined_low, _FakeClient.sent_texts
424
+ assert "reassure" in joined_low, _FakeClient.sent_texts
425
+ # "OPD" is expanded to "out-patient" by tts_preprocess; assert the
426
+ # closing-question content (the bug never voiced this) survived.
427
+ assert "maternity and out-patient" in joined_low, _FakeClient.sent_texts
428
+ assert "best fit for your family" in joined_low, _FakeClient.sent_texts
429
+
430
+ # The concatenated WAV is the FULL readout, not the ~2s first
431
+ # sentence: frames == sum of per-chunk fully-voiced frames, which is
432
+ # FAR above the first-sentence-only collapse the bug produced.
433
+ with wave.open(io.BytesIO(audio), "rb") as w:
434
+ total_frames = w.getnframes()
435
+ expected_frames = sum(
436
+ len(t) * _FakeClient.FRAMES_PER_CHAR for t in _FakeClient.sent_texts
437
+ )
438
+ assert total_frames == expected_frames, (total_frames, expected_frames)
439
+ first_sentence_frames = (
440
+ len(re.match(r"[^.!?]*[.!?]", spoken).group(0))
441
+ * _FakeClient.FRAMES_PER_CHAR
442
+ )
443
+ assert total_frames > 3 * first_sentence_frames, (
444
+ f"{total_frames} frames β€” collapsed toward the ~2s "
445
+ f"first-sentence-only clip the bug produced"
446
+ )
447
+
448
+
449
  def test_http_error_on_any_chunk_propagates_loudly(patch_httpx, monkeypatch):
450
  """No silent truncation: if any chunk's Sarvam call fails, the error
451
  propagates so the boundary classifier surfaces a real tts_error_code."""