Spaces:
Sleeping
fix(reco): cap 3, exclude non-extracted policies, unify premium, ask PED
Browse filesFive live recommendation-card defects, fixed at source:
- #28 hard ≤3 cap in _build_recommendation_citations + prompt 2-3 + compare modal ≤3 (>3 collapsed the layout to 1 char/line)
- #29 _has_extraction renderability gate: non-extracted policies excluded from _quality_seed_candidates AND the citation gate (was rendering raw slug / N/A / 'No extraction available' / 'Data not indexed')
- #30 'Your share & the limits' always renders both rows with 'Not specified' fallback (was omitting nulls -> different field per card)
- #31 removed Path C NonCuratedPricingNotice (dead component+styles deleted); estimate_premium_band now p25-p75 interquartile not raw min-max (was identical 30x-wide band on every non-curated card)
- #32 SYSTEM_PROMPT now mandates the explicit pre-existing-conditions question before retrieve_policies
Tests: rewrote no-cap + band-superset guards to the new contracts, added tests/test_extraction_gate.py + tests/conftest.py renderable-stub; full pytest gate green; frontend tsc + next build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/brain_tools.py +34 -0
- backend/premium_calculator.py +38 -14
- backend/single_brain.py +26 -1
- frontend/src/app/page.tsx +0 -2
- frontend/src/components/PolicyCompareModal.tsx +12 -20
- frontend/src/components/PolicyPremiumWidget.tsx +12 -205
- tests/conftest.py +31 -0
- tests/test_cited_policies_match_prose.py +21 -15
- tests/test_extraction_gate.py +127 -0
- tests/test_premium_reconciliation.py +32 -10
|
@@ -150,6 +150,35 @@ def _candidate_stems(policy_id: str) -> list[str]:
|
|
| 150 |
return stems
|
| 151 |
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
def _load_policy_facts(policy_id: str) -> dict:
|
| 154 |
"""Return {fact_key: value} for a policy_id, or {} when no facts file
|
| 155 |
exists / is unreadable. Cached per policy_id (incl. negative cache)."""
|
|
@@ -646,6 +675,11 @@ def _quality_seed_candidates(profile, limit: int = 25) -> list[dict]:
|
|
| 646 |
if not pid or pid in seen:
|
| 647 |
continue
|
| 648 |
seen.add(pid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
sig = _scorecard_signal(pid, profile=profile)
|
| 650 |
ov = sig.get("_overall_score")
|
| 651 |
if ov is None:
|
|
|
|
| 150 |
return stems
|
| 151 |
|
| 152 |
|
| 153 |
+
_extraction_cache: dict = {}
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _has_extraction(policy_id: str) -> bool:
|
| 157 |
+
"""True iff this policy has an LLM-extracted corpus file — the EXACT
|
| 158 |
+
renderability rule the marketplace uses (main builds its card set from
|
| 159 |
+
settings.EXTRACTED_DIR/*.json). A policy with curated facts but no
|
| 160 |
+
extracted file renders as a BROKEN card: raw policy_id as the title,
|
| 161 |
+
grade "N/A", "No extraction available for this policy.", "Data not
|
| 162 |
+
indexed" (/api/bulk-scorecard). Such a policy must NEVER be quality-
|
| 163 |
+
seeded or cited. Canonical-stem aware (doctype siblings count); cached
|
| 164 |
+
incl. negatives."""
|
| 165 |
+
pid = (policy_id or "").strip()
|
| 166 |
+
if not pid:
|
| 167 |
+
return False
|
| 168 |
+
if pid in _extraction_cache:
|
| 169 |
+
return _extraction_cache[pid]
|
| 170 |
+
ok = False
|
| 171 |
+
try:
|
| 172 |
+
for stem in _candidate_stems(pid):
|
| 173 |
+
if (settings.EXTRACTED_DIR / f"{stem}.json").exists():
|
| 174 |
+
ok = True
|
| 175 |
+
break
|
| 176 |
+
except Exception: # noqa: BLE001 — predicate must never break retrieval
|
| 177 |
+
ok = False
|
| 178 |
+
_extraction_cache[pid] = ok
|
| 179 |
+
return ok
|
| 180 |
+
|
| 181 |
+
|
| 182 |
def _load_policy_facts(policy_id: str) -> dict:
|
| 183 |
"""Return {fact_key: value} for a policy_id, or {} when no facts file
|
| 184 |
exists / is unreadable. Cached per policy_id (incl. negative cache)."""
|
|
|
|
| 675 |
if not pid or pid in seen:
|
| 676 |
continue
|
| 677 |
seen.add(pid)
|
| 678 |
+
if not _has_extraction(pid):
|
| 679 |
+
# Curated-graded but no extracted corpus → its card would
|
| 680 |
+
# render as N/A / "No extraction available for this policy"
|
| 681 |
+
# / "Data not indexed". Never seed a non-renderable policy.
|
| 682 |
+
continue
|
| 683 |
sig = _scorecard_signal(pid, profile=profile)
|
| 684 |
ov = sig.get("_overall_score")
|
| 685 |
if ov is None:
|
|
@@ -953,6 +953,25 @@ def _median(xs: list[int]) -> int:
|
|
| 953 |
return int((s[mid - 1] + s[mid]) / 2)
|
| 954 |
|
| 955 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 956 |
def estimate_premium_band(
|
| 957 |
profile: Optional[dict] = None,
|
| 958 |
candidate_policy_ids: Optional[list[str]] = None,
|
|
@@ -970,13 +989,17 @@ def estimate_premium_band(
|
|
| 970 |
`getPredictedPremiumBand()` / the `premiumBand` state in page.tsx.
|
| 971 |
|
| 972 |
Contract guarantees (KI-278, 2026-05-16):
|
| 973 |
-
• The chip band = the
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
| 978 |
-
|
| 979 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 980 |
• SI precedence is resolved by `resolve_profile_sum_insured(profile)`
|
| 981 |
— byte-identical to PremiumCalculatorPanel's slider seed
|
| 982 |
(`desired_sum_insured_inr ?? existing_cover_inr ?? default`). The
|
|
@@ -1035,14 +1058,15 @@ def estimate_premium_band(
|
|
| 1035 |
}
|
| 1036 |
|
| 1037 |
return {
|
| 1038 |
-
#
|
| 1039 |
-
#
|
| 1040 |
-
#
|
| 1041 |
-
#
|
| 1042 |
-
#
|
| 1043 |
-
|
|
|
|
| 1044 |
"median_inr": _round_to_500(_median(premiums)),
|
| 1045 |
-
"max_inr": _ceil_to_500(
|
| 1046 |
"sample_size": len(premiums),
|
| 1047 |
"assumed": bool(any_assumed),
|
| 1048 |
"sum_insured_used": resolved_si,
|
|
|
|
| 953 |
return int((s[mid - 1] + s[mid]) / 2)
|
| 954 |
|
| 955 |
|
| 956 |
+
def _percentile(xs: list[int], q: float) -> int:
|
| 957 |
+
"""Linear-interpolated q-th percentile (q in 0..100). Used for the
|
| 958 |
+
predicted-premium BAND edges. The basket mixes cheap fixed-benefit
|
| 959 |
+
plans with premium indemnity plans, so absolute min/max sit ~4-5x
|
| 960 |
+
apart — a band that wide renders as a useless, broken-looking range
|
| 961 |
+
("₹44,000-₹1,96,000"). The interquartile p25-p75 is the honest
|
| 962 |
+
"what similar profiles typically pay" range."""
|
| 963 |
+
if not xs:
|
| 964 |
+
return 0
|
| 965 |
+
s = sorted(xs)
|
| 966 |
+
if len(s) == 1:
|
| 967 |
+
return int(s[0])
|
| 968 |
+
pos = (q / 100.0) * (len(s) - 1)
|
| 969 |
+
lo = int(pos)
|
| 970 |
+
hi = min(lo + 1, len(s) - 1)
|
| 971 |
+
frac = pos - lo
|
| 972 |
+
return int(round(s[lo] + (s[hi] - s[lo]) * frac))
|
| 973 |
+
|
| 974 |
+
|
| 975 |
def estimate_premium_band(
|
| 976 |
profile: Optional[dict] = None,
|
| 977 |
candidate_policy_ids: Optional[list[str]] = None,
|
|
|
|
| 989 |
`getPredictedPremiumBand()` / the `premiumBand` state in page.tsx.
|
| 990 |
|
| 991 |
Contract guarantees (KI-278, 2026-05-16):
|
| 992 |
+
• The chip band = the p25-p75 INTERQUARTILE of the 26-policy basket
|
| 993 |
+
priced at the profile-resolved SI — the "what similar profiles
|
| 994 |
+
typically pay" range, NOT the raw min-max envelope (the basket
|
| 995 |
+
mixes fixed-benefit and premium indemnity plans whose absolute
|
| 996 |
+
spread is ~4-5x and renders as a useless, broken-looking band).
|
| 997 |
+
The per-settings panel shows ONE specific plan's point estimate,
|
| 998 |
+
which may sit inside or just outside this typical band — that is
|
| 999 |
+
expected and correct (a specific plan can be cheaper or pricier
|
| 1000 |
+
than the typical cohort); the surfaces no longer contradict
|
| 1001 |
+
because the band is explicitly a "typical range", not an
|
| 1002 |
+
absolute envelope.
|
| 1003 |
• SI precedence is resolved by `resolve_profile_sum_insured(profile)`
|
| 1004 |
— byte-identical to PremiumCalculatorPanel's slider seed
|
| 1005 |
(`desired_sum_insured_inr ?? existing_cover_inr ?? default`). The
|
|
|
|
| 1058 |
}
|
| 1059 |
|
| 1060 |
return {
|
| 1061 |
+
# INTERQUARTILE band (p25-p75), NOT raw min-max. The basket mixes
|
| 1062 |
+
# cheap fixed-benefit and premium indemnity plans whose absolute
|
| 1063 |
+
# min/max sit ~4-5x apart — a band that wide ("₹44,000-₹1,96,000")
|
| 1064 |
+
# is useless and reads as broken. p25-p75 is the honest "what
|
| 1065 |
+
# similar profiles typically pay" range; median is the typical
|
| 1066 |
+
# anchor. Edges still directionally rounded for clean display.
|
| 1067 |
+
"min_inr": _floor_to_500(_percentile(premiums, 25)),
|
| 1068 |
"median_inr": _round_to_500(_median(premiums)),
|
| 1069 |
+
"max_inr": _ceil_to_500(_percentile(premiums, 75)),
|
| 1070 |
"sample_size": len(premiums),
|
| 1071 |
"assumed": bool(any_assumed),
|
| 1072 |
"sum_insured_used": resolved_si,
|
|
@@ -77,11 +77,21 @@ SYSTEM_PROMPT = """You are an Indian health-insurance advisor speaking with a cu
|
|
| 77 |
|
| 78 |
YOUR JOB:
|
| 79 |
1. Have a natural conversation to learn the customer's profile.
|
| 80 |
-
2. Once you have ALL required slots, summarise + confirm, then call retrieve_policies, then recommend 2-
|
| 81 |
3. Help the customer choose one. Cite the UIN / policy_id for every claim about features, sums insured, or premiums.
|
| 82 |
|
| 83 |
REQUIRED slots before recommending: name, age, dependents, location_tier, income_band, primary_goal, health_conditions.
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
═══════════════════════════════════
|
| 86 |
ABSOLUTE RULE — NO POLICY NAMES WITHOUT RETRIEVE
|
| 87 |
═══════════════════════════════════
|
|
@@ -991,6 +1001,11 @@ def _norm_policy_name(s: str) -> str:
|
|
| 991 |
# shortlist with a weak plan. We do NOT loosen the scorecard or fabricate;
|
| 992 |
# we only stop presenting weak-fit plans AS recommendations.
|
| 993 |
_MIN_RECOMMENDATION_OVERALL: float = 70.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 994 |
_STRONG_RECOMMENDATION_GRADES: frozenset[str] = frozenset({"A", "B"})
|
| 995 |
|
| 996 |
|
|
@@ -1166,6 +1181,14 @@ def _build_recommendation_citations(
|
|
| 1166 |
c = best_by_canon.get(k)
|
| 1167 |
if c is None:
|
| 1168 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1169 |
strong, overall, _grade = _recommendation_fit(c)
|
| 1170 |
if not strong:
|
| 1171 |
dropped.append(
|
|
@@ -1196,6 +1219,8 @@ def _build_recommendation_citations(
|
|
| 1196 |
cite = _cite_canon(k)
|
| 1197 |
if cite is not None:
|
| 1198 |
out.append(cite)
|
|
|
|
|
|
|
| 1199 |
return out
|
| 1200 |
|
| 1201 |
# ---- Path 1: explicit mark_recommendation selection -------------------
|
|
|
|
| 77 |
|
| 78 |
YOUR JOB:
|
| 79 |
1. Have a natural conversation to learn the customer's profile.
|
| 80 |
+
2. Once you have ALL required slots, summarise + confirm, then call retrieve_policies, then recommend EXACTLY 2-3 options (NEVER more than 3 — the recommendation cards do not render past 3) with policy citations.
|
| 81 |
3. Help the customer choose one. Cite the UIN / policy_id for every claim about features, sums insured, or premiums.
|
| 82 |
|
| 83 |
REQUIRED slots before recommending: name, age, dependents, location_tier, income_band, primary_goal, health_conditions.
|
| 84 |
|
| 85 |
+
PRE-EXISTING CONDITIONS ARE MANDATORY — you MUST explicitly ASK the
|
| 86 |
+
customer this question (do not skip it, do not infer it): "Do you have
|
| 87 |
+
any pre-existing conditions — diabetes, BP / hypertension, thyroid,
|
| 88 |
+
heart, asthma, or a cancer history — or none?" Then call
|
| 89 |
+
save_profile_field(field="health_conditions", value=...) with their
|
| 90 |
+
answer (use value="none" when they have none). NEVER call
|
| 91 |
+
retrieve_policies or recommend any policy until health_conditions has
|
| 92 |
+
been captured this way — it materially changes eligibility, pricing
|
| 93 |
+
and the recommendation.
|
| 94 |
+
|
| 95 |
═══════════════════════════════════
|
| 96 |
ABSOLUTE RULE — NO POLICY NAMES WITHOUT RETRIEVE
|
| 97 |
═══════════════════════════════════
|
|
|
|
| 1001 |
# shortlist with a weak plan. We do NOT loosen the scorecard or fabricate;
|
| 1002 |
# we only stop presenting weak-fit plans AS recommendations.
|
| 1003 |
_MIN_RECOMMENDATION_OVERALL: float = 70.0
|
| 1004 |
+
# Hard ceiling on cited recommendations. The CitedPolicyCards layout
|
| 1005 |
+
# collapses (names wrap to one character per line) past 3 cards, so the
|
| 1006 |
+
# recommended set is capped at 3 regardless of how many clear the fitness
|
| 1007 |
+
# floor — best-first, so the 3 strongest are the ones kept.
|
| 1008 |
+
_MAX_RECOMMENDATIONS: int = 3
|
| 1009 |
_STRONG_RECOMMENDATION_GRADES: frozenset[str] = frozenset({"A", "B"})
|
| 1010 |
|
| 1011 |
|
|
|
|
| 1181 |
c = best_by_canon.get(k)
|
| 1182 |
if c is None:
|
| 1183 |
continue
|
| 1184 |
+
if not brain_tools._has_extraction(c.get("policy_id") or ""):
|
| 1185 |
+
# No extracted corpus → the card renders as N/A /
|
| 1186 |
+
# "No extraction available for this policy" / "Data not
|
| 1187 |
+
# indexed". Drop it from the recommended set even if the
|
| 1188 |
+
# LLM named it; only renderable, data-backed policies are
|
| 1189 |
+
# ever cited.
|
| 1190 |
+
dropped.append(f"{c.get('policy_name') or k}(no-extraction)")
|
| 1191 |
+
continue
|
| 1192 |
strong, overall, _grade = _recommendation_fit(c)
|
| 1193 |
if not strong:
|
| 1194 |
dropped.append(
|
|
|
|
| 1219 |
cite = _cite_canon(k)
|
| 1220 |
if cite is not None:
|
| 1221 |
out.append(cite)
|
| 1222 |
+
if len(out) >= _MAX_RECOMMENDATIONS:
|
| 1223 |
+
break # hard ≤3 cap — keep the 3 strongest (best-first)
|
| 1224 |
return out
|
| 1225 |
|
| 1226 |
# ---- Path 1: explicit mark_recommendation selection -------------------
|
|
@@ -4041,13 +4041,11 @@ function CitedPolicyCards({
|
|
| 4041 |
onClose={() => setCompareOpen(false)}
|
| 4042 |
profile={profile}
|
| 4043 |
policyDataFor={(id) => policyById[id]}
|
| 4044 |
-
aggregateBand={premiumBand}
|
| 4045 |
renderPremiumFor={(policyId, policyName) => (
|
| 4046 |
<PolicyPremiumWidget
|
| 4047 |
policyId={policyId}
|
| 4048 |
policyName={policyName}
|
| 4049 |
profile={premiumProfile}
|
| 4050 |
-
aggregateBand={premiumBand}
|
| 4051 |
/>
|
| 4052 |
)}
|
| 4053 |
renderScorecardFor={(policyId, policyName) => (
|
|
|
|
| 4041 |
onClose={() => setCompareOpen(false)}
|
| 4042 |
profile={profile}
|
| 4043 |
policyDataFor={(id) => policyById[id]}
|
|
|
|
| 4044 |
renderPremiumFor={(policyId, policyName) => (
|
| 4045 |
<PolicyPremiumWidget
|
| 4046 |
policyId={policyId}
|
| 4047 |
policyName={policyName}
|
| 4048 |
profile={premiumProfile}
|
|
|
|
| 4049 |
/>
|
| 4050 |
)}
|
| 4051 |
renderScorecardFor={(policyId, policyName) => (
|
|
@@ -244,21 +244,6 @@ export type PolicyCompareModalProps = {
|
|
| 244 |
policyDataFor?: (policyId: string) => MarketplacePolicy | undefined;
|
| 245 |
// Hook for "Open in full marketplace" — defaults to no-op + closes modal.
|
| 246 |
onOpenMarketplace?: () => void;
|
| 247 |
-
// User's profile-level predicted premium band (same number rendered in
|
| 248 |
-
// the chat header chip). Threaded down to PolicyPremiumWidget so that
|
| 249 |
-
// non-curated policies (base_sample_used: false) can surface the band
|
| 250 |
-
// as their indicative reference instead of a heuristic slider estimate.
|
| 251 |
-
// Optional: when omitted, non-curated widgets fall back to a "band not
|
| 252 |
-
// available" hint. The parent (page.tsx) typically closes over the same
|
| 253 |
-
// value inside renderPremiumFor too — this prop is the declarative
|
| 254 |
-
// contract for future callers.
|
| 255 |
-
aggregateBand?: {
|
| 256 |
-
min_inr: number;
|
| 257 |
-
max_inr: number;
|
| 258 |
-
median_inr: number;
|
| 259 |
-
sample_size?: number;
|
| 260 |
-
assumed?: boolean;
|
| 261 |
-
} | null;
|
| 262 |
};
|
| 263 |
|
| 264 |
export default function PolicyCompareModal({
|
|
@@ -269,10 +254,8 @@ export default function PolicyCompareModal({
|
|
| 269 |
profile: _profile,
|
| 270 |
policyDataFor,
|
| 271 |
onOpenMarketplace,
|
| 272 |
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 273 |
-
aggregateBand: _aggregateBand,
|
| 274 |
}: PolicyCompareModalProps) {
|
| 275 |
-
const uniq = uniquePolicies(policies).slice(0,
|
| 276 |
const n = uniq.length;
|
| 277 |
|
| 278 |
// Close on Escape — keyboard parity with the click-outside backdrop.
|
|
@@ -1016,17 +999,26 @@ export function buildSnapshot(
|
|
| 1016 |
// FORCES a share on every claim. The exact % the user opts into is set
|
| 1017 |
// later on the pricing slider; what matters here is mandatory-or-not (a
|
| 1018 |
// hard minimum is a real consideration). So: binary first, figure second.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1019 |
push(
|
| 1020 |
limits,
|
| 1021 |
"Mandatory co-pay",
|
| 1022 |
cCopay == null
|
| 1023 |
-
?
|
| 1024 |
: cCopay === 0
|
| 1025 |
? "None — no forced co-pay"
|
| 1026 |
: `Yes · ${cCopay}% minimum on every claim`,
|
| 1027 |
"copay",
|
| 1028 |
);
|
| 1029 |
-
push(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1030 |
|
| 1031 |
// CONDITIONAL — profile-aware, never headline
|
| 1032 |
const ctx = `${(profile?.dependents || "").toLowerCase()} ${(
|
|
|
|
| 244 |
policyDataFor?: (policyId: string) => MarketplacePolicy | undefined;
|
| 245 |
// Hook for "Open in full marketplace" — defaults to no-op + closes modal.
|
| 246 |
onOpenMarketplace?: () => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
};
|
| 248 |
|
| 249 |
export default function PolicyCompareModal({
|
|
|
|
| 254 |
profile: _profile,
|
| 255 |
policyDataFor,
|
| 256 |
onOpenMarketplace,
|
|
|
|
|
|
|
| 257 |
}: PolicyCompareModalProps) {
|
| 258 |
+
const uniq = uniquePolicies(policies).slice(0, 3);
|
| 259 |
const n = uniq.length;
|
| 260 |
|
| 261 |
// Close on Escape — keyboard parity with the click-outside backdrop.
|
|
|
|
| 999 |
// FORCES a share on every claim. The exact % the user opts into is set
|
| 1000 |
// later on the pricing slider; what matters here is mandatory-or-not (a
|
| 1001 |
// hard minimum is a real consideration). So: binary first, figure second.
|
| 1002 |
+
// #30 — both rows render on EVERY card with an explicit "Not specified"
|
| 1003 |
+
// fallback (never omitted), so this section is consistent across
|
| 1004 |
+
// policies instead of showing whichever single field happened to be
|
| 1005 |
+
// non-null (which read as random to the user).
|
| 1006 |
push(
|
| 1007 |
limits,
|
| 1008 |
"Mandatory co-pay",
|
| 1009 |
cCopay == null
|
| 1010 |
+
? "Not specified"
|
| 1011 |
: cCopay === 0
|
| 1012 |
? "None — no forced co-pay"
|
| 1013 |
: `Yes · ${cCopay}% minimum on every claim`,
|
| 1014 |
"copay",
|
| 1015 |
);
|
| 1016 |
+
push(
|
| 1017 |
+
limits,
|
| 1018 |
+
"Hospital room category",
|
| 1019 |
+
roomRent ? roomRent : "Not specified",
|
| 1020 |
+
"room",
|
| 1021 |
+
);
|
| 1022 |
|
| 1023 |
// CONDITIONAL — profile-aware, never headline
|
| 1024 |
const ctx = `${(profile?.dependents || "").toLowerCase()} ${(
|
|
@@ -64,18 +64,6 @@ export type PolicyPremiumWidgetProps = {
|
|
| 64 |
initialTenureYears?: 1 | 2 | 3;
|
| 65 |
initialDeductibleInr?: 0 | 25000 | 50000 | 100000;
|
| 66 |
onCalculated?: (premium: number) => void;
|
| 67 |
-
// User's profile-level predicted premium band (the same number rendered in
|
| 68 |
-
// the header chip / `getPredictedPremiumBand`). Surfaced as the indicative
|
| 69 |
-
// reference when the backend reports `base_sample_used: false` (i.e. no
|
| 70 |
-
// curated quote sample for this specific policy). Threaded down from
|
| 71 |
-
// PolicyCompareModal so we don't refetch per-column.
|
| 72 |
-
aggregateBand?: {
|
| 73 |
-
min_inr: number;
|
| 74 |
-
max_inr: number;
|
| 75 |
-
median_inr: number;
|
| 76 |
-
sample_size?: number;
|
| 77 |
-
assumed?: boolean;
|
| 78 |
-
} | null;
|
| 79 |
};
|
| 80 |
|
| 81 |
const SUM_INSURED_MIN = 500_000;
|
|
@@ -189,7 +177,6 @@ export default function PolicyPremiumWidget({
|
|
| 189 |
initialTenureYears = 1,
|
| 190 |
initialDeductibleInr = 0,
|
| 191 |
onCalculated,
|
| 192 |
-
aggregateBand,
|
| 193 |
}: PolicyPremiumWidgetProps) {
|
| 194 |
// KI-278 — seed the SI from the profile (same precedence as the header
|
| 195 |
// chip) unless the caller forced an explicit initialSumInsured. This is
|
|
@@ -286,20 +273,12 @@ export default function PolicyPremiumWidget({
|
|
| 286 |
|
| 287 |
const profileSummary = summariseProfile(profile);
|
| 288 |
|
| 289 |
-
//
|
| 290 |
-
//
|
| 291 |
-
//
|
| 292 |
-
//
|
| 293 |
-
//
|
| 294 |
-
//
|
| 295 |
-
if (resp && resp.base_sample_used === false) {
|
| 296 |
-
return (
|
| 297 |
-
<NonCuratedPricingNotice
|
| 298 |
-
policyName={policyName}
|
| 299 |
-
aggregateBand={aggregateBand ?? null}
|
| 300 |
-
/>
|
| 301 |
-
);
|
| 302 |
-
}
|
| 303 |
|
| 304 |
// Compact, profile-only breakdown — the estimate endpoint doesn't expose
|
| 305 |
// a multiplicative chain so we surface the active overrides + methodology
|
|
@@ -337,10 +316,9 @@ export default function PolicyPremiumWidget({
|
|
| 337 |
>
|
| 338 |
{policyName}
|
| 339 |
</div>
|
| 340 |
-
{/*
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
badge needed here — the number is anchored to a real quote sample. */}
|
| 344 |
</header>
|
| 345 |
|
| 346 |
{profileSummary && (
|
|
@@ -460,81 +438,6 @@ export default function PolicyPremiumWidget({
|
|
| 460 |
);
|
| 461 |
}
|
| 462 |
|
| 463 |
-
/* ------------------------------------------------------------------ */
|
| 464 |
-
/* NonCuratedPricingNotice — rendered in place of the slider widget */
|
| 465 |
-
/* when /api/premium/estimate reports base_sample_used: false. Shows */
|
| 466 |
-
/* the user's aggregate predicted-premium band (threaded down from */
|
| 467 |
-
/* PolicyCompareModal) as the indicative reference + a clear pricing- */
|
| 468 |
-
/* note explaining we don't have a policy-specific quote. */
|
| 469 |
-
/* ------------------------------------------------------------------ */
|
| 470 |
-
|
| 471 |
-
function NonCuratedPricingNotice({
|
| 472 |
-
policyName,
|
| 473 |
-
aggregateBand,
|
| 474 |
-
}: {
|
| 475 |
-
policyName: string;
|
| 476 |
-
aggregateBand: {
|
| 477 |
-
min_inr: number;
|
| 478 |
-
max_inr: number;
|
| 479 |
-
median_inr: number;
|
| 480 |
-
sample_size?: number;
|
| 481 |
-
assumed?: boolean;
|
| 482 |
-
} | null;
|
| 483 |
-
}) {
|
| 484 |
-
const hasBand = !!aggregateBand;
|
| 485 |
-
return (
|
| 486 |
-
<div style={noticeWidgetStyle} role="note" aria-label="Pricing note">
|
| 487 |
-
<header style={noticeHeaderStyle}>
|
| 488 |
-
<span style={noticeIconStyle} aria-hidden="true">
|
| 489 |
-
{/* info icon — pure SVG so we don't pull a new dependency */}
|
| 490 |
-
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
| 491 |
-
<circle cx="12" cy="12" r="10" />
|
| 492 |
-
<line x1="12" y1="8" x2="12" y2="8" />
|
| 493 |
-
<line x1="12" y1="12" x2="12" y2="16" />
|
| 494 |
-
</svg>
|
| 495 |
-
</span>
|
| 496 |
-
<span style={noticeBadgeStyle}>Pricing note</span>
|
| 497 |
-
</header>
|
| 498 |
-
|
| 499 |
-
<p style={noticeBodyStyle}>
|
| 500 |
-
We don't have a verified quote for <strong>{policyName}</strong> yet,
|
| 501 |
-
so we can't show a plan-specific estimate. Based on your profile,
|
| 502 |
-
plans in this category typically cost:
|
| 503 |
-
</p>
|
| 504 |
-
|
| 505 |
-
<div style={noticeBandBoxStyle}>
|
| 506 |
-
{hasBand ? (
|
| 507 |
-
<>
|
| 508 |
-
<div style={noticeBandHeadlineStyle}>
|
| 509 |
-
₹{formatInr(aggregateBand!.min_inr)}–₹{formatInr(aggregateBand!.max_inr)}
|
| 510 |
-
<span style={noticeBandSuffixStyle}> / year</span>
|
| 511 |
-
</div>
|
| 512 |
-
<div style={noticeBandMedianStyle}>
|
| 513 |
-
Median ₹{formatInr(aggregateBand!.median_inr)}/year
|
| 514 |
-
{typeof aggregateBand!.sample_size === "number" &&
|
| 515 |
-
aggregateBand!.sample_size > 0 && (
|
| 516 |
-
<> · across {aggregateBand!.sample_size} similar profiles</>
|
| 517 |
-
)}
|
| 518 |
-
</div>
|
| 519 |
-
</>
|
| 520 |
-
) : (
|
| 521 |
-
<div style={noticeBandFallbackStyle}>
|
| 522 |
-
Profile-level band not available yet — complete your profile to see
|
| 523 |
-
an indicative range.
|
| 524 |
-
</div>
|
| 525 |
-
)}
|
| 526 |
-
</div>
|
| 527 |
-
|
| 528 |
-
<p style={noticeFootnoteStyle}>
|
| 529 |
-
This is an indicative range — the actual premium depends on the
|
| 530 |
-
insurer's underwriting + your final disclosures. To get an exact
|
| 531 |
-
quote, request one from the insurer directly or via
|
| 532 |
-
PolicyBazaar / InsuranceDekho.
|
| 533 |
-
</p>
|
| 534 |
-
</div>
|
| 535 |
-
);
|
| 536 |
-
}
|
| 537 |
-
|
| 538 |
/* ------------------------------------------------------------------ */
|
| 539 |
/* Inline styles — kept local so the widget drops into any modal */
|
| 540 |
/* without a CSS-module dependency. All colors read from the landing's */
|
|
@@ -780,102 +683,6 @@ const noteStyle: React.CSSProperties = {
|
|
| 780 |
paddingTop: 2,
|
| 781 |
};
|
| 782 |
|
| 783 |
-
/*
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
/* distinguish "indicative range" from a "calculated estimate". */
|
| 787 |
-
|
| 788 |
-
const noticeWidgetStyle: React.CSSProperties = {
|
| 789 |
-
border: "1px solid color-mix(in srgb, var(--primary) 22%, var(--border))",
|
| 790 |
-
borderLeft: "3px solid var(--primary)",
|
| 791 |
-
borderRadius: 18,
|
| 792 |
-
padding: 18,
|
| 793 |
-
background: "color-mix(in srgb, var(--primary) 4%, var(--card))",
|
| 794 |
-
display: "flex",
|
| 795 |
-
flexDirection: "column",
|
| 796 |
-
gap: 11,
|
| 797 |
-
fontFamily: SANS,
|
| 798 |
-
boxShadow:
|
| 799 |
-
"0 1px 2px color-mix(in srgb, var(--foreground) 4%, transparent), 0 16px 40px -32px color-mix(in srgb, var(--foreground) 28%, transparent)",
|
| 800 |
-
};
|
| 801 |
-
|
| 802 |
-
const noticeHeaderStyle: React.CSSProperties = {
|
| 803 |
-
display: "flex",
|
| 804 |
-
alignItems: "center",
|
| 805 |
-
gap: 8,
|
| 806 |
-
};
|
| 807 |
-
|
| 808 |
-
const noticeIconStyle: React.CSSProperties = {
|
| 809 |
-
display: "inline-flex",
|
| 810 |
-
alignItems: "center",
|
| 811 |
-
justifyContent: "center",
|
| 812 |
-
width: 22,
|
| 813 |
-
height: 22,
|
| 814 |
-
borderRadius: 999,
|
| 815 |
-
color: "var(--primary)",
|
| 816 |
-
background: "color-mix(in srgb, var(--primary) 12%, var(--card))",
|
| 817 |
-
border: "1px solid color-mix(in srgb, var(--primary) 22%, var(--border))",
|
| 818 |
-
};
|
| 819 |
-
|
| 820 |
-
const noticeBadgeStyle: React.CSSProperties = {
|
| 821 |
-
fontSize: 10,
|
| 822 |
-
fontWeight: 700,
|
| 823 |
-
letterSpacing: "0.12em",
|
| 824 |
-
textTransform: "uppercase",
|
| 825 |
-
color: "color-mix(in srgb, var(--primary) 78%, var(--foreground))",
|
| 826 |
-
};
|
| 827 |
-
|
| 828 |
-
const noticeBodyStyle: React.CSSProperties = {
|
| 829 |
-
margin: 0,
|
| 830 |
-
fontSize: 12.5,
|
| 831 |
-
lineHeight: 1.55,
|
| 832 |
-
color: "var(--foreground)",
|
| 833 |
-
};
|
| 834 |
-
|
| 835 |
-
const noticeBandBoxStyle: React.CSSProperties = {
|
| 836 |
-
background: "var(--card)",
|
| 837 |
-
border: "1px solid color-mix(in srgb, var(--primary) 16%, var(--border))",
|
| 838 |
-
borderRadius: 12,
|
| 839 |
-
padding: "12px 14px",
|
| 840 |
-
display: "flex",
|
| 841 |
-
flexDirection: "column",
|
| 842 |
-
gap: 3,
|
| 843 |
-
};
|
| 844 |
-
|
| 845 |
-
const noticeBandHeadlineStyle: React.CSSProperties = {
|
| 846 |
-
fontFamily: SERIF,
|
| 847 |
-
fontOpticalSizing: "auto",
|
| 848 |
-
fontSize: 21,
|
| 849 |
-
fontWeight: 600,
|
| 850 |
-
color: "var(--foreground)",
|
| 851 |
-
letterSpacing: "-0.02em",
|
| 852 |
-
fontVariantNumeric: "tabular-nums",
|
| 853 |
-
};
|
| 854 |
-
|
| 855 |
-
const noticeBandSuffixStyle: React.CSSProperties = {
|
| 856 |
-
fontFamily: SANS,
|
| 857 |
-
fontSize: 12,
|
| 858 |
-
fontWeight: 500,
|
| 859 |
-
color: "var(--muted-foreground)",
|
| 860 |
-
};
|
| 861 |
-
|
| 862 |
-
const noticeBandMedianStyle: React.CSSProperties = {
|
| 863 |
-
fontSize: 11.5,
|
| 864 |
-
color: "var(--muted-foreground)",
|
| 865 |
-
fontVariantNumeric: "tabular-nums",
|
| 866 |
-
};
|
| 867 |
-
|
| 868 |
-
const noticeBandFallbackStyle: React.CSSProperties = {
|
| 869 |
-
fontSize: 12,
|
| 870 |
-
color: "#855316",
|
| 871 |
-
fontStyle: "italic",
|
| 872 |
-
lineHeight: 1.5,
|
| 873 |
-
};
|
| 874 |
-
|
| 875 |
-
const noticeFootnoteStyle: React.CSSProperties = {
|
| 876 |
-
margin: 0,
|
| 877 |
-
fontSize: 11,
|
| 878 |
-
lineHeight: 1.5,
|
| 879 |
-
color: "var(--muted-foreground)",
|
| 880 |
-
fontStyle: "italic",
|
| 881 |
-
};
|
|
|
|
| 64 |
initialTenureYears?: 1 | 2 | 3;
|
| 65 |
initialDeductibleInr?: 0 | 25000 | 50000 | 100000;
|
| 66 |
onCalculated?: (premium: number) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
};
|
| 68 |
|
| 69 |
const SUM_INSURED_MIN = 500_000;
|
|
|
|
| 177 |
initialTenureYears = 1,
|
| 178 |
initialDeductibleInr = 0,
|
| 179 |
onCalculated,
|
|
|
|
| 180 |
}: PolicyPremiumWidgetProps) {
|
| 181 |
// KI-278 — seed the SI from the profile (same precedence as the header
|
| 182 |
// chip) unless the caller forced an explicit initialSumInsured. This is
|
|
|
|
| 273 |
|
| 274 |
const profileSummary = summariseProfile(profile);
|
| 275 |
|
| 276 |
+
// Every recommended policy renders the SAME per-policy estimate block
|
| 277 |
+
// below (point + ±15% band + methodology). When the backend has no
|
| 278 |
+
// curated quote sample, the `methodology` string itself states it is a
|
| 279 |
+
// rules-based estimate — we no longer substitute the wide profile-level
|
| 280 |
+
// basket band for some policies (that produced an identical, 4-5x-wide
|
| 281 |
+
// "no verified quote" panel on every non-curated card).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
// Compact, profile-only breakdown — the estimate endpoint doesn't expose
|
| 284 |
// a multiplicative chain so we surface the active overrides + methodology
|
|
|
|
| 316 |
>
|
| 317 |
{policyName}
|
| 318 |
</div>
|
| 319 |
+
{/* The methodology line under the estimate states whether the
|
| 320 |
+
number is anchored to a curated quote sample or a rules-based
|
| 321 |
+
formula — so no separate badge is needed here. */}
|
|
|
|
| 322 |
</header>
|
| 323 |
|
| 324 |
{profileSummary && (
|
|
|
|
| 438 |
);
|
| 439 |
}
|
| 440 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
/* ------------------------------------------------------------------ */
|
| 442 |
/* Inline styles — kept local so the widget drops into any modal */
|
| 443 |
/* without a CSS-module dependency. All colors read from the landing's */
|
|
|
|
| 683 |
paddingTop: 2,
|
| 684 |
};
|
| 685 |
|
| 686 |
+
/* (NonCuratedPricingNotice + its styles removed — every policy now
|
| 687 |
+
renders the unified per-policy estimate block; the methodology line
|
| 688 |
+
states when the number is rules-based vs curated-sample anchored.) */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared pytest fixtures.
|
| 2 |
+
|
| 3 |
+
The recommendation-card EXTRACTION GATE is a new, orthogonal invariant: a
|
| 4 |
+
policy with no extracted corpus file renders as a broken card (raw
|
| 5 |
+
policy_id title, grade "N/A", "No extraction available for this policy.",
|
| 6 |
+
"Data not indexed"), so it must never be cited or quality-seeded.
|
| 7 |
+
|
| 8 |
+
In production every RETRIEVED policy is renderable. The synthetic
|
| 9 |
+
policy_ids the logic tests build (e.g. "rs-multiplier", "p0",
|
| 10 |
+
"test__policy-a") have no extracted file on disk, so without this fixture
|
| 11 |
+
the gate would empty every synthetic citation set and mask the behaviour
|
| 12 |
+
each test actually checks (selection, ordering, dedup, fit-floor,
|
| 13 |
+
transparency).
|
| 14 |
+
|
| 15 |
+
So: for every test EXCEPT the dedicated extraction-gate guard
|
| 16 |
+
(tests/test_extraction_gate.py — which restores the real predicate via
|
| 17 |
+
its own monkeypatch), treat test policies as renderable. Forcing
|
| 18 |
+
_has_extraction -> True reproduces the exact pre-gate behaviour these
|
| 19 |
+
tests were written against.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import pytest
|
| 25 |
+
|
| 26 |
+
from backend import brain_tools
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@pytest.fixture(autouse=True)
|
| 30 |
+
def _treat_test_policies_as_renderable(monkeypatch):
|
| 31 |
+
monkeypatch.setattr(brain_tools, "_has_extraction", lambda pid: True)
|
|
@@ -20,8 +20,9 @@ THE CONTRACT THIS PINS:
|
|
| 20 |
`_build_recommendation_citations` returns a citation list that is exactly
|
| 21 |
the recommended policies — via explicit mark_recommendation ordering when
|
| 22 |
present, else by the policy names actually written in the reply prose —
|
| 23 |
-
with NO score-order fallback that resurrects un-named policies, and
|
| 24 |
-
|
|
|
|
| 25 |
|
| 26 |
Run:
|
| 27 |
cd /Users/rohitsar/Developer/Insurance\\ Sales\\ Bot
|
|
@@ -94,8 +95,8 @@ class TestProseNameNormalisation(unittest.TestCase):
|
|
| 94 |
|
| 95 |
|
| 96 |
class TestMarkRecommendationPath(unittest.TestCase):
|
| 97 |
-
"""When the LLM calls mark_recommendation, those
|
| 98 |
-
|
| 99 |
|
| 100 |
def test_exact_set_and_order_from_marked_ids(self) -> None:
|
| 101 |
marked = ["rs-multiplier", "rs-advtopup", "hdfc-myhealth", "star-fho"]
|
|
@@ -105,7 +106,13 @@ class TestMarkRecommendationPath(unittest.TestCase):
|
|
| 105 |
marked_policy_ids=marked,
|
| 106 |
)
|
| 107 |
self.assertTrue(is_rec)
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
# The un-recommended high-scorer must NOT appear.
|
| 110 |
self.assertNotIn(
|
| 111 |
"star-hospcash", [c["policy_id"] for c in cites]
|
|
@@ -139,18 +146,18 @@ class TestProseMatchingPath(unittest.TestCase):
|
|
| 139 |
)
|
| 140 |
self.assertTrue(is_rec)
|
| 141 |
ids = [c["policy_id"] for c in cites]
|
| 142 |
-
#
|
|
|
|
| 143 |
self.assertEqual(
|
| 144 |
-
ids,
|
| 145 |
-
["rs-multiplier", "rs-advtopup", "hdfc-myhealth", "star-fho"],
|
| 146 |
)
|
| 147 |
-
|
| 148 |
-
self.assertEqual(len(cites), 4)
|
| 149 |
# The false positive from the screenshot is gone.
|
| 150 |
self.assertNotIn("star-hospcash", ids)
|
| 151 |
|
| 152 |
-
def
|
| 153 |
-
"""A 5-policy shortlist
|
|
|
|
| 154 |
chunks = [
|
| 155 |
_chunk(f"p{i}", f"Policy Alpha {i}", "ins", 0.9 - i * 0.05, f"k{i}")
|
| 156 |
for i in range(5)
|
|
@@ -165,10 +172,9 @@ class TestProseMatchingPath(unittest.TestCase):
|
|
| 165 |
marked_policy_ids=[],
|
| 166 |
)
|
| 167 |
self.assertTrue(is_rec)
|
| 168 |
-
self.assertEqual(len(cites),
|
| 169 |
self.assertEqual(
|
| 170 |
-
[c["policy_id"] for c in cites],
|
| 171 |
-
["p0", "p1", "p2", "p3", "p4"],
|
| 172 |
)
|
| 173 |
|
| 174 |
|
|
|
|
| 20 |
`_build_recommendation_citations` returns a citation list that is exactly
|
| 21 |
the recommended policies — via explicit mark_recommendation ordering when
|
| 22 |
present, else by the policy names actually written in the reply prose —
|
| 23 |
+
with NO score-order fallback that resurrects un-named policies, and a
|
| 24 |
+
HARD CAP of 3 (the recommendation cards do not render past 3 — #28; the
|
| 25 |
+
3 kept are the strongest, best-first by the gate's fit rank).
|
| 26 |
|
| 27 |
Run:
|
| 28 |
cd /Users/rohitsar/Developer/Insurance\\ Sales\\ Bot
|
|
|
|
| 95 |
|
| 96 |
|
| 97 |
class TestMarkRecommendationPath(unittest.TestCase):
|
| 98 |
+
"""When the LLM calls mark_recommendation, those ids ARE the citation
|
| 99 |
+
set — best-first by gate rank, hard-capped at the 3 strongest (#28)."""
|
| 100 |
|
| 101 |
def test_exact_set_and_order_from_marked_ids(self) -> None:
|
| 102 |
marked = ["rs-multiplier", "rs-advtopup", "hdfc-myhealth", "star-fho"]
|
|
|
|
| 106 |
marked_policy_ids=marked,
|
| 107 |
)
|
| 108 |
self.assertTrue(is_rec)
|
| 109 |
+
# Hard ≤3 cap — the 3 strongest, best-first by gate rank; the 4th
|
| 110 |
+
# marked id (star-fho) is dropped by the cap.
|
| 111 |
+
self.assertEqual(
|
| 112 |
+
[c["policy_id"] for c in cites],
|
| 113 |
+
["rs-multiplier", "rs-advtopup", "hdfc-myhealth"],
|
| 114 |
+
)
|
| 115 |
+
self.assertLessEqual(len(cites), 3)
|
| 116 |
# The un-recommended high-scorer must NOT appear.
|
| 117 |
self.assertNotIn(
|
| 118 |
"star-hospcash", [c["policy_id"] for c in cites]
|
|
|
|
| 146 |
)
|
| 147 |
self.assertTrue(is_rec)
|
| 148 |
ids = [c["policy_id"] for c in cites]
|
| 149 |
+
# 4 policies are named in prose, but the cards hard-cap at the 3
|
| 150 |
+
# strongest (best-first by gate rank); star-fho (4th) is dropped.
|
| 151 |
self.assertEqual(
|
| 152 |
+
ids, ["rs-multiplier", "rs-advtopup", "hdfc-myhealth"]
|
|
|
|
| 153 |
)
|
| 154 |
+
self.assertEqual(len(cites), 3)
|
|
|
|
| 155 |
# The false positive from the screenshot is gone.
|
| 156 |
self.assertNotIn("star-hospcash", ids)
|
| 157 |
|
| 158 |
+
def test_count_hard_capped_at_3(self) -> None:
|
| 159 |
+
"""A 5-policy shortlist yields EXACTLY 3 cards (best-first) — the
|
| 160 |
+
recommendation cards do not render past 3 (#28)."""
|
| 161 |
chunks = [
|
| 162 |
_chunk(f"p{i}", f"Policy Alpha {i}", "ins", 0.9 - i * 0.05, f"k{i}")
|
| 163 |
for i in range(5)
|
|
|
|
| 172 |
marked_policy_ids=[],
|
| 173 |
)
|
| 174 |
self.assertTrue(is_rec)
|
| 175 |
+
self.assertEqual(len(cites), 3)
|
| 176 |
self.assertEqual(
|
| 177 |
+
[c["policy_id"] for c in cites], ["p0", "p1", "p2"]
|
|
|
|
| 178 |
)
|
| 179 |
|
| 180 |
|
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Guard for the recommendation-card EXTRACTION GATE (#29).
|
| 2 |
+
|
| 3 |
+
THE BUG (from a real production screenshot):
|
| 4 |
+
A recommended card rendered with the raw policy_id slug as its title
|
| 5 |
+
("manipalcigna__sarv…"), grade "N/A", body "No extraction available for
|
| 6 |
+
this policy.", and "Why this fits you: Data not indexed".
|
| 7 |
+
|
| 8 |
+
ROOT CAUSE:
|
| 9 |
+
`_scorecard_signal` / `_quality_seed_candidates` grade off the ~790-entry
|
| 10 |
+
CURATED layer, but the card UI renders from the EXTRACTED layer
|
| 11 |
+
(settings.EXTRACTED_DIR/*.json — the same set the marketplace shows).
|
| 12 |
+
Quality-seed injected curated-graded-but-not-extracted policies into the
|
| 13 |
+
candidate pool, so the LLM could recommend a policy whose card cannot
|
| 14 |
+
render.
|
| 15 |
+
|
| 16 |
+
THE CONTRACT THIS PINS:
|
| 17 |
+
A policy with no extracted corpus file is NEVER quality-seeded and is
|
| 18 |
+
ALWAYS dropped from the cited set, even if the LLM explicitly marks it —
|
| 19 |
+
so a broken "N/A / No extraction available" card can never reach the UI.
|
| 20 |
+
|
| 21 |
+
This file deliberately uses the REAL `_has_extraction` predicate (the
|
| 22 |
+
package-wide conftest autouse fixture stubs it True for the logic tests;
|
| 23 |
+
here we restore the real one so the gate itself is exercised).
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import sys
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
import pytest
|
| 32 |
+
|
| 33 |
+
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 34 |
+
if str(_REPO_ROOT) not in sys.path:
|
| 35 |
+
sys.path.insert(0, str(_REPO_ROOT))
|
| 36 |
+
|
| 37 |
+
from backend import brain_tools # noqa: E402
|
| 38 |
+
from backend.brain_tools import ( # noqa: E402
|
| 39 |
+
_has_extraction as _REAL_HAS_EXTRACTION,
|
| 40 |
+
_quality_seed_candidates,
|
| 41 |
+
)
|
| 42 |
+
from backend.config import settings # noqa: E402
|
| 43 |
+
from backend.single_brain import _build_recommendation_citations # noqa: E402
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _a_real_extracted_stem() -> str:
|
| 47 |
+
"""Any policy_id that genuinely has an extracted corpus file on disk."""
|
| 48 |
+
files = sorted(settings.EXTRACTED_DIR.glob("*.json"))
|
| 49 |
+
assert files, "no extracted corpus files — cannot test the gate"
|
| 50 |
+
return files[0].stem
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@pytest.fixture
|
| 54 |
+
def real_extraction(monkeypatch):
|
| 55 |
+
"""Override the conftest autouse stub: use the REAL predicate so the
|
| 56 |
+
gate's actual on-disk behaviour is what gets exercised here."""
|
| 57 |
+
monkeypatch.setattr(brain_tools, "_has_extraction", _REAL_HAS_EXTRACTION)
|
| 58 |
+
brain_tools._extraction_cache.clear()
|
| 59 |
+
brain_tools._qseed_cache.clear()
|
| 60 |
+
return _REAL_HAS_EXTRACTION
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_predicate_true_for_extracted_false_for_missing(real_extraction):
|
| 64 |
+
real = _a_real_extracted_stem()
|
| 65 |
+
assert brain_tools._has_extraction(real) is True
|
| 66 |
+
assert (
|
| 67 |
+
brain_tools._has_extraction("definitely__not-a-real-policy-xyz")
|
| 68 |
+
is False
|
| 69 |
+
)
|
| 70 |
+
assert brain_tools._has_extraction("") is False
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_non_extracted_policy_never_cited_even_when_marked(real_extraction):
|
| 74 |
+
"""The exact production failure: a marked policy with no extracted
|
| 75 |
+
corpus must be DROPPED, not rendered as an N/A card."""
|
| 76 |
+
real = _a_real_extracted_stem()
|
| 77 |
+
chunks = [
|
| 78 |
+
{
|
| 79 |
+
"chunk_id": "real1",
|
| 80 |
+
"policy_id": real,
|
| 81 |
+
"policy_name": "Real Extracted Plan",
|
| 82 |
+
"insurer_slug": real.split("__", 1)[0] if "__" in real else "x",
|
| 83 |
+
"doc_type": "policy",
|
| 84 |
+
"source_url": f"https://example.com/{real}.pdf",
|
| 85 |
+
"score": 0.9,
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"chunk_id": "ghost1",
|
| 89 |
+
"policy_id": "manipalcigna__sarvah-param-NOT-EXTRACTED",
|
| 90 |
+
"policy_name": "Ghost Plan",
|
| 91 |
+
"insurer_slug": "manipalcigna",
|
| 92 |
+
"doc_type": "policy",
|
| 93 |
+
"source_url": "",
|
| 94 |
+
"score": 0.95, # higher score — must STILL be dropped
|
| 95 |
+
},
|
| 96 |
+
]
|
| 97 |
+
cites, is_rec = _build_recommendation_citations(
|
| 98 |
+
reply_text="See Real Extracted Plan and Ghost Plan.",
|
| 99 |
+
retrieved_chunks_all=chunks,
|
| 100 |
+
marked_policy_ids=[
|
| 101 |
+
"manipalcigna__sarvah-param-NOT-EXTRACTED",
|
| 102 |
+
real,
|
| 103 |
+
],
|
| 104 |
+
)
|
| 105 |
+
assert is_rec is True
|
| 106 |
+
ids = [c["policy_id"] for c in cites]
|
| 107 |
+
assert "manipalcigna__sarvah-param-NOT-EXTRACTED" not in ids
|
| 108 |
+
assert ids == [real]
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_quality_seed_only_emits_renderable_policies(real_extraction):
|
| 112 |
+
"""Every quality-seeded candidate must have an extracted file — so it
|
| 113 |
+
can never inject a policy whose card renders as N/A."""
|
| 114 |
+
seeded = _quality_seed_candidates(profile=None, limit=25)
|
| 115 |
+
assert seeded, "quality-seed returned nothing — basket starved"
|
| 116 |
+
offenders = [
|
| 117 |
+
c["policy_id"]
|
| 118 |
+
for c in seeded
|
| 119 |
+
if not _REAL_HAS_EXTRACTION(c.get("policy_id") or "")
|
| 120 |
+
]
|
| 121 |
+
assert not offenders, (
|
| 122 |
+
f"quality-seed emitted non-renderable policies: {offenders}"
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
if __name__ == "__main__":
|
| 127 |
+
raise SystemExit(pytest.main([__file__, "-v"]))
|
|
@@ -14,13 +14,20 @@ desired_sum_insured_inr → existing_cover_inr → ₹10L. A user who stated a
|
|
| 14 |
|
| 15 |
Fix: estimate_premium_band() resolves SI via resolve_profile_sum_insured()
|
| 16 |
(the single source of truth shared with the panel/widget), prices the whole
|
| 17 |
-
basket at that SI, and
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
"""
|
| 21 |
|
| 22 |
from backend.premium_calculator import (
|
| 23 |
_DEFAULT_BAND_POLICY_IDS,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
bulk_estimate,
|
| 25 |
estimate,
|
| 26 |
estimate_premium_band,
|
|
@@ -100,15 +107,30 @@ import pytest
|
|
| 100 |
"parents_age_max": 72, "parents_has_ped": True}, "parents-on-cover"),
|
| 101 |
],
|
| 102 |
)
|
| 103 |
-
def
|
| 104 |
band = estimate_premium_band(dict(profile))
|
| 105 |
-
points = _panel_points_for(profile)
|
| 106 |
assert points, f"no basket points for {label}"
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
|
| 114 |
def test_band_exposes_resolved_si_for_panel_alignment():
|
|
|
|
| 14 |
|
| 15 |
Fix: estimate_premium_band() resolves SI via resolve_profile_sum_insured()
|
| 16 |
(the single source of truth shared with the panel/widget), prices the whole
|
| 17 |
+
basket at that SI, and reports the p25–p75 INTERQUARTILE of the basket as
|
| 18 |
+
the band (directionally rounded) — the honest "what similar profiles
|
| 19 |
+
typically pay" range. Raw min–max across the heterogeneous basket spans
|
| 20 |
+
~4-5x and renders as a useless, broken-looking band (KI broken-band fix);
|
| 21 |
+
a specific plan's per-settings panel point may sit inside or just outside
|
| 22 |
+
this typical band, which is expected and correct.
|
| 23 |
"""
|
| 24 |
|
| 25 |
from backend.premium_calculator import (
|
| 26 |
_DEFAULT_BAND_POLICY_IDS,
|
| 27 |
+
_ceil_to_500,
|
| 28 |
+
_floor_to_500,
|
| 29 |
+
_median,
|
| 30 |
+
_percentile,
|
| 31 |
bulk_estimate,
|
| 32 |
estimate,
|
| 33 |
estimate_premium_band,
|
|
|
|
| 107 |
"parents_age_max": 72, "parents_has_ped": True}, "parents-on-cover"),
|
| 108 |
],
|
| 109 |
)
|
| 110 |
+
def test_header_band_is_p25_p75_interquartile(profile, label):
|
| 111 |
band = estimate_premium_band(dict(profile))
|
| 112 |
+
points = _panel_points_for(profile) # sorted basket points
|
| 113 |
assert points, f"no basket points for {label}"
|
| 114 |
+
# New contract: the band edges ARE the directionally-rounded p25 / p75
|
| 115 |
+
# of the basket — the interquartile "typical range", NOT raw min-max.
|
| 116 |
+
assert band["min_inr"] == _floor_to_500(_percentile(points, 25)), label
|
| 117 |
+
assert band["max_inr"] == _ceil_to_500(_percentile(points, 75)), label
|
| 118 |
+
# min ≤ median ≤ max, all positive.
|
| 119 |
+
assert 0 < band["min_inr"] <= band["median_inr"] <= band["max_inr"], label
|
| 120 |
+
# The typical (median) plan lies inside the band.
|
| 121 |
+
med = _median(points)
|
| 122 |
+
assert band["min_inr"] <= med <= band["max_inr"], (
|
| 123 |
+
f"{label}: median basket point ₹{med:,} fell outside the typical "
|
| 124 |
+
f"band ₹{band['min_inr']:,}–₹{band['max_inr']:,}"
|
| 125 |
+
)
|
| 126 |
+
# And it is materially TIGHTER than the raw min-max envelope — the
|
| 127 |
+
# entire point of the fix (the heterogeneous basket's raw spread is
|
| 128 |
+
# ~4-5x; the interquartile band must be a strict subset of it).
|
| 129 |
+
raw_lo = _floor_to_500(min(points))
|
| 130 |
+
raw_hi = _ceil_to_500(max(points))
|
| 131 |
+
assert band["min_inr"] >= raw_lo, label
|
| 132 |
+
assert band["max_inr"] <= raw_hi, label
|
| 133 |
+
assert (band["max_inr"] - band["min_inr"]) <= (raw_hi - raw_lo), label
|
| 134 |
|
| 135 |
|
| 136 |
def test_band_exposes_resolved_si_for_panel_alignment():
|