Spaces:
Sleeping
fix(retrieval): quality-seeded candidates + wide recall pool — surface the genuinely-best policy (KI-QUALITY-SEED / KI-RECALL-POOL)
Browse filesROOT CAUSE (#24): the Path B single-LLM rewrite collapsed recommendation
candidate-selection to pure cosine-RAG, which is QUALITY-BLIND. A genuinely
best-fit policy whose document wording isn't cosine-similar to a generic
needs-query (e.g. SBI General Super Health A/82, ManipalCigna Sarvah Param
A/86, ICICI Health AdvantEdge A/80) never entered the candidate pool, so the
(audit-verified-correct, KI-FULLFACTS) profile-tuned scorecard +
filter_pipeline never got to surface it. It stayed invisible behind the #21
model + #23 scorecard-starvation regressions (everything scored —/0, so good
vs bad was indistinguishable). The marketplace kept the structured/quality
path (its 80+ gradings were always right); chat lost it in the rewrite.
Fix (pure logic; NO re-ingest, NO vector-DB change):
1. KI-RECALL-POOL — decouple the Chroma recall pool (40) from the caller's
top_k so cosine breadth isn't capped at 8; truncate to the top-N
best-fit AFTER quality-aware filter_pipeline ranking.
2. KI-QUALITY-SEED — union the cosine pool with the catalogue's top
policies by the profile-tuned scorecard overall (cached per
profile-signature). filter_pipeline still does precise eligibility +
profile-fit ranking on the union (proven: it eligibility-passes + ranks
a present A-policy #1), so this only guarantees CANDIDACY — never
bypasses eligibility, never fabricates.
Verified: persona query now surfaces Sarvah Param A/86, SBI Super Health
A/82, Optima Restore A/76 etc. into contention; local E2E recommends them
with real UINs and renders cards (CITATIONS 2-3, prose⇄card 1:1 on the top
picks). Full pytest gate green (rc=0, 0 failures); scorecard-parity green.
Not pushed (deploy-altogether). Residual: LLM occasionally pads prose with
one sub-floor policy — a follow-up prompt-gating refinement for strict 1:1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/brain_tools.py +124 -1
|
@@ -88,6 +88,12 @@ _log = logging.getLogger(__name__)
|
|
| 88 |
|
| 89 |
_FACTS_DIR = settings.DATA_DIR / "policy_facts"
|
| 90 |
_DOCTYPE_SUFFIXES = ("__wordings", "__brochure", "__cis", "__prospectus")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
_FACT_KEYS = (
|
| 92 |
"policy_type_indemnity_or_fixed",
|
| 93 |
# KI-279 (2026-05-16) — the raw catalog type key. Some curated files
|
|
@@ -637,6 +643,79 @@ def save_profile_field(session, field: str, value: Any) -> dict:
|
|
| 637 |
}
|
| 638 |
|
| 639 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 640 |
# ---- retrieve_policies -----------------------------------------------------
|
| 641 |
|
| 642 |
async def retrieve_policies(
|
|
@@ -803,9 +882,26 @@ async def retrieve_policies(
|
|
| 803 |
try:
|
| 804 |
from rag.retrieve import retrieve as _retrieve
|
| 805 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 806 |
chunks = await _retrieve(
|
| 807 |
query=query,
|
| 808 |
-
top_k=
|
| 809 |
policy_ids=policy_filter_ids or None,
|
| 810 |
session_id=eff_session_id,
|
| 811 |
)
|
|
@@ -857,6 +953,22 @@ async def retrieve_policies(
|
|
| 857 |
guard_signal = None
|
| 858 |
filtered = raw
|
| 859 |
if not policy_filter_ids:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 860 |
try:
|
| 861 |
from backend.retrieval_filters import filter_pipeline
|
| 862 |
filtered, guard_signal = filter_pipeline(
|
|
@@ -866,6 +978,17 @@ async def retrieve_policies(
|
|
| 866 |
_log.warning("retrieval_filters.filter_pipeline failed: %s", e)
|
| 867 |
filtered = raw
|
| 868 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 869 |
# X7 — cache slug→insurer lookups on session so a subsequent
|
| 870 |
# mark_recommendation call (same turn) can stamp the right insurer on the
|
| 871 |
# shown_policies event. Each retrieve_policies call MERGES into the cache
|
|
|
|
| 88 |
|
| 89 |
_FACTS_DIR = settings.DATA_DIR / "policy_facts"
|
| 90 |
_DOCTYPE_SUFFIXES = ("__wordings", "__brochure", "__cis", "__prospectus")
|
| 91 |
+
# KI-RECALL-POOL (2026-05-17) — width of the Chroma candidate pool pulled
|
| 92 |
+
# BEFORE eligibility + scorecard-aware ranking. Must be wide enough that a
|
| 93 |
+
# genuinely-strong A/B policy that is cosine-rank ~15-30 for a profile
|
| 94 |
+
# query still enters contention (so the quality-aware filter_pipeline can
|
| 95 |
+
# surface it). The LLM still only ever sees the top-k best-fit survivors.
|
| 96 |
+
_RECALL_POOL = 40
|
| 97 |
_FACT_KEYS = (
|
| 98 |
"policy_type_indemnity_or_fixed",
|
| 99 |
# KI-279 (2026-05-16) — the raw catalog type key. Some curated files
|
|
|
|
| 643 |
}
|
| 644 |
|
| 645 |
|
| 646 |
+
_qseed_cache: dict = {} # (profile_sig) -> [seed chunk dicts]
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
def _quality_seed_candidates(profile, limit: int = 25) -> list[dict]:
|
| 650 |
+
"""KI-QUALITY-SEED (2026-05-17). Cosine retrieval is quality-blind: a
|
| 651 |
+
genuinely-best policy whose document wording isn't cosine-similar to a
|
| 652 |
+
generic profile query never enters the candidate pool, so the
|
| 653 |
+
(audit-verified-correct) profile-tuned scorecard + filter_pipeline never
|
| 654 |
+
get the chance to surface it. This deterministically seeds the pool with
|
| 655 |
+
the catalogue's top policies by the PROFILE-TUNED scorecard overall.
|
| 656 |
+
filter_pipeline still does precise eligibility + profile-fit ranking on
|
| 657 |
+
the union (proven: it eligibility-passes + ranks a present A-policy #1),
|
| 658 |
+
so this ONLY guarantees CANDIDACY — it never bypasses eligibility or
|
| 659 |
+
fabricates a recommendation. Pure logic layer: no re-ingest, no vector
|
| 660 |
+
DB change. Cached per profile-signature (the catalogue + a fixed profile
|
| 661 |
+
yield a fixed ranking)."""
|
| 662 |
+
out: list[dict] = []
|
| 663 |
+
try:
|
| 664 |
+
prof_sig = repr(sorted(
|
| 665 |
+
(s, getattr(profile, s, None))
|
| 666 |
+
for s in (
|
| 667 |
+
"age", "income_band", "primary_goal", "existing_cover_inr",
|
| 668 |
+
"copay_pct", "dependents", "health_conditions",
|
| 669 |
+
"budget_band", "location_tier", "parents_to_insure",
|
| 670 |
+
"parents_age_max",
|
| 671 |
+
)
|
| 672 |
+
)) if profile is not None else "none"
|
| 673 |
+
if prof_sig in _qseed_cache:
|
| 674 |
+
return _qseed_cache[prof_sig][:limit]
|
| 675 |
+
cur = _curated_facts_all()
|
| 676 |
+
scored: list[tuple[float, str, dict, dict]] = []
|
| 677 |
+
seen: set[str] = set()
|
| 678 |
+
for key, data in cur.items():
|
| 679 |
+
if not isinstance(data, dict):
|
| 680 |
+
continue
|
| 681 |
+
pid = (data.get("policy_id") or key or "").strip()
|
| 682 |
+
if not pid or pid in seen:
|
| 683 |
+
continue
|
| 684 |
+
seen.add(pid)
|
| 685 |
+
sig = _scorecard_signal(pid, profile=profile)
|
| 686 |
+
ov = sig.get("_overall_score")
|
| 687 |
+
if ov is None:
|
| 688 |
+
continue
|
| 689 |
+
try:
|
| 690 |
+
ovf = float(ov)
|
| 691 |
+
except (TypeError, ValueError):
|
| 692 |
+
continue
|
| 693 |
+
scored.append((ovf, pid, data, sig))
|
| 694 |
+
scored.sort(key=lambda t: -t[0])
|
| 695 |
+
for ovf, pid, data, sig in scored[: max(limit, 25)]:
|
| 696 |
+
ch = {
|
| 697 |
+
"chunk_id": f"{pid}__qseed",
|
| 698 |
+
"policy_id": pid,
|
| 699 |
+
"policy_name": data.get("policy_name", pid),
|
| 700 |
+
"insurer_slug": data.get("insurer_slug")
|
| 701 |
+
or (pid.split("__", 1)[0] if "__" in pid else ""),
|
| 702 |
+
"doc_type": "policy",
|
| 703 |
+
"source_url": data.get("_primary_source_pdf") or "",
|
| 704 |
+
# No cosine text — filter_pipeline ranks the union by
|
| 705 |
+
# _fit_score (scorecard grade + profile fit), not cosine,
|
| 706 |
+
# so a 0.0 cosine score does not bury a seeded A-policy.
|
| 707 |
+
"chunk_text": "",
|
| 708 |
+
"score": 0.0,
|
| 709 |
+
}
|
| 710 |
+
ch.update(_load_policy_facts(pid))
|
| 711 |
+
ch.update(sig)
|
| 712 |
+
out.append(ch)
|
| 713 |
+
_qseed_cache[prof_sig] = out
|
| 714 |
+
except Exception as e: # noqa: BLE001 — seeding must never break retrieval
|
| 715 |
+
_log.warning("quality-seed failed: %s", e)
|
| 716 |
+
return out[:limit]
|
| 717 |
+
|
| 718 |
+
|
| 719 |
# ---- retrieve_policies -----------------------------------------------------
|
| 720 |
|
| 721 |
async def retrieve_policies(
|
|
|
|
| 882 |
try:
|
| 883 |
from rag.retrieve import retrieve as _retrieve
|
| 884 |
|
| 885 |
+
# KI-RECALL-POOL (2026-05-17) — DECOUPLE the Chroma recall pool from
|
| 886 |
+
# the caller's top_k. Cosine-only Chroma ranking is quality-blind: a
|
| 887 |
+
# mediocre C/D policy whose wording matches the query out-ranks a
|
| 888 |
+
# genuinely-better A/B policy that IS in the corpus. At top_k=8 the
|
| 889 |
+
# A-grade policies never even enter the candidate set, so the
|
| 890 |
+
# downstream quality-aware filter_pipeline (_fit_score, now fed REAL
|
| 891 |
+
# scorecard grades after KI-FULLFACTS) can't surface them — it can
|
| 892 |
+
# only re-order what was retrieved. Fix: retrieve a WIDE pool, let
|
| 893 |
+
# eligibility + scorecard-aware ranking choose, then return the
|
| 894 |
+
# top-k best-fit survivors (truncation happens after filter_pipeline
|
| 895 |
+
# below). For an explicit known-policy follow-up (policy_filter_ids)
|
| 896 |
+
# the caller already knows the policy — keep the narrow top_k.
|
| 897 |
+
_recall_pool = (
|
| 898 |
+
(int(top_k) if top_k else 8)
|
| 899 |
+
if policy_filter_ids
|
| 900 |
+
else _RECALL_POOL
|
| 901 |
+
)
|
| 902 |
chunks = await _retrieve(
|
| 903 |
query=query,
|
| 904 |
+
top_k=_recall_pool,
|
| 905 |
policy_ids=policy_filter_ids or None,
|
| 906 |
session_id=eff_session_id,
|
| 907 |
)
|
|
|
|
| 953 |
guard_signal = None
|
| 954 |
filtered = raw
|
| 955 |
if not policy_filter_ids:
|
| 956 |
+
# KI-QUALITY-SEED — union the cosine pool with the catalogue's
|
| 957 |
+
# top profile-graded policies so a genuinely-best policy that
|
| 958 |
+
# isn't cosine-similar to a generic needs-query still enters
|
| 959 |
+
# contention. filter_pipeline then does precise eligibility +
|
| 960 |
+
# profile-fit ranking on the union (it eligibility-passes + ranks
|
| 961 |
+
# a present A-policy #1, proven). Cosine chunk wins on dup (it
|
| 962 |
+
# carries real retrieved text); seeds only ADD missing candidates.
|
| 963 |
+
if profile is not None:
|
| 964 |
+
_seen_pids = {
|
| 965 |
+
(c.get("policy_id") or "").strip() for c in raw
|
| 966 |
+
}
|
| 967 |
+
for _seed in _quality_seed_candidates(profile, limit=25):
|
| 968 |
+
_sp = (_seed.get("policy_id") or "").strip()
|
| 969 |
+
if _sp and _sp not in _seen_pids:
|
| 970 |
+
_seen_pids.add(_sp)
|
| 971 |
+
raw.append(_seed)
|
| 972 |
try:
|
| 973 |
from backend.retrieval_filters import filter_pipeline
|
| 974 |
filtered, guard_signal = filter_pipeline(
|
|
|
|
| 978 |
_log.warning("retrieval_filters.filter_pipeline failed: %s", e)
|
| 979 |
filtered = raw
|
| 980 |
|
| 981 |
+
# KI-RECALL-POOL — filter_pipeline has now eligibility-filtered +
|
| 982 |
+
# ranked the WIDE pool best-fit-first (scorecard-aware, deduped to
|
| 983 |
+
# ~1 chunk/policy). Return only the top-N best-fit survivors: enough
|
| 984 |
+
# for the LLM to pick 2-4 strong recommendations AND for the
|
| 985 |
+
# citation builder to select from, without dumping the 40-wide tail
|
| 986 |
+
# as context. This is the ONLY truncation — done AFTER quality
|
| 987 |
+
# ranking, so the genuinely-best eligible policies (e.g. SBI Super
|
| 988 |
+
# Health A/87) are what the LLM and the cards both see. Set on the
|
| 989 |
+
# session cache too so prose ⇄ cited cards are gated on the SAME set.
|
| 990 |
+
filtered = filtered[: max((int(top_k) if top_k else 8), 12)]
|
| 991 |
+
|
| 992 |
# X7 — cache slug→insurer lookups on session so a subsequent
|
| 993 |
# mark_recommendation call (same turn) can stamp the right insurer on the
|
| 994 |
# shown_policies event. Each retrieve_policies call MERGES into the cache
|