Spaces:
Sleeping
fix(#60+#62): bulk-scorecard curated fallback + stop internal scoring jargon leaking
Browse files#60 REGRESSION: /api/scorecard/bulk hard-required rag/extracted/<pid>.json
and emitted the N/A 'No extraction available' / 'Data not indexed'
sentinel for curated-only catalogued products (star-health__star-
comprehensive, UIN SHAHLIP26044V092526) β the broken-card defect the
user re-reported. Mirrored the curated fallback the marketplace +
single /api/scorecard endpoint already use (policies_all Pass-2). Star
Comprehensive now grades A 76 instead of N/A.
#62: scorecard.py hardcoded the user-facing signal as
'lifelong renewable (IRDAI norm β not scored)' β internal scoring
scaffolding leaking into the card UI. Now 'Lifelong renewability
guaranteed'. Test updated to assert clean copy + no jargon leak.
Full pytest gate green (rc=0).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/main.py +37 -14
- backend/scorecard.py +1 -1
- tests/test_scorecard_lifelong.py +7 -1
|
@@ -3850,7 +3850,43 @@ async def scorecard_bulk(req: BulkScorecardRequest):
|
|
| 3850 |
|
| 3851 |
for pid in req.policy_ids:
|
| 3852 |
extracted_path = settings.EXTRACTED_DIR / f"{pid}.json"
|
| 3853 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3854 |
out[pid] = BulkScorecardEntry(
|
| 3855 |
policy_id=pid,
|
| 3856 |
policy_name=pid,
|
|
@@ -3864,19 +3900,6 @@ async def scorecard_bulk(req: BulkScorecardRequest):
|
|
| 3864 |
signals={},
|
| 3865 |
)
|
| 3866 |
continue
|
| 3867 |
-
try:
|
| 3868 |
-
policy = _json.loads(extracted_path.read_text())
|
| 3869 |
-
except Exception as e:
|
| 3870 |
-
out[pid] = BulkScorecardEntry(
|
| 3871 |
-
policy_id=pid, policy_name=pid, insurer_slug="?",
|
| 3872 |
-
overall_grade="N/A", overall_score=0, sub_scores={},
|
| 3873 |
-
profile_rationale=[f"Data unreadable: {e}"],
|
| 3874 |
-
data_completeness_pct=0.0,
|
| 3875 |
-
one_liner="Extraction file is corrupted.",
|
| 3876 |
-
signals={},
|
| 3877 |
-
)
|
| 3878 |
-
continue
|
| 3879 |
-
policy = _merge_curated(policy, _curated.get(policy.get("policy_id", pid)) or _curated.get(pid))
|
| 3880 |
|
| 3881 |
slug = policy.get("insurer_slug") or "?"
|
| 3882 |
if slug not in insurer_cache:
|
|
|
|
| 3850 |
|
| 3851 |
for pid in req.policy_ids:
|
| 3852 |
extracted_path = settings.EXTRACTED_DIR / f"{pid}.json"
|
| 3853 |
+
policy = None
|
| 3854 |
+
if extracted_path.exists():
|
| 3855 |
+
try:
|
| 3856 |
+
policy = _json.loads(extracted_path.read_text())
|
| 3857 |
+
except Exception as e:
|
| 3858 |
+
out[pid] = BulkScorecardEntry(
|
| 3859 |
+
policy_id=pid, policy_name=pid, insurer_slug="?",
|
| 3860 |
+
overall_grade="N/A", overall_score=0, sub_scores={},
|
| 3861 |
+
profile_rationale=[f"Data unreadable: {e}"],
|
| 3862 |
+
data_completeness_pct=0.0,
|
| 3863 |
+
one_liner="Extraction file is corrupted.",
|
| 3864 |
+
signals={},
|
| 3865 |
+
)
|
| 3866 |
+
continue
|
| 3867 |
+
policy = _merge_curated(
|
| 3868 |
+
policy, _curated.get(policy.get("policy_id", pid)) or _curated.get(pid)
|
| 3869 |
+
)
|
| 3870 |
+
else:
|
| 3871 |
+
# ROOT-CAUSE FIX #60 (2026-05-18): curated-only catalogued
|
| 3872 |
+
# products (e.g. star-health__star-comprehensive, UIN
|
| 3873 |
+
# SHAHLIP26044V092526) have NO rag/extracted/<pid>.json β only
|
| 3874 |
+
# doctype-suffixed extractions. The marketplace + the single
|
| 3875 |
+
# /api/scorecard endpoint already resolve these from the curated
|
| 3876 |
+
# layer (policies_all Pass-2 / lines ~3617). The BULK endpoint
|
| 3877 |
+
# did not, so it emitted the N/A "No extraction available" /
|
| 3878 |
+
# "Data not indexed" sentinel β the broken-card defect the user
|
| 3879 |
+
# saw for Star Comprehensive. Mirror that curated fallback here;
|
| 3880 |
+
# the curated dict also carries doctype-suffixed alias keys.
|
| 3881 |
+
policy = (
|
| 3882 |
+
_curated.get(pid)
|
| 3883 |
+
or _curated.get(f"{pid}__wordings")
|
| 3884 |
+
or _curated.get(f"{pid}__cis")
|
| 3885 |
+
or _curated.get(f"{pid}__brochure")
|
| 3886 |
+
or _curated.get(f"{pid}__prospectus")
|
| 3887 |
+
)
|
| 3888 |
+
if not policy:
|
| 3889 |
+
# Genuinely absent from BOTH layers β not a catalogued product.
|
| 3890 |
out[pid] = BulkScorecardEntry(
|
| 3891 |
policy_id=pid,
|
| 3892 |
policy_name=pid,
|
|
|
|
| 3900 |
signals={},
|
| 3901 |
)
|
| 3902 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3903 |
|
| 3904 |
slug = policy.get("insurer_slug") or "?"
|
| 3905 |
if slug not in insurer_cache:
|
|
@@ -378,7 +378,7 @@ def score_renewal_protection(p: dict) -> SubScore:
|
|
| 378 |
maximum *entry* age β how late a first-time buyer can take the policy β
|
| 379 |
so that is the sole driver of this sub-score.
|
| 380 |
"""
|
| 381 |
-
signals: list[str] = ["
|
| 382 |
s = 50 # true-neutral base (was 60 β recalibrated for real spread)
|
| 383 |
|
| 384 |
maxe = _int(p, "max_entry_age")
|
|
|
|
| 378 |
maximum *entry* age β how late a first-time buyer can take the policy β
|
| 379 |
so that is the sole driver of this sub-score.
|
| 380 |
"""
|
| 381 |
+
signals: list[str] = ["Lifelong renewability guaranteed"]
|
| 382 |
s = 50 # true-neutral base (was 60 β recalibrated for real spread)
|
| 383 |
|
| 384 |
maxe = _int(p, "max_entry_age")
|
|
@@ -50,7 +50,13 @@ def test_entry_age_is_the_sole_driver():
|
|
| 50 |
def test_lifelong_shown_as_informational_signal_not_scored():
|
| 51 |
sc = score_renewal_protection({})
|
| 52 |
assert sc.name == "Renewal Protection"
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
def test_completeness_ignores_max_renewal_age():
|
|
|
|
| 50 |
def test_lifelong_shown_as_informational_signal_not_scored():
|
| 51 |
sc = score_renewal_protection({})
|
| 52 |
assert sc.name == "Renewal Protection"
|
| 53 |
+
# The signal must surface lifelong renewability as an informational
|
| 54 |
+
# bullet β but in CLEAN user-facing copy. The internal "(IRDAI norm β
|
| 55 |
+
# not scored)" scaffolding must NOT leak to the UI (#62). That it is
|
| 56 |
+
# genuinely not scored is enforced by the score-invariance tests above.
|
| 57 |
+
assert any("lifelong renewab" in s.lower() for s in sc.signals)
|
| 58 |
+
assert not any("not scored" in s.lower() or "irdai norm" in s.lower()
|
| 59 |
+
for s in sc.signals), "internal scoring jargon leaked into UI signal"
|
| 60 |
|
| 61 |
|
| 62 |
def test_completeness_ignores_max_renewal_age():
|