rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
ba47227
Β·
1 Parent(s): 1a7161d

#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 CHANGED
@@ -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
  }
backend/single_brain.py CHANGED
@@ -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, [
tests/test_bug4143_fixes.py ADDED
@@ -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()