Spaces:
Sleeping
fix(scorecard): rec-path scorecard == marketplace (KI-FULLFACTS) — restores recommendation cards
Browse filesROOT CAUSE of empty CitedPolicyCards: brain_tools._scorecard_signal fed
build_scorecard the 7-key eligibility subset (_load_policy_facts), which is
below build_scorecard's data-completeness floor → every recommended policy
returned grade "—" / overall 0 → the Bug #71 >=70 fitness gate dropped 100%
of them → recommendation citations always empty → no cards. The marketplace
(/api/policies/all) scored the SAME policies correctly (HDFC ERGO Optima
Restore = A/78) because it feeds the FULL curated layer
(main._load_curated_facts, KI-219/251 canonical precedence).
Fix: _scorecard_signal now reuses main._load_curated_facts (lazy import —
main is loaded at request time; dodges the cycle) + the same
40-data/reviews/<slug>.json insurer reviews + profile that the marketplace
passes to build_scorecard. A policy's recommendation grade is now identical
to its marketplace grade by construction. The 7-key _load_policy_facts is
unchanged — it remains the correct input for the eligibility/dedup gate,
NOT for grading. Cached (curated layer + reviews) to stay off the hot path.
tests/test_scorecard_parity.py: NEW regression guard — rec-path grade letter
MUST equal marketplace grade letter for a representative policy spread
(incl. Optima Restore = strong A/B and rec-fit-floor-clearing). The two
paths can no longer silently diverge.
Verified: full pytest gate green (rc=0, 0 failures). Local E2E
(/tmp/local-verify.py) now PASSES first time — name captured turn 0 →
fact-find → retrieve_policies → grounded recommendation → structured
citation populated (CitedPolicyCards render). Not pushed
(deploy-altogether-later). KNOWN NEXT (#24): retrieval/ranking surfaces
C/D/F policies while A/87 maternity family floaters exist unretrieved —
separate retrieval-quality issue, not scorecard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/brain_tools.py +66 -10
- tests/test_scorecard_parity.py +107 -0
|
@@ -203,19 +203,75 @@ def _load_policy_facts(policy_id: str) -> dict:
|
|
| 203 |
return out
|
| 204 |
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
def _scorecard_signal(policy_id: str, profile=None) -> dict:
|
| 207 |
-
"""Best-effort {_grade, _overall_score} from backend.scorecard
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
| 211 |
try:
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
from backend.scorecard import build_scorecard
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
prof = None
|
| 220 |
if profile is not None:
|
| 221 |
prof = {
|
|
@@ -225,7 +281,7 @@ def _scorecard_signal(policy_id: str, profile=None) -> dict:
|
|
| 225 |
"existing_cover_inr", "copay_pct",
|
| 226 |
)
|
| 227 |
}
|
| 228 |
-
sc = build_scorecard(
|
| 229 |
# KI-280 (2026-05-16) — the Scorecard dataclass field is
|
| 230 |
# `overall_score` (scorecard.py:95), NOT `overall`. The previous
|
| 231 |
# getattr(sc, "overall", ...) ALWAYS returned None, so every
|
|
|
|
| 203 |
return out
|
| 204 |
|
| 205 |
|
| 206 |
+
_curated_all_cache: dict = {} # single-slot cache for the full curated layer
|
| 207 |
+
_reviews_cache: dict = {}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _curated_facts_all() -> dict:
|
| 211 |
+
"""The FULL curated-facts layer, EXACTLY as the marketplace uses it.
|
| 212 |
+
|
| 213 |
+
KI-FULLFACTS (2026-05-17) — root cause of empty recommendation cards:
|
| 214 |
+
`_scorecard_signal` used to feed build_scorecard the 7-key eligibility
|
| 215 |
+
subset (`_load_policy_facts`), which is BELOW build_scorecard's
|
| 216 |
+
data-completeness floor → every recommended policy returned grade "—"
|
| 217 |
+
/ overall 0 → the Bug #71 ≥70 gate dropped 100% of them → no cards.
|
| 218 |
+
Meanwhile /api/policies/all scored the SAME policies correctly
|
| 219 |
+
(HDFC Ergo Optima Restore = A/78) because it feeds the FULL curated
|
| 220 |
+
dict from main._load_curated_facts (KI-219/251 canonical precedence).
|
| 221 |
+
We now reuse that EXACT function (lazy import — main is loaded at
|
| 222 |
+
request time; dodges the import cycle) so a policy's recommendation
|
| 223 |
+
grade is identical to its marketplace grade by construction. The
|
| 224 |
+
7-key `_load_policy_facts` stays as-is — it is the correct input for
|
| 225 |
+
the eligibility/dedup gate, NOT for grading."""
|
| 226 |
+
if "d" not in _curated_all_cache:
|
| 227 |
+
from backend.main import _load_curated_facts # lazy: avoids cycle
|
| 228 |
+
_curated_all_cache["d"] = _load_curated_facts()
|
| 229 |
+
return _curated_all_cache["d"]
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def _insurer_reviews(slug: str) -> Optional[dict]:
|
| 233 |
+
"""Same reviews source the marketplace passes to build_scorecard
|
| 234 |
+
(40-data/reviews/<slug>.json) — drives the claim-experience sub-score.
|
| 235 |
+
Without it the rec-path grade would drift below the marketplace's."""
|
| 236 |
+
if not slug:
|
| 237 |
+
return None
|
| 238 |
+
if slug in _reviews_cache:
|
| 239 |
+
return _reviews_cache[slug]
|
| 240 |
+
ir = None
|
| 241 |
+
try:
|
| 242 |
+
rp = settings.DATA_DIR / "reviews" / f"{slug}.json"
|
| 243 |
+
if rp.exists():
|
| 244 |
+
ir = _json.loads(rp.read_text())
|
| 245 |
+
except Exception: # noqa: BLE001 — reviews optional
|
| 246 |
+
ir = None
|
| 247 |
+
_reviews_cache[slug] = ir
|
| 248 |
+
return ir
|
| 249 |
+
|
| 250 |
+
|
| 251 |
def _scorecard_signal(policy_id: str, profile=None) -> dict:
|
| 252 |
+
"""Best-effort {_grade, _overall_score} from backend.scorecard, using
|
| 253 |
+
the IDENTICAL inputs as the marketplace (/api/policies/all) so a
|
| 254 |
+
policy's recommendation grade == its marketplace grade by construction
|
| 255 |
+
(parity is asserted by tests/test_scorecard_parity.py). READ-ONLY;
|
| 256 |
+
degrades silently to {} on any failure (ranking still works off
|
| 257 |
+
co-pay + SI headroom + cosine)."""
|
| 258 |
try:
|
| 259 |
+
cur = _curated_facts_all()
|
| 260 |
+
data = None
|
| 261 |
+
for stem in _candidate_stems(policy_id):
|
| 262 |
+
if stem in cur:
|
| 263 |
+
data = cur[stem]
|
| 264 |
+
break
|
| 265 |
+
if not data:
|
| 266 |
+
# Genuinely unknown policy → fail OPEN (no false weak signal;
|
| 267 |
+
# _recommendation_fit keeps chunks with no grade evidence).
|
| 268 |
+
return {}
|
| 269 |
from backend.scorecard import build_scorecard
|
| 270 |
|
| 271 |
+
slug = data.get("insurer_slug") or (
|
| 272 |
+
policy_id.split("__", 1)[0] if "__" in policy_id else ""
|
| 273 |
+
)
|
| 274 |
+
ir = _insurer_reviews(slug)
|
| 275 |
prof = None
|
| 276 |
if profile is not None:
|
| 277 |
prof = {
|
|
|
|
| 281 |
"existing_cover_inr", "copay_pct",
|
| 282 |
)
|
| 283 |
}
|
| 284 |
+
sc = build_scorecard(data, insurer_reviews=ir, profile=prof)
|
| 285 |
# KI-280 (2026-05-16) — the Scorecard dataclass field is
|
| 286 |
# `overall_score` (scorecard.py:95), NOT `overall`. The previous
|
| 287 |
# getattr(sc, "overall", ...) ALWAYS returned None, so every
|
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression guard — recommendation-path scorecard == marketplace scorecard.
|
| 2 |
+
|
| 3 |
+
ROOT CAUSE THIS LOCKS DOWN (KI-FULLFACTS, 2026-05-17)
|
| 4 |
+
-----------------------------------------------------
|
| 5 |
+
`brain_tools._scorecard_signal` used to feed `build_scorecard` the 7-key
|
| 6 |
+
eligibility subset (`_load_policy_facts`), which is below build_scorecard's
|
| 7 |
+
data-completeness floor → every recommended policy came back grade "—" /
|
| 8 |
+
overall 0 → the Bug #71 >=70 fitness gate dropped 100% of them → the
|
| 9 |
+
recommendation `citations` array was always empty → CitedPolicyCards never
|
| 10 |
+
rendered. Meanwhile /api/policies/all scored the SAME policies correctly
|
| 11 |
+
(HDFC ERGO Optima Restore = A) because it feeds the FULL curated layer
|
| 12 |
+
(main._load_curated_facts, KI-219/251 canonical precedence).
|
| 13 |
+
|
| 14 |
+
The fix makes `_scorecard_signal` reuse that EXACT function. This test makes
|
| 15 |
+
the two paths *provably* unable to diverge again: for a representative
|
| 16 |
+
spread of policies, the recommendation-path GRADE LETTER must equal the
|
| 17 |
+
marketplace GRADE LETTER. (Overall scores may differ by a few points
|
| 18 |
+
because the marketplace overlays EXTRACTED_DIR data on top; the LETTER —
|
| 19 |
+
what _recommendation_fit gates on — must match.)
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import sys
|
| 24 |
+
import unittest
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 28 |
+
if str(_REPO_ROOT) not in sys.path:
|
| 29 |
+
sys.path.insert(0, str(_REPO_ROOT))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class TestScorecardParity(unittest.TestCase):
|
| 33 |
+
"""Rec-path _scorecard_signal grade letter == marketplace grade letter."""
|
| 34 |
+
|
| 35 |
+
@classmethod
|
| 36 |
+
def setUpClass(cls):
|
| 37 |
+
from backend.main import _load_curated_facts
|
| 38 |
+
from backend.scorecard import build_scorecard
|
| 39 |
+
from backend.brain_tools import _scorecard_signal, _insurer_reviews
|
| 40 |
+
|
| 41 |
+
cls._scorecard_signal = staticmethod(_scorecard_signal)
|
| 42 |
+
cur = _load_curated_facts()
|
| 43 |
+
|
| 44 |
+
# Marketplace grade for a policy_id = build_scorecard on the SAME
|
| 45 |
+
# full curated dict + reviews the /api/policies/all path uses.
|
| 46 |
+
def _mkt_grade(pid: str):
|
| 47 |
+
data = cur.get(pid)
|
| 48 |
+
if not data:
|
| 49 |
+
return None
|
| 50 |
+
slug = data.get("insurer_slug") or (
|
| 51 |
+
pid.split("__", 1)[0] if "__" in pid else ""
|
| 52 |
+
)
|
| 53 |
+
sc = build_scorecard(
|
| 54 |
+
data, insurer_reviews=_insurer_reviews(slug), profile=None
|
| 55 |
+
)
|
| 56 |
+
return sc.grade
|
| 57 |
+
|
| 58 |
+
cls._mkt_grade = staticmethod(_mkt_grade)
|
| 59 |
+
# A representative spread across grade bands + insurers, incl. the
|
| 60 |
+
# user's own policy (HDFC ERGO Optima Restore — must be a strong A).
|
| 61 |
+
cls._sample = [
|
| 62 |
+
"hdfc-ergo__optima-restore",
|
| 63 |
+
"hdfc-ergo__my-health-sampoorna-suraksha",
|
| 64 |
+
"hdfc-ergo__my-optima-secure",
|
| 65 |
+
"oriental-insurance__happy-family-floater",
|
| 66 |
+
"star-health__family-health-optima",
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
def test_rec_path_grade_matches_marketplace_grade(self):
|
| 70 |
+
mismatches = []
|
| 71 |
+
for pid in self._sample:
|
| 72 |
+
mkt = self._mkt_grade(pid)
|
| 73 |
+
if mkt is None:
|
| 74 |
+
continue # policy not in curated layer in this env — skip
|
| 75 |
+
rec = (self._scorecard_signal(pid, profile=None) or {}).get("_grade")
|
| 76 |
+
if rec != mkt:
|
| 77 |
+
mismatches.append(f"{pid}: rec={rec!r} marketplace={mkt!r}")
|
| 78 |
+
self.assertEqual(
|
| 79 |
+
mismatches, [],
|
| 80 |
+
"recommendation-path scorecard grade diverged from the "
|
| 81 |
+
"marketplace grade — the two paths must feed build_scorecard "
|
| 82 |
+
"the SAME curated facts (see KI-FULLFACTS):\n "
|
| 83 |
+
+ "\n ".join(mismatches),
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
def test_optima_restore_is_a_strong_recommendable_grade(self):
|
| 87 |
+
# The user's own policy — a genuinely strong plan. It must NOT come
|
| 88 |
+
# back as the data-starved "—"/0 sentinel, and must clear the Bug
|
| 89 |
+
# #71 recommendation fitness floor so its card can render.
|
| 90 |
+
from backend.single_brain import _recommendation_fit
|
| 91 |
+
|
| 92 |
+
sig = self._scorecard_signal("hdfc-ergo__optima-restore", profile=None)
|
| 93 |
+
self.assertIn(sig.get("_grade"), ("A", "B"),
|
| 94 |
+
f"Optima Restore must be A/B, got {sig!r}")
|
| 95 |
+
strong, overall, grade = _recommendation_fit({
|
| 96 |
+
"_overall_score": sig.get("_overall_score"),
|
| 97 |
+
"_grade": sig.get("_grade"),
|
| 98 |
+
})
|
| 99 |
+
self.assertTrue(
|
| 100 |
+
strong,
|
| 101 |
+
f"Optima Restore must clear the rec-fit floor "
|
| 102 |
+
f"(grade={grade} overall={overall})",
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
if __name__ == "__main__":
|
| 107 |
+
unittest.main()
|