rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
78c652f
·
1 Parent(s): 4a63fa6

fix(retrieval): quality-seeded candidates + wide recall pool — surface the genuinely-best policy (KI-QUALITY-SEED / KI-RECALL-POOL)

Browse files

ROOT 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>

Files changed (1) hide show
  1. backend/brain_tools.py +124 -1
backend/brain_tools.py CHANGED
@@ -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=int(top_k) if top_k else 8,
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