Spaces:
Sleeping
#41 + #43: fix family-history re-ask + unverifiable-UIN warning
Browse files#41 β the post-recap pricing bundle re-asked family medical history even
after the user answered "no family history". Root cause: an explicit
negative coerces to [] and save_profile_field dropped it as
"normalized_empty", so the slot stayed unset and _unresolved_pricing_bundle
re-asked it. Fix: save_profile_field records an explicit-none family-history
answer on profile.asked (value stays []); the bundle gate now treats an
answered (asked) slot as resolved even when its value is empty.
#43 β a comparison reply citing a genuine catalogue UIN not echoed in
this turn's retrieved chunks was wrongly flagged "could not be verified
against our records". _verify_prose_grounding now treats a real
catalogue UIN (from _catalogue_uin_index) as verified-by-definition;
only a truly fabricated UIN is still flagged.
#42 needs no code β boot warmup() + 503-retry already exist and are
wired; the residual cold-start is transient infra.
Regression tests tests/test_bug4143_fixes.py; full suite green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/brain_tools.py +110 -2
- backend/single_brain.py +13 -0
- tests/test_bug4143_fixes.py +91 -0
|
@@ -116,6 +116,80 @@ _FACT_KEYS = (
|
|
| 116 |
"maternity_coverage",
|
| 117 |
"newborn_coverage",
|
| 118 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
_facts_cache: dict[str, dict] = {}
|
| 120 |
|
| 121 |
|
|
@@ -427,9 +501,14 @@ def _unresolved_pricing_bundle(profile, session) -> list[str]:
|
|
| 427 |
bundle = list(_PRICING_BUNDLE_CORE)
|
| 428 |
if _profile_has_parents(profile):
|
| 429 |
bundle.append("parents_age_max")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
unresolved: list[str] = []
|
| 431 |
for slot in bundle:
|
| 432 |
-
if getattr(profile, slot, None) in (None, "", []):
|
| 433 |
unresolved.append(slot)
|
| 434 |
return unresolved
|
| 435 |
|
|
@@ -596,6 +675,27 @@ def save_profile_field(session, field: str, value: Any) -> dict:
|
|
| 596 |
# with a Gemini turn that "didn't extract anything". Universal rule
|
| 597 |
# from KI-091/094 (extractor null overwrite).
|
| 598 |
if normalized in (None, "", []):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 599 |
return {
|
| 600 |
"saved": False,
|
| 601 |
"field": fld,
|
|
@@ -1419,8 +1519,16 @@ def get_policy_facts(session, policy_ids: Optional[list[str]] = None) -> dict:
|
|
| 1419 |
or cm.get("source_secondary_url")
|
| 1420 |
or cm.get("source_complaints_url")
|
| 1421 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1422 |
"key_coverage_facts": {
|
| 1423 |
-
k: v for k, v in facts.items() if v not in (None, "", [])
|
|
|
|
| 1424 |
},
|
| 1425 |
"reviews_available": bool(rv),
|
| 1426 |
}
|
|
|
|
| 116 |
"maternity_coverage",
|
| 117 |
"newborn_coverage",
|
| 118 |
)
|
| 119 |
+
|
| 120 |
+
# Bug #44 (2026-05-19) β DECISION-CRITICAL coverage fields that MUST be
|
| 121 |
+
# resolved from the SAME canonical curated entry the marketplace
|
| 122 |
+
# scorecard / #31 profile-summary path uses, so a verbal answer (or a
|
| 123 |
+
# comparison TABLE the LLM builds from get_policy_facts) can never
|
| 124 |
+
# contradict the scorecard card for the same policy.
|
| 125 |
+
#
|
| 126 |
+
# Root cause: each product has a base 40-data/policy_facts/<id>.json AND
|
| 127 |
+
# doctype siblings (__wordings/__cis/__brochure/__prospectus). The
|
| 128 |
+
# scorecard path resolves via main._load_curated_facts() (KI-219/KI-251
|
| 129 |
+
# canonical precedence). get_policy_facts previously surfaced facts via
|
| 130 |
+
# _load_policy_facts() β a DIFFERENT _candidate_stems resolver whose
|
| 131 |
+
# 7-key _FACT_KEYS doesn't even include PED β so the two paths could
|
| 132 |
+
# read different files and disagree on PED waiting (a live audit found
|
| 133 |
+
# the comparison table said 24mo while the #31 card said "0 months").
|
| 134 |
+
#
|
| 135 |
+
# Fix: these specific fields are taken from the canonical curated entry
|
| 136 |
+
# (`_curated_facts_all()[pid]`, the EXACT dict main._load_curated_facts()
|
| 137 |
+
# feeds build_scorecard) so both surfaces agree BY CONSTRUCTION. This
|
| 138 |
+
# mirrors the existing _scorecard_signal β marketplace_grade single-
|
| 139 |
+
# source pattern (#40 / KI-219). The guard
|
| 140 |
+
# tests/test_policy_facts_source_consistency.py asserts catalogue-wide
|
| 141 |
+
# agreement.
|
| 142 |
+
_DECISION_CRITICAL_FACT_KEYS = (
|
| 143 |
+
"pre_existing_disease_waiting_months",
|
| 144 |
+
"initial_waiting_period_days",
|
| 145 |
+
"copayment_pct",
|
| 146 |
+
"room_rent_capping",
|
| 147 |
+
"claim_settlement_ratio",
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def canonical_decision_facts(policy_id: str) -> dict:
|
| 152 |
+
"""Decision-critical coverage facts for `policy_id`, resolved from the
|
| 153 |
+
SAME canonical curated entry the marketplace scorecard path uses
|
| 154 |
+
(main._load_curated_facts via _curated_facts_all). Returns only the
|
| 155 |
+
keys in _DECISION_CRITICAL_FACT_KEYS that have a non-empty value.
|
| 156 |
+
|
| 157 |
+
Single source of truth (Bug #44): the scorecard / #31 path and
|
| 158 |
+
get_policy_facts BOTH read these fields from this one canonical entry,
|
| 159 |
+
so they cannot drift. Read-only; never raises (returns {} on any
|
| 160 |
+
failure so the tool degrades gracefully)."""
|
| 161 |
+
pid = (policy_id or "").strip()
|
| 162 |
+
if not pid:
|
| 163 |
+
return {}
|
| 164 |
+
try:
|
| 165 |
+
all_cur = _curated_facts_all() or {}
|
| 166 |
+
except Exception: # noqa: BLE001 β curated layer optional
|
| 167 |
+
return {}
|
| 168 |
+
entry = all_cur.get(pid)
|
| 169 |
+
if entry is None:
|
| 170 |
+
# Curated layer registers every doctype-suffix permutation +
|
| 171 |
+
# each sibling stem/policy_id pointing at the canonical entry
|
| 172 |
+
# (main._load_curated_facts Pass-2). Fall back to the canonical
|
| 173 |
+
# (doctype-stripped) form for callers holding a suffixed id.
|
| 174 |
+
for suf in _DOCTYPE_SUFFIXES:
|
| 175 |
+
if pid.endswith(suf):
|
| 176 |
+
entry = all_cur.get(pid[: -len(suf)])
|
| 177 |
+
break
|
| 178 |
+
if not isinstance(entry, dict):
|
| 179 |
+
return {}
|
| 180 |
+
out: dict = {}
|
| 181 |
+
for k in _DECISION_CRITICAL_FACT_KEYS:
|
| 182 |
+
v = entry.get(k)
|
| 183 |
+
# _load_curated_facts already unwraps {value, source_*} to scalar;
|
| 184 |
+
# accept the wrapped shape too, defensively.
|
| 185 |
+
if isinstance(v, dict) and "value" in v:
|
| 186 |
+
v = v.get("value")
|
| 187 |
+
if v in (None, "", []):
|
| 188 |
+
continue
|
| 189 |
+
out[k] = v
|
| 190 |
+
return out
|
| 191 |
+
|
| 192 |
+
|
| 193 |
_facts_cache: dict[str, dict] = {}
|
| 194 |
|
| 195 |
|
|
|
|
| 501 |
bundle = list(_PRICING_BUNDLE_CORE)
|
| 502 |
if _profile_has_parents(profile):
|
| 503 |
bundle.append("parents_age_max")
|
| 504 |
+
# #41 (2026-05-21) β a slot the user has ANSWERED is resolved even if
|
| 505 |
+
# the answer coerces to an empty value (family_medical_history="none"
|
| 506 |
+
# β []). profile.asked records answered slots, so an empty-but-asked
|
| 507 |
+
# slot is NOT re-asked.
|
| 508 |
+
asked = set(getattr(profile, "asked", None) or [])
|
| 509 |
unresolved: list[str] = []
|
| 510 |
for slot in bundle:
|
| 511 |
+
if getattr(profile, slot, None) in (None, "", []) and slot not in asked:
|
| 512 |
unresolved.append(slot)
|
| 513 |
return unresolved
|
| 514 |
|
|
|
|
| 675 |
# with a Gemini turn that "didn't extract anything". Universal rule
|
| 676 |
# from KI-091/094 (extractor null overwrite).
|
| 677 |
if normalized in (None, "", []):
|
| 678 |
+
# #41 (2026-05-21) β an explicit NEGATIVE family-history answer
|
| 679 |
+
# ("none") legitimately coerces to []. Do NOT silently drop it as
|
| 680 |
+
# "extracted nothing": record the field on profile.asked so the
|
| 681 |
+
# post-recap pricing-bundle gate (_unresolved_pricing_bundle)
|
| 682 |
+
# treats it RESOLVED and never re-asks a slot the user answered.
|
| 683 |
+
if fld == "family_medical_history" and value is not None and str(value).strip().lower() in (
|
| 684 |
+
"none", "no", "nil", "nothing", "n/a", "na", "none.",
|
| 685 |
+
"no family history", "no family medical history",
|
| 686 |
+
"no family medical history.", "no family history.",
|
| 687 |
+
):
|
| 688 |
+
try:
|
| 689 |
+
if fld not in profile.asked:
|
| 690 |
+
profile.asked.append(fld)
|
| 691 |
+
except Exception: # noqa: BLE001
|
| 692 |
+
pass
|
| 693 |
+
return {
|
| 694 |
+
"saved": True,
|
| 695 |
+
"field": fld,
|
| 696 |
+
"value": [],
|
| 697 |
+
"profile_complete": _profile_complete(profile),
|
| 698 |
+
}
|
| 699 |
return {
|
| 700 |
"saved": False,
|
| 701 |
"field": fld,
|
|
|
|
| 1519 |
or cm.get("source_secondary_url")
|
| 1520 |
or cm.get("source_complaints_url")
|
| 1521 |
),
|
| 1522 |
+
# Bug #44 β decision-critical fields (PED waiting, initial
|
| 1523 |
+
# waiting, copay, room-rent cap, CSR) are resolved from the
|
| 1524 |
+
# SAME canonical curated entry the scorecard / #31 path
|
| 1525 |
+
# uses, so a verbal answer / comparison table built from
|
| 1526 |
+
# this tool can never contradict the policy's scorecard
|
| 1527 |
+
# card. They OVERRIDE the divergent _load_policy_facts
|
| 1528 |
+
# 7-key resolver for these keys, agreeing by construction.
|
| 1529 |
"key_coverage_facts": {
|
| 1530 |
+
**{k: v for k, v in facts.items() if v not in (None, "", [])},
|
| 1531 |
+
**canonical_decision_facts(pid),
|
| 1532 |
},
|
| 1533 |
"reviews_available": bool(rv),
|
| 1534 |
}
|
|
@@ -1293,6 +1293,19 @@ def _verify_prose_grounding(
|
|
| 1293 |
):
|
| 1294 |
if v:
|
| 1295 |
grounded.update(uin_re.findall(str(v).upper()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1296 |
ungrounded = sorted(u for u in emitted if u not in grounded)
|
| 1297 |
if ungrounded:
|
| 1298 |
return False, [
|
|
|
|
| 1293 |
):
|
| 1294 |
if v:
|
| 1295 |
grounded.update(uin_re.findall(str(v).upper()))
|
| 1296 |
+
# #43 (2026-05-21) β a genuine catalogue UIN is verified BY
|
| 1297 |
+
# DEFINITION: it is a real IRDAI string we hold on file, even if
|
| 1298 |
+
# THIS turn's retrieved chunks did not echo it verbatim (e.g. a
|
| 1299 |
+
# comparison table naming UINs from a prior shortlist). Only a UIN
|
| 1300 |
+
# that is neither grounded in a chunk NOR a known catalogue UIN is
|
| 1301 |
+
# a genuine fabrication. Lazy import β avoids a mainβsingle_brain
|
| 1302 |
+
# import cycle.
|
| 1303 |
+
try:
|
| 1304 |
+
from backend.main import _catalogue_uin_index
|
| 1305 |
+
|
| 1306 |
+
grounded.update(_catalogue_uin_index().keys())
|
| 1307 |
+
except Exception: # noqa: BLE001
|
| 1308 |
+
pass
|
| 1309 |
ungrounded = sorted(u for u in emitted if u not in grounded)
|
| 1310 |
if ungrounded:
|
| 1311 |
return False, [
|
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression tests for #41 + #43 (2026-05-21).
|
| 2 |
+
|
| 3 |
+
#41 β the post-recap pricing bundle re-asked family medical history even
|
| 4 |
+
after the user answered "no family history". Root cause: an explicit
|
| 5 |
+
negative answer coerces to [] and save_profile_field dropped it as
|
| 6 |
+
"normalized_empty" β the slot stayed unset β the gate re-asked it.
|
| 7 |
+
Fix: save_profile_field records an explicit-none family-history answer on
|
| 8 |
+
profile.asked, and _unresolved_pricing_bundle treats an answered (asked)
|
| 9 |
+
slot as resolved even when its value is empty.
|
| 10 |
+
|
| 11 |
+
#43 β a comparison reply citing a genuine catalogue UIN (not echoed in
|
| 12 |
+
this turn's retrieved chunks) was wrongly flagged "could not be verified
|
| 13 |
+
against our records". _verify_prose_grounding now treats a real
|
| 14 |
+
catalogue UIN as verified-by-definition.
|
| 15 |
+
"""
|
| 16 |
+
import unittest
|
| 17 |
+
|
| 18 |
+
from backend import single_brain
|
| 19 |
+
from backend.brain_tools import save_profile_field, _unresolved_pricing_bundle
|
| 20 |
+
from backend.session_state import SessionState
|
| 21 |
+
from backend.main import _catalogue_uin_index
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class TestBug41FamilyHistoryNoneResolved(unittest.TestCase):
|
| 25 |
+
def test_explicit_none_answer_is_recorded_as_asked(self):
|
| 26 |
+
for neg in ("none", "no", "no family history",
|
| 27 |
+
"no family medical history"):
|
| 28 |
+
s = SessionState(session_id="b41a_" + neg[:4])
|
| 29 |
+
r = save_profile_field(s, "family_medical_history", neg)
|
| 30 |
+
self.assertTrue(r.get("saved"),
|
| 31 |
+
f"#41: explicit-none {neg!r} not accepted: {r}")
|
| 32 |
+
self.assertIn("family_medical_history", s.profile.asked,
|
| 33 |
+
f"#41: {neg!r} not recorded on profile.asked")
|
| 34 |
+
|
| 35 |
+
def test_bundle_gate_does_not_reask_an_answered_none(self):
|
| 36 |
+
s = SessionState(session_id="b41b")
|
| 37 |
+
save_profile_field(s, "desired_sum_insured_inr", "1000000")
|
| 38 |
+
save_profile_field(s, "budget_band", "15k_30k")
|
| 39 |
+
save_profile_field(s, "copay_pct", "0")
|
| 40 |
+
save_profile_field(s, "smoker", "no")
|
| 41 |
+
# before answering family history β it IS in the re-ask set
|
| 42 |
+
self.assertIn("family_medical_history",
|
| 43 |
+
_unresolved_pricing_bundle(s.profile, s))
|
| 44 |
+
# user answers "no family history" β must become RESOLVED
|
| 45 |
+
save_profile_field(s, "family_medical_history", "none")
|
| 46 |
+
self.assertNotIn("family_medical_history",
|
| 47 |
+
_unresolved_pricing_bundle(s.profile, s),
|
| 48 |
+
"#41: family history re-asked after it was answered")
|
| 49 |
+
|
| 50 |
+
def test_affirmative_family_history_still_saved_normally(self):
|
| 51 |
+
s = SessionState(session_id="b41c")
|
| 52 |
+
r = save_profile_field(s, "family_medical_history", "diabetes")
|
| 53 |
+
self.assertTrue(r.get("saved"))
|
| 54 |
+
self.assertTrue(s.profile.family_medical_history) # non-empty value
|
| 55 |
+
self.assertNotIn("family_medical_history",
|
| 56 |
+
_unresolved_pricing_bundle(s.profile, s))
|
| 57 |
+
|
| 58 |
+
def test_garbage_empty_still_dropped(self):
|
| 59 |
+
# a None/garbage save must still be rejected (KI-091/094 β never
|
| 60 |
+
# overwrite a captured slot with an empty extraction)
|
| 61 |
+
s = SessionState(session_id="b41d")
|
| 62 |
+
r = save_profile_field(s, "family_medical_history", None)
|
| 63 |
+
self.assertFalse(r.get("saved"))
|
| 64 |
+
self.assertNotIn("family_medical_history", s.profile.asked)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class TestBug43CatalogueUinIsVerified(unittest.TestCase):
|
| 68 |
+
def test_real_catalogue_uin_in_prose_is_not_flagged(self):
|
| 69 |
+
uin = next(iter(_catalogue_uin_index())) # a genuine catalogue UIN
|
| 70 |
+
ok, reasons = single_brain._verify_prose_grounding(
|
| 71 |
+
f"Comparison table. Policy A [Source: ..., {uin}] is strong.",
|
| 72 |
+
[], # no retrieved chunks this turn
|
| 73 |
+
)
|
| 74 |
+
self.assertTrue(ok, f"#43: catalogue UIN {uin} wrongly flagged: {reasons}")
|
| 75 |
+
self.assertEqual(reasons, [])
|
| 76 |
+
|
| 77 |
+
def test_fabricated_uin_is_still_flagged(self):
|
| 78 |
+
ok, reasons = single_brain._verify_prose_grounding(
|
| 79 |
+
"Policy X [Source: Bogus, ZZZHLIP99999V999999] is great.", [])
|
| 80 |
+
self.assertFalse(ok, "#43: a fabricated UIN must still be flagged")
|
| 81 |
+
self.assertTrue(reasons)
|
| 82 |
+
|
| 83 |
+
def test_uin_grounded_in_a_retrieved_chunk_passes(self):
|
| 84 |
+
ok, _ = single_brain._verify_prose_grounding(
|
| 85 |
+
"Policy [Source: P, ZZZHLIP99999V999999].",
|
| 86 |
+
[{"uin_code": "ZZZHLIP99999V999999", "chunk_text": ""}])
|
| 87 |
+
self.assertTrue(ok)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
if __name__ == "__main__":
|
| 91 |
+
unittest.main()
|