Spaces:
Sleeping
Sleeping
fix: deductible gating (#29), existing-cover steering + dangling-turn guard (#30), compare-modal full reviews (#32)
3fcae97 | """Retrieval-side filters + guards (A3 / A6 audit fixes). | |
| This module is a SIDECAR to the retrieval path (`rag/retrieve.py` and the | |
| single-brain tool layer in `backend/brain_tools.py`). It is deliberately a | |
| separate file so retrieval-correctness work stays isolated from the | |
| turn-handling code. | |
| Public API: | |
| apply_profile_filter(chunks, profile) -> list[RetrievedChunk] | |
| Drop chunks for policies the user is demographically ineligible for. | |
| apply_eligibility_filter(chunks, profile) -> list[RetrievedChunk] | |
| Drop chunks the user is STRUCTURALLY ineligible for / that clearly | |
| contradict an explicit stated need (KI-278 / KI-279): | |
| - top-up / super-top-up when the user has no existing base cover | |
| - plans whose max sum-insured cannot meet the requested SI | |
| - high co-pay plans when the user explicitly wants zero co-pay | |
| - fixed-benefit products (hospital daily cash / personal accident / | |
| critical illness / cancer) when the profile clearly signals the | |
| user wants comprehensive INDEMNITY cover (KI-279) | |
| rank_by_profile_fit(chunks, profile) -> list[RetrievedChunk] | |
| Re-order surviving chunks so the plans that best match the stated | |
| needs (grade, co-pay, SI headroom) rank above weak-fit plans whose | |
| only advantage was raw cosine similarity (KI-278). | |
| bypass_cosine_for_exact_match(chunks, query) -> list[RetrievedChunk] | None | |
| If the query contains an IRDAI UIN or an exact policy name, return a | |
| substring-matched chunk list (caller can use this instead of cosine). | |
| Returns None if no exact-match signal in the query. | |
| empty_retrieval_guard(chunks, intent) -> dict | None | |
| Return a structured "empty_retrieval" signal when filtered chunk count | |
| is below the minimum for a recommendation intent. | |
| enforce_citation_grounding(chunks) -> list[RetrievedChunk] | |
| Reject chunks missing policy_id / policy_name / chunk_offset. | |
| dedup_by_policy(chunks) -> list[RetrievedChunk] | |
| Within a top-K, keep highest-score chunk per policy_id. | |
| All functions are pure / side-effect-free; safe to call from the | |
| single-brain tool layer or from rag/retrieve.py without import-cycle risk. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from typing import Any, Iterable, Optional | |
| # Shared canonical-identity / dedup rule. The same | |
| # UIN-primary + product_key invariant the marketplace endpoint uses | |
| # (main.py /api/policies/all). Imported (not reimplemented) so the | |
| # recommender and the marketplace agree on "the same policy". The module | |
| # is dependency-free so this stays import-cycle-safe. | |
| from backend.policy_identity import canonical_key | |
| # We avoid importing RetrievedChunk at module-import time to keep this file | |
| # import-cycle-safe. Instead we type-duck on attributes. | |
| # --------------------------------------------------------------------------- | |
| # Constants β keep tunables here so they're greppable from one place. | |
| # --------------------------------------------------------------------------- | |
| # Age tolerance for the profile pre-filter. The catalog's min_entry_age / | |
| # max_entry_age is the regulator-filed entry age, but real underwriters allow | |
| # a small grace band; we mirror that grace here so we don't accidentally | |
| # drop a perfectly fine policy because the user is one year off the edge. | |
| PROFILE_AGE_TOLERANCE = 2 | |
| # A "senior-only" policy is one whose marketing/eligibility makes it | |
| # inappropriate for adults under 50. We detect this from the policy name | |
| # AND from the min_entry_age metadata (>=60 β senior-only). | |
| SENIOR_ONLY_NAME_RE = re.compile( | |
| r"\b(senior|red\s*carpet|silver|elder|varisht|varistha|" | |
| r"sixty\s*plus|60\s*plus|seniority|golden\s*years)\b", | |
| flags=re.IGNORECASE, | |
| ) | |
| # Adult-only / young-adult-only plans typically cap at 50 or 55 and have | |
| # no senior variant. If the user is >=60 these are inappropriate | |
| # (they need a senior variant of the SAME insurer/family instead). | |
| ADULT_ONLY_NAME_RE = re.compile( | |
| r"\b(young\s*star|young\s*adult|millennial|gen\s*z|under\s*45|" | |
| r"early\s*career|first\s*time)\b", | |
| flags=re.IGNORECASE, | |
| ) | |
| # Maternity-themed policies. If the profile has no female adult AND no | |
| # maternity goal, these are noise. | |
| MATERNITY_NAME_RE = re.compile( | |
| r"\b(maternity|mother\s*&?\s*baby|mother\s*to\s*be|" | |
| r"new\s*born|joy|stork|baby\s*shield|pregnancy)\b", | |
| flags=re.IGNORECASE, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Eligibility / profile-fit tunables | |
| # --------------------------------------------------------------------------- | |
| # A plan is a TOP-UP / SUPER-TOP-UP (only useful ALONGSIDE an existing base | |
| # policy or above a large out-of-pocket deductible) when ANY of these hold: | |
| # 1. policy_type_indemnity_or_fixed contains "top_up" / "top-up" / "super" | |
| # 2. the policy NAME matches TOP_UP_NAME_RE | |
| # 3. it carries a large aggregate `deductible_amount` (the de-facto base | |
| # cover the user must already hold) β see TOP_UP_DEDUCTIBLE_FLOOR_INR | |
| TOP_UP_NAME_RE = re.compile( | |
| r"\b(super[\s\-]?top[\s\-]?up|top[\s\-]?up|topup|super[\s\-]?top)\b", | |
| flags=re.IGNORECASE, | |
| ) | |
| # An aggregate deductible at/above this rupee floor means the plan only pays | |
| # AFTER the insured has already spent this much (from a base policy or | |
| # pocket) β i.e. it is functionally a top-up and unusable as a sole first | |
| # policy. βΉ1,00,000 is conservative: real top-ups deductibles start ~βΉ2-3L; | |
| # a genuine non-top-up plan never carries a six-figure aggregate deductible. | |
| TOP_UP_DEDUCTIBLE_FLOOR_INR = 100_000 | |
| # When the user explicitly wants zero co-pay AND is not income-constrained | |
| # (can afford full cover), any plan whose mandatory co-payment exceeds this | |
| # percentage is a hard mismatch and is dropped. A 0 here means "drop ANY | |
| # plan with a non-zero mandatory co-pay for a strict zero-copay user". | |
| ZERO_COPAY_USER_MAX_COPAY_PCT = 0 | |
| # Even when the user did NOT explicitly demand zero co-pay, a punitive | |
| # co-pay (>= this) is dropped because no metric recovers from a 50% claim | |
| # haircut for a metro / high-income first-time buyer. | |
| PUNITIVE_COPAY_PCT = 40 | |
| # SI headroom: a plan is SI-eligible only if its largest sum-insured option | |
| # is at least the requested SI. (We do NOT require an exact tier β most | |
| # insurers interpolate; but a plan whose ceiling is below the ask cannot | |
| # deliver the cover the user said they need.) | |
| # Minimum chunk count to attempt a recommendation. Below this we ask for | |
| # one more clarifier instead of letting the brain hallucinate. | |
| MIN_CHUNKS_FOR_RECOMMENDATION = 3 | |
| # Recommendation-style intents β the empty-retrieval guard fires only on these. | |
| # Other intents (faq, regulatory, smalltalk) tolerate sparse retrieval. | |
| _RECOMMENDATION_INTENTS = { | |
| "recommend", | |
| "recommendation", | |
| "compare", | |
| "comparison", | |
| "suggest", | |
| "shortlist", | |
| "best_policy", | |
| "pick_for_me", | |
| } | |
| # IRDAI UIN pattern. Real UINs look like "IRDA/HLT/HDFC/V.I/188/14-15" | |
| # or "IRDAI/HLT/HDFC/V.I/188/14-15" β we accept both. | |
| UIN_RE = re.compile( | |
| r"\b(?:IRDAI?|UIN)[/:]\s*[A-Z0-9./\-]{6,}", | |
| flags=re.IGNORECASE, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Fixed-benefit exclusion for comprehensive-indemnity intent | |
| # --------------------------------------------------------------------------- | |
| # A FIXED-BENEFIT product (hospital daily cash, personal accident, critical | |
| # illness, cancer / other defined-benefit) pays a fixed lump-sum / per-day | |
| # amount rather than reimbursing actual hospitalisation expenses. It is a | |
| # legitimate SUPPLEMENT but a wrong PRIMARY recommendation for a buyer who | |
| # clearly wants comprehensive INDEMNITY cover (their first health policy, | |
| # a desired sum insured, no existing base cover). This regex mirrors | |
| # scorecard._FIXED_BENEFIT_RE so classification stays consistent across the | |
| # scorecard and the retrieval filter (single behavioural contract). | |
| FIXED_BENEFIT_NAME_RE = re.compile( | |
| r"hospital[\s_-]*cash|hospi[\s_-]*cash|daily[\s_-]*cash|" | |
| r"personal[\s_-]*accident|critical[\s_-]*illness|" | |
| r"criti[\s_-]*(?:care|medicare)|\bcancer\b|wellsurance|hospi[\s_-]*care", | |
| flags=re.IGNORECASE, | |
| ) | |
| # Tokens in policy_type / policy_type_indemnity_or_fixed that mark a | |
| # fixed-benefit / defined-benefit product (NOT indemnity reimbursement). | |
| _FIXED_BENEFIT_TYPE_TOKENS = ( | |
| "fixed", "benefit", "defined", | |
| "hospital_cash", "hospital cash", "daily_cash", "daily cash", | |
| "personal_accident", "personal accident", | |
| "critical_illness", "critical illness", "cancer", | |
| ) | |
| # Goal phrases that mean the user EXPLICITLY wants a supplement / add-on / | |
| # top-up / fixed-benefit product β the comprehensive-indemnity intent must | |
| # NOT fire for these (we must keep showing them PA / CI / hospital-cash / | |
| # top-up products on purpose). | |
| _SUPPLEMENT_GOAL_TOKENS = ( | |
| "supplement", "add-on", "add on", "addon", "rider", | |
| "top-up", "top up", "topup", "super top", | |
| "critical illness", "critical-illness", "ci only", "ci cover", | |
| "personal accident", "accident cover", "accident plan", | |
| "hospital cash", "daily cash", "cash plan", "cancer cover", | |
| "cancer plan", "defined benefit", "fixed benefit", | |
| "alongside", "in addition to", "on top of", | |
| ) | |
| # Goal phrases that POSITIVELY signal a primary comprehensive health plan. | |
| _COMPREHENSIVE_GOAL_TOKENS = ( | |
| "first", "new policy", "fresh", "primary", "comprehensive", | |
| "general health", "health cover", "main cover", "base cover", | |
| "family floater", "indemnity", "hospitalisation", "hospitalization", | |
| "cover my", "protect my family", "medical cover", "buy health", | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Unified recommendation-fit gate tunables | |
| # --------------------------------------------------------------------------- | |
| # Recommendation-fit is SYSTEMIC: the cited-card list and the advisory prose | |
| # are gated by the SAME fitness logic. Two hard gates (entry-age for the | |
| # INSURED person; required feature = maternity/newborn) plus ranking signals | |
| # for grade/rank ordering and the cost-objective lead. | |
| # Entry-age grace. The catalog's max_entry_age is the regulator-filed entry | |
| # age; underwriters allow a small grace band (mirrors PROFILE_AGE_TOLERANCE). | |
| ENTRY_AGE_TOLERANCE = 2 | |
| # Goal phrases that mean COST is the dominant objective (P5). When present, | |
| # ranking must not let a pricier plan win purely on cosine β the lowest-cost | |
| # *appropriate* plan leads. We proxy "cost" by (low/zero co-pay surcharge + | |
| # scorecard Cost Predictability already folded into _overall_score); the | |
| # explicit signal here flips a stronger cosine-discount so a cheap plan is | |
| # not buried under a higher-cosine pricier one. | |
| _COST_OBJECTIVE_TOKENS = ( | |
| "cost_optimize", "cheapest", "cheap", "lowest premium", "low premium", | |
| "budget", "affordable", "tight", "money is tight", "save money", | |
| "least expensive", "minimum premium", "low cost", | |
| ) | |
| # Profile/goal phrases that say the policy INSURES senior parents (so the | |
| # entry-age gate must use the PARENTS' age, not the paying child's age). | |
| _PARENTS_COVER_TOKENS = ( | |
| "parent", "parents", "mother", "father", "mom", "dad", | |
| "senior citizen", "senior citizens", "elderly", | |
| ) | |
| # Explicit maternity / newborn requirement phrases. When the profile states | |
| # this need, plans whose facts CONFIRM maternity/newborn rank above those | |
| # that do not (and unverified ones are ranked strictly below confirmed). | |
| _MATERNITY_NEED_TOKENS = ( | |
| "maternity", "pregnan", "newborn", "new born", "new-born", | |
| "delivery cover", "childbirth", "planning a child", "planning another", | |
| "expecting", "baby cover", "having a baby", | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def _meta(chunk: Any) -> dict: | |
| """Pull metadata off a chunk regardless of whether the caller hands us a | |
| RetrievedChunk dataclass or a raw dict. RetrievedChunk stores fields as | |
| top-level attributes; ingestion-side code sometimes passes a dict.""" | |
| if isinstance(chunk, dict): | |
| return chunk | |
| out = {} | |
| for k in ( | |
| "policy_id", "policy_name", "insurer_slug", "doc_type", | |
| "chunk_idx", "chunk_offset", "min_entry_age", "max_entry_age", | |
| "score", "text", | |
| ): | |
| if hasattr(chunk, k): | |
| out[k] = getattr(chunk, k) | |
| return out | |
| def _meta_full(chunk: Any) -> dict: | |
| """Like `_meta` but also surfaces the KI-278 enriched structured-fact | |
| keys (policy_type_indemnity_or_fixed / deductible_amount / | |
| co_payment_pct / sum_insured_options / _grade / _overall_score). | |
| `brain_tools.retrieve_policies` passes plain dicts, so the common path | |
| is just `return chunk`. For dataclass chunks we duck-type the union of | |
| citation + enriched fields so the eligibility/ranking rules still work | |
| if a future caller hands us a dataclass. | |
| """ | |
| if isinstance(chunk, dict): | |
| return chunk | |
| out = _meta(chunk) | |
| for k in ( | |
| "policy_type_indemnity_or_fixed", "deductible_amount", | |
| "co_payment_pct", "sum_insured_options", "_grade", "_overall_score", | |
| # KI-280 β unified-gate signals (entry-age / required-feature / | |
| # canonical dedup identity). | |
| "uin_code", "max_entry_age", "min_entry_age", | |
| "maternity_coverage", "newborn_coverage", "policy_type", | |
| ): | |
| if hasattr(chunk, k): | |
| out[k] = getattr(chunk, k) | |
| return out | |
| def _profile_get(profile: Any, key: str, default=None): | |
| """Profile may be a dataclass (needs_finder.Profile) OR a dict β accept either.""" | |
| if profile is None: | |
| return default | |
| if isinstance(profile, dict): | |
| return profile.get(key, default) | |
| return getattr(profile, key, default) | |
| def _profile_has_female_adult(profile: Any) -> bool: | |
| """Heuristic: profile covers a female adult if dependents includes spouse | |
| (the user might be the female adult, or spouse may be). We treat | |
| 'spouse' tokens as a positive signal and 'self+spouse', 'family' shapes | |
| likewise. Conservative: return True when uncertain (better to keep | |
| maternity chunks than to wrongly drop them).""" | |
| dep = _profile_get(profile, "dependents") or "" | |
| if not isinstance(dep, str): | |
| return True | |
| dep_lower = dep.lower() | |
| if any(tok in dep_lower for tok in ("spouse", "wife", "family", "kids", "child")): | |
| return True | |
| # No spouse / no family signal AND explicit self-only β no female adult | |
| if dep_lower in ("self", "self_only", "individual", "just_me"): | |
| return False | |
| return True # default permissive | |
| def _profile_has_maternity_goal(profile: Any) -> bool: | |
| goal = _profile_get(profile, "primary_goal") or "" | |
| if not isinstance(goal, str): | |
| return False | |
| return "maternity" in goal.lower() or "pregnan" in goal.lower() or "baby" in goal.lower() | |
| # --------------------------------------------------------------------------- | |
| # (1) Profile-fit pre-filter (A3 / A6) | |
| # --------------------------------------------------------------------------- | |
| def apply_profile_filter(chunks: Iterable[Any], profile: Any) -> list[Any]: | |
| """Drop chunks that are demographically inappropriate for this user. | |
| Rules: | |
| - min_entry_age > age + 2 β drop | |
| - max_entry_age < age - 2 β drop | |
| - senior-only plan AND age < 50 β drop (user is too young) | |
| - adult-only plan AND age >= 60 β drop (user needs senior variant) | |
| - maternity plan AND no female adult / no maternity goal β drop | |
| Profile chunks (doc_type == 'profile') and regulatory chunks | |
| (doc_type == 'regulatory' / 'review') are NEVER dropped here β those | |
| aren't policies and the demographic rules don't apply. | |
| Conservative on missing data: if a chunk doesn't expose min/max entry | |
| age metadata, we DO NOT drop it on those rules (we still apply the | |
| name-based senior/adult/maternity rules where the name pattern matches). | |
| """ | |
| chunks_list = list(chunks) | |
| if not chunks_list: | |
| return chunks_list | |
| # The demographic gates (numeric age-range, senior-only, adult-only) | |
| # reason about the age of the person the policy actually INSURES, not | |
| # the payer's. When the policy covers parents (~70), using the paying | |
| # child's age (e.g. 36) would wrongly DROP a senior-citizen plan | |
| # ("min_entry_age>=60 AND payer<50") and wrongly KEEP a max-entry-65 | |
| # plan. `_oldest_insured_age` returns the eldest parent's age when | |
| # insuring parents, else the profile's own age. Falls back to the raw | |
| # profile age if no resolved signal. | |
| _resolved_age = _oldest_insured_age(profile) | |
| if _resolved_age is None: | |
| _resolved_age = _profile_get(profile, "age") | |
| age = _resolved_age | |
| # If age unknown, only the maternity rule can fire β keep everything else. | |
| has_age = isinstance(age, int) | |
| has_female_adult = _profile_has_female_adult(profile) | |
| maternity_goal = _profile_has_maternity_goal(profile) | |
| kept: list[Any] = [] | |
| for ch in chunks_list: | |
| m = _meta(ch) | |
| doc_type = (m.get("doc_type") or "").lower() | |
| # Never drop non-policy chunks via demographic filter. | |
| # #52 β `user_upload` is a globally-visible uploaded marketplace | |
| # doc: a Q&A TARGET, not a demographically-ranked recommendable | |
| # corpus policy. Exempt it exactly like regulatory/review so a | |
| # question literally about the uploaded document isn't dropped | |
| # because the (often anonymous) asker's age/eligibility doesn't | |
| # match the uploaded plan. | |
| if doc_type in ("profile", "regulatory", "review", "user_upload"): | |
| kept.append(ch) | |
| continue | |
| name = (m.get("policy_name") or "").strip() | |
| min_age = m.get("min_entry_age") | |
| max_age = m.get("max_entry_age") | |
| # Numeric age-range gate | |
| if has_age: | |
| try: | |
| if isinstance(min_age, (int, float)) and min_age > age + PROFILE_AGE_TOLERANCE: | |
| continue | |
| if isinstance(max_age, (int, float)) and max_age < age - PROFILE_AGE_TOLERANCE: | |
| continue | |
| except TypeError: | |
| pass # metadata corruption β fall through to name rules | |
| # Senior-only inferred from name OR from min_entry_age >= 60 | |
| is_senior_only = bool(SENIOR_ONLY_NAME_RE.search(name)) or ( | |
| isinstance(min_age, (int, float)) and min_age >= 60 | |
| ) | |
| if is_senior_only and has_age and age < 50: | |
| continue | |
| # Adult-only inferred from name (no metadata signal exists for this). | |
| # If user is 60+ AND policy looks adult-only AND has max_age < 60, drop. | |
| is_adult_only_name = bool(ADULT_ONLY_NAME_RE.search(name)) | |
| if has_age and age >= 60 and is_adult_only_name: | |
| continue | |
| if has_age and age >= 60 and isinstance(max_age, (int, float)) and max_age < 60: | |
| continue | |
| # Maternity gate β only drop if BOTH conditions fail | |
| if MATERNITY_NAME_RE.search(name) and not maternity_goal and not has_female_adult: | |
| continue | |
| kept.append(ch) | |
| return kept | |
| # --------------------------------------------------------------------------- | |
| # (1b) Eligibility filter (KI-278) β structural mismatch hard-drop | |
| # --------------------------------------------------------------------------- | |
| def _as_int(v: Any) -> Optional[int]: | |
| """Best-effort int coercion (bool excluded β it's an int subclass).""" | |
| if v is None or isinstance(v, bool): | |
| return None | |
| if isinstance(v, (int, float)): | |
| return int(v) | |
| try: | |
| return int(str(v).strip()) | |
| except (TypeError, ValueError): | |
| return None | |
| def _max_sum_insured(chunk_meta: dict) -> Optional[int]: | |
| """Largest SI the policy can offer, from the enriched | |
| `sum_insured_options` list. None when the fact is absent (don't drop on | |
| missing data).""" | |
| opts = chunk_meta.get("sum_insured_options") | |
| if not opts: | |
| return None | |
| nums: list[int] = [] | |
| if isinstance(opts, (list, tuple)): | |
| for o in opts: | |
| n = _as_int(o) | |
| if n is not None: | |
| nums.append(n) | |
| else: | |
| n = _as_int(opts) | |
| if n is not None: | |
| nums.append(n) | |
| return max(nums) if nums else None | |
| def _is_top_up(chunk_meta: dict) -> bool: | |
| """A plan is a top-up / super-top-up when ANY structural signal fires: | |
| 1. policy_type_indemnity_or_fixed says top_up / super | |
| 2. the policy name matches TOP_UP_NAME_RE | |
| 3. it carries a large aggregate deductible (>= TOP_UP_DEDUCTIBLE_FLOOR) | |
| """ | |
| ptype = str(chunk_meta.get("policy_type_indemnity_or_fixed") or "").lower() | |
| if "top" in ptype or "super_top" in ptype or "super-top" in ptype: | |
| return True | |
| name = str(chunk_meta.get("policy_name") or "") | |
| if TOP_UP_NAME_RE.search(name): | |
| return True | |
| ded = _as_int(chunk_meta.get("deductible_amount")) | |
| if ded is not None and ded >= TOP_UP_DEDUCTIBLE_FLOOR_INR: | |
| return True | |
| return False | |
| def _user_has_base_cover(profile: Any) -> bool: | |
| """True when the user already holds base health cover. A first-time | |
| buyer (existing_cover_inr == 0 / None / falsy, OR primary_goal looks | |
| like a first purchase) has NO base cover, so a top-up is unusable.""" | |
| existing = _as_int(_profile_get(profile, "existing_cover_inr")) | |
| if existing and existing > 0: | |
| return True | |
| goal = str(_profile_get(profile, "primary_goal") or "").lower() | |
| # "first_buy" / "first policy" / "first-time" β definitively no base. | |
| if any(tok in goal for tok in ("first", "new policy", "fresh")): | |
| return False | |
| # No explicit existing cover and no first-buy signal β treat as no base | |
| # cover (conservative: a top-up only helps someone who KNOWS they have a | |
| # base; absent that signal, don't surface an unusable product). | |
| return False | |
| def _user_wants_zero_copay(profile: Any) -> bool: | |
| copay = _as_int(_profile_get(profile, "copay_pct")) | |
| return copay is not None and copay <= ZERO_COPAY_USER_MAX_COPAY_PCT | |
| # --------------------------------------------------------------------------- | |
| # KI-279 β fixed-benefit classification + comprehensive-indemnity intent | |
| # --------------------------------------------------------------------------- | |
| def _is_fixed_benefit_chunk(chunk_meta: dict) -> bool: | |
| """True when the policy is a FIXED-BENEFIT / defined-benefit product | |
| (hospital daily cash, personal accident, critical illness, cancer) | |
| rather than an indemnity hospitalisation-reimbursement plan. | |
| Detection mirrors scorecard._is_fixed_benefit so the scorecard and the | |
| retrieval filter classify identically (one behavioural contract): | |
| 1. The canonical type key `policy_type_indemnity_or_fixed` contains a | |
| fixed / benefit / defined token. | |
| 2. Fallback β the raw catalog key `policy_type` contains a | |
| fixed-benefit token (hospital_cash / personal_accident / ...). | |
| Critically, several curated files (e.g. Star Hospital Cash) carry | |
| the type ONLY here. | |
| 3. Last-resort β the policy_id / policy_name matches | |
| FIXED_BENEFIT_NAME_RE (hospital cash / daily cash / PA / CI / | |
| cancer / wellsurance). | |
| Conservative: if NO type signal exists AND the name doesn't match, the | |
| policy is treated as indemnity (not dropped). | |
| """ | |
| # Canonical curated key first. | |
| canon = str(chunk_meta.get("policy_type_indemnity_or_fixed") or "").lower() | |
| if canon and any(tok in canon for tok in _FIXED_BENEFIT_TYPE_TOKENS): | |
| return True | |
| if canon and "indemnity" in canon: | |
| return False # explicitly indemnity β never a false positive | |
| # Raw catalog type key (the Star-Hospital-Cash case). | |
| raw = str(chunk_meta.get("policy_type") or "").lower() | |
| if raw and any(tok in raw for tok in _FIXED_BENEFIT_TYPE_TOKENS): | |
| return True | |
| if raw and "indemnity" in raw: | |
| return False | |
| # Name / id fallback. | |
| blob = f"{chunk_meta.get('policy_id','')} {chunk_meta.get('policy_name','')}" | |
| return bool(FIXED_BENEFIT_NAME_RE.search(blob)) | |
| def _user_explicitly_wants_supplement(profile: Any) -> bool: | |
| """True only when primary_goal explicitly names a supplement / add-on / | |
| top-up / PA / CI / hospital-cash / cancer / defined-benefit product. | |
| Used to SUPPRESS the fixed-benefit ranking demotion (we must not bury a | |
| product the user explicitly asked for).""" | |
| goal = str(_profile_get(profile, "primary_goal") or "").lower().strip() | |
| if not goal: | |
| return False | |
| return any(tok in goal for tok in _SUPPLEMENT_GOAL_TOKENS) | |
| def _insures_parents(profile: Any) -> bool: | |
| """True when the policy is being bought to cover the user's PARENTS / | |
| seniors (so the entry-age gate must test the parents' age, not the | |
| paying child's). Signals (any): parents_to_insure flag, a | |
| parents_age_max value, dependents mentioning parents, or a | |
| parents-cover / senior-citizen primary_goal.""" | |
| if _profile_get(profile, "parents_to_insure") is True: | |
| return True | |
| if _as_int(_profile_get(profile, "parents_age_max")) is not None: | |
| return True | |
| dep = str(_profile_get(profile, "dependents") or "").lower() | |
| goal = str(_profile_get(profile, "primary_goal") or "").lower() | |
| blob = f"{dep} {goal}" | |
| return any(tok in blob for tok in _PARENTS_COVER_TOKENS) | |
| def _oldest_insured_age(profile: Any) -> Optional[int]: | |
| """The age of the OLDEST person the policy must accept at entry. | |
| When the policy insures parents, that is `parents_age_max` (the eldest | |
| parent). Otherwise it is the profile's own `age`. Returns None when no | |
| usable age signal exists (the gate then stays conservative and does not | |
| drop on missing data β same philosophy as apply_profile_filter). | |
| """ | |
| if _insures_parents(profile): | |
| pa = _as_int(_profile_get(profile, "parents_age_max")) | |
| if pa is not None: | |
| return pa | |
| # Insures parents but their age unknown β fall through to own age | |
| # only if it is itself senior; otherwise None (don't gate blind). | |
| own = _as_int(_profile_get(profile, "age")) | |
| return own if (own is not None and own >= 60) else None | |
| return _as_int(_profile_get(profile, "age")) | |
| def _profile_requires_maternity(profile: Any) -> bool: | |
| """True when the profile EXPLICITLY needs maternity / newborn cover. | |
| Checks primary_goal first (the strongest signal β single_brain folds | |
| 'maternity' into the goal), then a dedicated maternity flag if present. | |
| Conservative: only fires on an explicit token, never inferred from a | |
| female adult alone (that would over-trigger and wrongly demote good | |
| plans for users who never asked for maternity).""" | |
| if _profile_has_maternity_goal(profile): | |
| return True | |
| goal = str(_profile_get(profile, "primary_goal") or "").lower() | |
| if any(tok in goal for tok in _MATERNITY_NEED_TOKENS): | |
| return True | |
| # A dedicated boolean slot, if a future Profile grows one. | |
| for key in ("needs_maternity", "maternity_required", "wants_maternity"): | |
| if _profile_get(profile, key) is True: | |
| return True | |
| return False | |
| def _cost_is_primary_objective(profile: Any) -> bool: | |
| """True when the user's stated objective is dominated by cost (P5 β | |
| 'cheapest decent cover, lowest premium is my top priority').""" | |
| goal = str(_profile_get(profile, "primary_goal") or "").lower() | |
| band = str(_profile_get(profile, "budget_band") or "").lower() | |
| blob = f"{goal} {band}" | |
| return any(tok in blob for tok in _COST_OBJECTIVE_TOKENS) | |
| def _chunk_confirms_maternity(chunk_meta: dict) -> Optional[bool]: | |
| """Tri-state: True (facts confirm maternity OR newborn cover), False | |
| (facts explicitly say neither), None (unverified β no fact present). | |
| None must rank BELOW True but is NOT hard-dropped (the curated facts | |
| are incomplete; a hard drop would wrongly hide good plans whose | |
| maternity flag simply wasn't curated yet).""" | |
| mat = chunk_meta.get("maternity_coverage") | |
| nb = chunk_meta.get("newborn_coverage") | |
| def _tri(v): | |
| if isinstance(v, bool): | |
| return v | |
| if isinstance(v, str): | |
| s = v.strip().lower() | |
| if s in ("true", "yes", "y", "covered"): | |
| return True | |
| if s in ("false", "no", "n", "not covered", "excluded"): | |
| return False | |
| if isinstance(v, dict) and "covered" in v: | |
| return bool(v.get("covered")) | |
| return None | |
| tm, tn = _tri(mat), _tri(nb) | |
| if tm is True or tn is True: | |
| return True | |
| if tm is False or tn is False: | |
| return False | |
| return None | |
| def _wants_comprehensive_indemnity(profile: Any) -> bool: | |
| """Conservatively detect that the user wants a PRIMARY COMPREHENSIVE | |
| INDEMNITY health plan (so a fixed-benefit daily-cash / PA / CI product | |
| is the wrong top pick). | |
| Fires ONLY when the signal is clear and NO explicit supplement signal | |
| is present: | |
| β’ NOT an explicit supplement / add-on / top-up / PA / CI / cancer / | |
| hospital-cash goal (hard veto β these users WANT fixed-benefit). | |
| β’ The user has NO existing base cover (first-time buyer). | |
| β’ AND at least one strong positive signal: | |
| - primary_goal reads as a first / primary / comprehensive | |
| health-cover goal, OR | |
| - a desired_sum_insured_inr is present (you only state a target | |
| cover amount when you want a real indemnity plan). | |
| Returns False on ambiguity (better to keep a fixed-benefit chunk and | |
| let ranking demote it than to wrongly hard-drop for an unclear case). | |
| """ | |
| goal = str(_profile_get(profile, "primary_goal") or "").lower().strip() | |
| # Hard veto β the user explicitly asked for a supplement / fixed-benefit | |
| # style product. Never hide those from them. | |
| if goal and any(tok in goal for tok in _SUPPLEMENT_GOAL_TOKENS): | |
| return False | |
| # A user who already holds base cover is plausibly shopping for a | |
| # supplement; don't aggressively hard-drop fixed-benefit for them. | |
| if _user_has_base_cover(profile): | |
| return False | |
| desired_si = _as_int(_profile_get(profile, "desired_sum_insured_inr")) | |
| has_desired_si = desired_si is not None and desired_si > 0 | |
| goal_is_comprehensive = bool(goal) and any( | |
| tok in goal for tok in _COMPREHENSIVE_GOAL_TOKENS | |
| ) | |
| # Clear signal = (first/primary/comprehensive goal) OR (a desired SI was | |
| # stated). Either alone, combined with "no base cover" and "no | |
| # supplement veto", is a confident comprehensive-indemnity intent. | |
| return goal_is_comprehensive or has_desired_si | |
| def apply_eligibility_filter(chunks: Iterable[Any], profile: Any) -> list[Any]: | |
| """Drop chunks the user is STRUCTURALLY ineligible for, or that plainly | |
| contradict an explicit stated need (KI-278). | |
| Hard-drop rules (policy chunks only β profile/regulatory/review chunks | |
| are never policies and pass through untouched): | |
| 1. TOP-UP GATE β a top-up / super-top-up plan is dropped when the user | |
| has no existing base cover (first-time buyer). A top-up only pays | |
| above a base policy / large deductible the user does not have. | |
| 2. SI FLOOR β when the user stated a desired sum insured, drop any | |
| plan whose largest SI option is below that requirement (it cannot | |
| deliver the cover the user said they need). | |
| 3. ZERO-COPAY GATE β when the user explicitly wants zero co-pay, drop | |
| any plan with a mandatory co-payment above | |
| ZERO_COPAY_USER_MAX_COPAY_PCT. Regardless of stated preference, | |
| drop plans with a punitive co-pay (>= PUNITIVE_COPAY_PCT). | |
| 4. FIXED-BENEFIT GATE (KI-279) β when the profile clearly signals the | |
| user wants a PRIMARY COMPREHENSIVE INDEMNITY health plan (first | |
| policy / general-cover goal + a desired SI + no existing base | |
| cover, and NO explicit supplement / PA / CI / hospital-cash / | |
| top-up goal), drop fixed-benefit products (hospital daily cash, | |
| personal accident, critical illness, cancer / defined-benefit). | |
| These pay a fixed per-day / lump-sum amount, not actual medical | |
| expenses, so they are a wrong PRIMARY recommendation. They are | |
| NEVER dropped for a user who explicitly wants a supplement. | |
| 5. ENTRY-AGE GATE β drop a plan whose max entry age cannot accept | |
| the OLDEST person the policy must insure. When the profile covers | |
| parents (~70), the gate uses the parents' age, not the paying | |
| child's (the demographic pre-filter only sees the payer's age). | |
| Conservative: fires only when both the insured age AND the policy | |
| fact are present. | |
| Conservative on missing data: a rule only fires when BOTH the profile | |
| signal AND the policy fact are present. A chunk with no enriched facts | |
| is never dropped here (it just won't get a fit boost in ranking). | |
| """ | |
| chunks_list = list(chunks) | |
| if not chunks_list: | |
| return chunks_list | |
| has_base = _user_has_base_cover(profile) | |
| wants_zero_copay = _user_wants_zero_copay(profile) | |
| desired_si = _as_int(_profile_get(profile, "desired_sum_insured_inr")) | |
| wants_comprehensive = _wants_comprehensive_indemnity(profile) | |
| # KI-280 β the age of the OLDEST person the plan must accept at entry | |
| # (parents' age when insuring parents, else the user's own age). | |
| oldest_age = _oldest_insured_age(profile) | |
| kept: list[Any] = [] | |
| for ch in chunks_list: | |
| m = _meta_full(ch) | |
| doc_type = (m.get("doc_type") or "").lower() | |
| # #52 β uploaded marketplace docs are Q&A targets; never hard-drop | |
| # them on eligibility (same class as regulatory/review). | |
| if doc_type in ("profile", "regulatory", "review", "user_upload"): | |
| kept.append(ch) | |
| continue | |
| # Rule 1 β top-up unusable without a base policy | |
| if not has_base and _is_top_up(m): | |
| continue | |
| # Rule 4 (KI-279) β fixed-benefit is a wrong PRIMARY pick when the | |
| # user clearly wants comprehensive indemnity cover. Hard-drop only | |
| # for that intent; supplement/PA/CI seekers keep seeing them. | |
| if wants_comprehensive and _is_fixed_benefit_chunk(m): | |
| continue | |
| # Rule 5 β HARD ELIGIBILITY: a plan whose max entry age cannot | |
| # accept the oldest insured person is structurally unusable (e.g. a | |
| # 70yo parent cannot be enrolled in a plan whose max_entry_age is | |
| # 65). Fires only when BOTH the age and the policy fact are present | |
| # (conservative on missing data) and only when the profile actually | |
| # insures someone whose age we know (the demographic | |
| # apply_profile_filter uses the *payer's* age and never sees the | |
| # parents' age). | |
| if oldest_age is not None: | |
| maxe = _as_int(m.get("max_entry_age")) | |
| if maxe is not None and maxe + ENTRY_AGE_TOLERANCE < oldest_age: | |
| continue | |
| # Rule 2 β SI floor | |
| if desired_si: | |
| max_si = _max_sum_insured(m) | |
| if max_si is not None and max_si < desired_si: | |
| continue | |
| # Rule 3 β co-pay gate | |
| copay = _as_int(m.get("co_payment_pct")) | |
| if copay is not None: | |
| if copay >= PUNITIVE_COPAY_PCT: | |
| continue | |
| if wants_zero_copay and copay > ZERO_COPAY_USER_MAX_COPAY_PCT: | |
| continue | |
| kept.append(ch) | |
| return kept | |
| # --------------------------------------------------------------------------- | |
| # (1c) Profile-fit ranking (KI-278) β re-order by stated-need match | |
| # --------------------------------------------------------------------------- | |
| _GRADE_POINTS = {"A": 100.0, "B": 70.0, "C": 45.0, "D": 25.0, "F": 5.0} | |
| def _fit_score(chunk_meta: dict, profile: Any, wants_zero_copay: bool, | |
| desired_si: Optional[int], *, | |
| requires_maternity: bool = False, | |
| cost_objective: bool = False) -> float: | |
| """Composite profile-fit score (higher = better). Blends raw cosine | |
| (so genuinely-relevant chunks still matter) with structural fit: | |
| β’ scorecard grade / overall (when enriched) | |
| β’ co-pay penalty (steep when the user wants zero co-pay) | |
| β’ SI headroom (a plan that comfortably offers the requested SI beats | |
| one that barely scrapes it) | |
| β’ KI-280 required-feature term: when the profile explicitly needs | |
| maternity/newborn, a plan whose facts CONFIRM it ranks above one | |
| that does not, and an UNVERIFIED plan ranks strictly below a | |
| confirmed one (but is not hard-dropped β curated facts are | |
| incomplete). | |
| β’ KI-280 cost-objective term: when cost is the dominant stated | |
| objective, shrink cosine's pull so a pricier plan can't win purely | |
| on vector similarity over a cheaper appropriate plan. | |
| """ | |
| try: | |
| cosine = float(chunk_meta.get("score") or 0.0) | |
| except (TypeError, ValueError): | |
| cosine = 0.0 | |
| # Cosine is ~[0,1]; scale so it contributes but never dominates fit. | |
| # KI-280: when COST is the primary objective (P5), damp cosine hard so | |
| # a higher-cosine pricier plan does not out-rank a cheaper appropriate | |
| # one. The scorecard's Cost Predictability is already inside | |
| # _overall_score; this just stops raw similarity from overriding it. | |
| cosine_weight = 6.0 if cost_objective else 30.0 | |
| score = cosine * cosine_weight | |
| # Grade / overall scorecard signal (enriched by brain_tools). | |
| grade = str(chunk_meta.get("_grade") or "").strip().upper() | |
| overall = _as_int(chunk_meta.get("_overall_score")) | |
| if overall is not None: | |
| score += float(overall) # 0-100 scorecard points | |
| elif grade in _GRADE_POINTS: | |
| score += _GRADE_POINTS[grade] | |
| # Co-pay penalty. KI-280: for a COST-primary objective where the user | |
| # explicitly tolerates a co-pay (P5 β "co-pay is completely fine if it | |
| # lowers premium"), a co-pay is a PREMIUM-REDUCING feature for this | |
| # buyer, not a defect β suppress the penalty so the cost-appropriate | |
| # plan is not wrongly demoted below a pricier zero-copay plan. The | |
| # zero-copay-user penalty path is untouched (P1 still drops high-copay). | |
| copay = _as_int(chunk_meta.get("co_payment_pct")) | |
| if copay is not None and copay > 0: | |
| if cost_objective and not wants_zero_copay: | |
| penalty = 0.0 | |
| else: | |
| penalty = copay * (3.0 if wants_zero_copay else 1.0) | |
| score -= penalty | |
| # SI headroom bonus β reward a plan that offers >= the requested SI. | |
| if desired_si: | |
| max_si = _max_sum_insured(chunk_meta) | |
| if max_si is not None: | |
| if max_si >= desired_si: | |
| score += 15.0 | |
| else: | |
| score -= 25.0 # shouldn't survive eligibility, belt-and-braces | |
| # BUG #30 (B1-c) β EXISTING-COVER term. When the user already holds ANY | |
| # base cover (even a small βΉ1L employer policy), a top-up / super-top-up | |
| # is a directly relevant product that the profile-neutral scorecard is | |
| # blind to. Surface it: the bonus (+22) clears roughly one letter-grade | |
| # gap so a relevant top-up lands alongside the primary indemnity picks | |
| # (which are untouched), giving a shortlist that mixes one strong primary | |
| # plan with one relevant top-up. Inert when the user holds no base cover. | |
| existing = _as_int(_profile_get(profile, "existing_cover_inr")) | |
| if existing and existing > 0 and _is_top_up(chunk_meta): | |
| score += 22.0 | |
| # KI-280 β REQUIRED-FEATURE term. When the profile explicitly needs | |
| # maternity / newborn cover (P3), a plan whose curated facts CONFIRM it | |
| # must outrank one that does not, and an UNVERIFIED plan (fact absent) | |
| # ranks strictly below a confirmed one. We do NOT hard-drop unverified | |
| # plans β the curated facts are incomplete and a hard drop would wrongly | |
| # hide good plans whose maternity flag simply wasn't curated. The | |
| # magnitudes are chosen so a confirmed plan clears a one-letter-grade | |
| # gap (β +25 confirmed vs β40 explicitly-absent) β enough to put a | |
| # maternity C-grade plan above a non-maternity B-grade plan when | |
| # maternity is a stated hard requirement, without disturbing ordering | |
| # for profiles that did not ask for maternity (term is inert then). | |
| if requires_maternity: | |
| conf = _chunk_confirms_maternity(chunk_meta) | |
| if conf is True: | |
| score += 25.0 | |
| elif conf is False: | |
| score -= 40.0 # facts say NO maternity β wrong for this need | |
| else: | |
| score -= 12.0 # unverified β rank below any confirmed plan | |
| # KI-279 β fixed-benefit demotion. The eligibility filter already | |
| # hard-drops fixed-benefit for a strong comprehensive-indemnity intent; | |
| # this penalty is the belt-and-braces for weaker signals where the | |
| # intent did NOT fire (so a fixed-benefit chunk survived) but the user | |
| # still has no explicit supplement goal and an indemnity plan is also | |
| # present β the indemnity plan must still lead. Suppressed only when | |
| # the user explicitly wants a supplement / PA / CI / hospital-cash | |
| # product (then fixed-benefit is exactly what they asked for). Penalty | |
| # is large enough to sink it below any comparable indemnity plan | |
| # without affecting indemnity-vs-indemnity ordering. | |
| if not _user_explicitly_wants_supplement(profile) and _is_fixed_benefit_chunk( | |
| chunk_meta | |
| ): | |
| score -= 120.0 | |
| return score | |
| def rank_by_profile_fit(chunks: Iterable[Any], profile: Any) -> list[Any]: | |
| """Stable-sort chunks by composite profile-fit (descending). Non-policy | |
| chunks (profile/regulatory/review) keep their relative position at the | |
| front so grounding context isn't reordered away. | |
| Stable: ties preserve the incoming (cosine / dedup) order, so this only | |
| *promotes* better-fit plans β it never randomly shuffles equals. | |
| """ | |
| chunks_list = list(chunks) | |
| if len(chunks_list) <= 1: | |
| return chunks_list | |
| wants_zero_copay = _user_wants_zero_copay(profile) | |
| desired_si = _as_int(_profile_get(profile, "desired_sum_insured_inr")) | |
| requires_maternity = _profile_requires_maternity(profile) | |
| cost_objective = _cost_is_primary_objective(profile) | |
| non_policy: list[Any] = [] | |
| policy: list[Any] = [] | |
| for ch in chunks_list: | |
| dt = (_meta_full(ch).get("doc_type") or "").lower() | |
| # #52 β keep uploaded marketplace docs in the non-policy lane so | |
| # profile-fit re-ranking can't bury them below recommendable corpus | |
| # policies when the user asked about the uploaded doc itself. | |
| (non_policy if dt in ("profile", "regulatory", "review", "user_upload") | |
| else policy).append(ch) | |
| # Decorate-sort-undecorate with original index as the stable tiebreaker. | |
| decorated = [ | |
| ( | |
| -_fit_score( | |
| _meta_full(ch), profile, wants_zero_copay, desired_si, | |
| requires_maternity=requires_maternity, | |
| cost_objective=cost_objective, | |
| ), | |
| i, | |
| ch, | |
| ) | |
| for i, ch in enumerate(policy) | |
| ] | |
| decorated.sort(key=lambda t: (t[0], t[1])) | |
| ranked_policy = [ch for _, _, ch in decorated] | |
| return non_policy + ranked_policy | |
| # --------------------------------------------------------------------------- | |
| # (2) Hybrid retrieval β exact-match bypass on UIN or policy name | |
| # --------------------------------------------------------------------------- | |
| def _extract_uin(query: str) -> Optional[str]: | |
| if not query: | |
| return None | |
| m = UIN_RE.search(query) | |
| return m.group(0) if m else None | |
| def _extract_quoted_policy_name(query: str) -> Optional[str]: | |
| """Pull a quoted policy name out of the query if present.""" | |
| if not query: | |
| return None | |
| # "..." or "..." or '...' | |
| for pat in (r'"([^"]{6,})"', r"β([^β]{6,})β", r"'([^']{6,})'"): | |
| m = re.search(pat, query) | |
| if m: | |
| return m.group(1).strip() | |
| return None | |
| def bypass_cosine_for_exact_match( | |
| chunks: Iterable[Any], | |
| query: str, | |
| ) -> Optional[list[Any]]: | |
| """If the query contains an exact UIN or an exact (quoted) policy name, | |
| return a substring-matched subset of `chunks`. This is the lexical | |
| "BM25-style" fallback β when the user clearly knows what they want, we | |
| should not let cosine similarity reorder away from their literal target. | |
| Returns None when no exact-match signal is present (caller falls back | |
| to normal cosine results). Returns an empty list if a signal was present | |
| but no chunk matched β that's a useful "we know what you mean, but our | |
| catalog doesn't have it" signal for the orchestrator's empty-retrieval | |
| guard. | |
| """ | |
| chunks_list = list(chunks) | |
| uin = _extract_uin(query) | |
| quoted = _extract_quoted_policy_name(query) | |
| if not uin and not quoted: | |
| return None | |
| needles: list[str] = [] | |
| if uin: | |
| needles.append(uin.lower()) | |
| if quoted: | |
| needles.append(quoted.lower()) | |
| matched: list[Any] = [] | |
| for ch in chunks_list: | |
| m = _meta(ch) | |
| haystack = " ".join( | |
| str(m.get(k, "")) for k in ("policy_id", "policy_name", "insurer_slug", "text") | |
| ).lower() | |
| if any(n in haystack for n in needles): | |
| matched.append(ch) | |
| return matched | |
| # --------------------------------------------------------------------------- | |
| # (3) Empty-retrieval guard | |
| # --------------------------------------------------------------------------- | |
| def empty_retrieval_guard( | |
| chunks: Iterable[Any], | |
| intent: Optional[str] = None, | |
| min_chunks: int = MIN_CHUNKS_FOR_RECOMMENDATION, | |
| ) -> Optional[dict]: | |
| """Return a structured signal if a recommendation intent has too few | |
| chunks to ground an answer. The orchestrator should surface this to the | |
| user as a clarifier question instead of calling the brain. | |
| Returns None when the retrieval is healthy (or the intent doesn't need | |
| much grounding). | |
| """ | |
| chunks_list = list(chunks) | |
| intent_norm = (intent or "").lower().strip() | |
| if intent_norm and intent_norm not in _RECOMMENDATION_INTENTS: | |
| return None # FAQ / regulatory / smalltalk intents are fine with sparse retrieval | |
| if len(chunks_list) >= min_chunks: | |
| return None | |
| return { | |
| "reason": "empty_retrieval", | |
| "fallback": "Ask 1 more clarifier", | |
| "chunk_count": len(chunks_list), | |
| "min_required": min_chunks, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # (4) Citation grounding β require policy_id, policy_name, chunk_offset | |
| # --------------------------------------------------------------------------- | |
| def enforce_citation_grounding(chunks: Iterable[Any]) -> list[Any]: | |
| """Drop chunks missing citation-critical fields. | |
| A citable chunk MUST expose: | |
| - policy_id (non-empty str) | |
| - policy_name (non-empty str) | |
| The chunk offset field (`chunk_offset` or legacy `chunk_idx`) is | |
| INFORMATIONAL only β it is not required for citation grounding because | |
| upstream call sites (e.g. brain_tools.retrieve_policies) build pruned | |
| dicts that intentionally omit it, and the brain cites by policy | |
| identity, not by chunk offset. Requiring an offset here would drop | |
| every chunk built by those pruned-dict call sites. | |
| """ | |
| kept: list[Any] = [] | |
| for ch in chunks: | |
| m = _meta(ch) | |
| pid = m.get("policy_id") | |
| pname = m.get("policy_name") | |
| if not pid or not isinstance(pid, str): | |
| continue | |
| if not pname or not isinstance(pname, str): | |
| continue | |
| kept.append(ch) | |
| return kept | |
| # --------------------------------------------------------------------------- | |
| # (5) Dedup by policy_id β keep highest-score chunk per policy | |
| # --------------------------------------------------------------------------- | |
| def dedup_by_policy(chunks: Iterable[Any]) -> list[Any]: | |
| """Within top-K results, collapse same-product / marketing-variant | |
| duplicates to one chunk, keeping the highest-scoring chunk. Preserves | |
| the order of first appearance of each kept product. | |
| Keying is by the SHARED canonical identity | |
| (policy_identity.canonical_key) β UIN-primary, product_key fallback β | |
| NOT the raw policy_id. The same product can appear under different | |
| policy_ids: a marketing rename ("my:Optima Secure" vs "my:Optima | |
| Secure (older variant)" β same UIN) or two doctype siblings | |
| ("...__wordings" vs "...__brochure" β same product_key). policy_id-only | |
| dedup would let both through; canonical-identity keying collapses them. | |
| This reuses the exact rule the marketplace endpoint uses so the | |
| recommender and the marketplace agree on "the same policy". | |
| """ | |
| best: dict[str, Any] = {} | |
| order: list[str] = [] | |
| for ch in chunks: | |
| m = _meta_full(ch) | |
| key = canonical_key(m) | |
| score = m.get("score") | |
| if score is None: | |
| # try attribute path | |
| score = getattr(ch, "score", 0.0) | |
| try: | |
| score_f = float(score) | |
| except (TypeError, ValueError): | |
| score_f = 0.0 | |
| if key not in best: | |
| best[key] = (ch, score_f) | |
| order.append(key) | |
| continue | |
| prev_chunk, prev_score = best[key] | |
| if score_f > prev_score: | |
| best[key] = (ch, score_f) | |
| return [best[key][0] for key in order] | |
| # --------------------------------------------------------------------------- | |
| # Convenience β compose the standard retrieval-side pipeline. | |
| # --------------------------------------------------------------------------- | |
| def filter_pipeline( | |
| chunks: Iterable[Any], | |
| profile: Any = None, | |
| query: str = "", | |
| intent: Optional[str] = None, | |
| ) -> tuple[list[Any], Optional[dict]]: | |
| """Run the standard A3/A6 + KI-278 retrieval-side pipeline: | |
| 1. Citation grounding (reject malformed chunks) | |
| 2. Profile pre-filter (drop demographically inappropriate policies) | |
| 3. Eligibility filter (KI-278 β drop structural mismatches: | |
| top-up w/o base cover, SI floor, co-pay; | |
| KI-279 β drop fixed-benefit products when | |
| the user wants comprehensive indemnity) | |
| 4. Exact-match bypass (if query has a UIN / quoted name, swap in) | |
| 5. Dedup by policy_id | |
| 6. Profile-fit ranking (KI-278 β promote best-fit over raw cosine; | |
| KI-279 β demote any surviving fixed-benefit | |
| below indemnity options) | |
| 7. Empty-retrieval guard | |
| Returns (filtered_chunks, guard_signal_or_None). When guard_signal is | |
| set, the caller should NOT pass `filtered_chunks` to the brain β they | |
| should surface the clarifier instead. | |
| KI-278 ordering note: the eligibility filter runs on the demographically- | |
| fitted set and BEFORE the empty-retrieval guard, so a profile whose only | |
| matches are ineligible (e.g. a first-time buyer where cosine only found | |
| top-ups) correctly trips the clarifier guard instead of recommending an | |
| unusable product. The exact-match bypass still wins when the user names | |
| a specific policy (they may legitimately want to read about a top-up). | |
| """ | |
| grounded = enforce_citation_grounding(chunks) | |
| fitted = apply_profile_filter(grounded, profile) | |
| eligible = apply_eligibility_filter(fitted, profile) | |
| exact = bypass_cosine_for_exact_match(grounded, query) | |
| if exact is not None and exact: | |
| # Exact match wins over cosine when the user clearly named a target. | |
| # The user explicitly asked about THIS policy β show it even if it | |
| # would fail the eligibility/fit gates (they may be researching a | |
| # top-up or a high-copay plan on purpose). Demographic filter still | |
| # applies (a 25yo asking about a senior plan still gets steered). | |
| eligible = apply_profile_filter(exact, profile) | |
| deduped = dedup_by_policy(eligible) | |
| ranked = rank_by_profile_fit(deduped, profile) | |
| guard = empty_retrieval_guard(ranked, intent=intent) | |
| return ranked, guard | |