Spaces:
Sleeping
fix(batch): voice interim, TTS truncation, RULE2.5 re-ask, faithfulness regression, markdown, compare-reviews, policy-name
Browse filesEight 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 +13 -4
- backend/main.py +24 -8
- backend/policy_identity.py +71 -1
- backend/providers/sarvam_tts.py +40 -5
- backend/scorecard.py +8 -1
- backend/single_brain.py +74 -0
- frontend/src/app/page.tsx +379 -15
- frontend/src/components/PolicyCompareModal.tsx +25 -0
- tests/test_bug24_display_name_cleanup.py +166 -0
- tests/test_get_policy_facts_grounding.py +228 -0
- tests/test_tts_full_natural_readout.py +137 -17
|
@@ -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":
|
|
|
|
|
|
|
| 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":
|
|
|
|
|
|
|
| 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":
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 {}
|
|
@@ -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=
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 = (
|
| 3336 |
-
|
|
|
|
|
|
|
| 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=
|
|
|
|
|
|
|
| 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=
|
|
|
|
|
|
|
| 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=
|
|
|
|
|
|
|
| 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(
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
]
|
|
@@ -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":
|
| 55 |
-
"bulbul:v3":
|
| 56 |
}
|
| 57 |
-
_TTS_CHAR_LIMIT_DEFAULT =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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 |
|
|
@@ -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 |
-
//
|
| 1050 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1051 |
let interim = "";
|
| 1052 |
-
let
|
| 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)
|
| 1058 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1059 |
}
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
|
|
|
|
|
|
|
| 1063 |
const { text: prevText, at: prevAt } = lastFinalTextRef.current;
|
| 1064 |
const now = Date.now();
|
| 1065 |
-
if (
|
| 1066 |
-
|
| 1067 |
-
lastFinalTextRef.current = { text: trimmedFinal, at: now };
|
| 1068 |
}
|
|
|
|
|
|
|
|
|
|
| 1069 |
}
|
| 1070 |
-
const display =
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1086 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
)}
|
|
@@ -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>
|
|
@@ -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()
|
|
@@ -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()
|
|
@@ -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
|
| 215 |
-
frame count == len(text) so the concatenated audio length is a
|
| 216 |
-
proxy for 'how many characters were actually voiced'.
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
"""
|
| 220 |
|
| 221 |
sent_texts: list[str] = []
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 318 |
|
| 319 |
|
| 320 |
def test_short_reply_is_single_call_unchanged(patch_httpx):
|
| 321 |
-
"""A
|
| 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."""
|