Spaces:
Sleeping
fix(single_brain): KI-245..KI-246 — Z6 + W2 bundle (3 bugs + 6 prompt edges)
Browse filesZ6 — 3 bugs from smoke_v_final:
- Bug A (BLOCKER): "no PED" not captured. RULE 1 health_conditions bullet now
explicitly mandates save_profile_field(health_conditions, "none") on
"no medical issues" / "I'm healthy" / "no PED" etc. + Worked Example B.
Without this the profile stayed incomplete forever and the bot looped.
- Bug B (BLOCKER): mid-session "JSON parse error" smoke trace.
ROOT CAUSE: single_brain.TurnResult.citations dicts don't carry
page_start/page_end fields that CitationOut(pydantic) requires; the
`[CitationOut(**c) for c in turn.citations]` line raised ValidationError
whenever retrieval succeeded, and FastAPI returned its raw 422 envelope
which the smoke test rendered as "the bot reply". Fix: (1) RequestValidationError
handler scoped to /api/chat returns ChatResponse-shaped 200 envelope
instead of raw 422; (2) response-build path now wrapped in try/except
with each citation built field-by-field with page_start/page_end
defaulted to 0. malformed cites are dropped + logged.
- Bug C: hallucinated policy names (Anita T4 "Star Health Family Health
Optima" without retrieve_policies result). ABSOLUTE RULE block listing
forbidden brand tokens added near the top of SYSTEM_PROMPT +
_scan_for_brand_hallucinations debug logger fires WARNING when reply
contains brand string but session.last_retrieved_chunks is empty.
W2 — 6 SYSTEM_PROMPT edge cases:
- RULE 4: returning-user greeting (4-step protocol: greet by name,
summarise, ask "has anything changed?", WAIT before retrieve)
- RULE 5: comparison view ("compare #1 and #3") — single retrieve_policies
call with both policy_filter_ids + markdown side-by-side table with
per-cell citations
- RULE 6: out-of-scope refusal (life/term/ULIP/motor/home/travel/MF) with
canned redirect + explicit no-retrieve
- RULE 7: soft close (mark_recommendation is_final=true on user choice +
offer purchase walkthrough or summary)
- RULE 1 primary_goal: expanded 1-line bullet to 4-row mapping table
covering "switching from corporate", "lost employer cover", "too
expensive" (new cost_optimize value), "Section 80D"
- RULE 8: Indic-language mirroring (Hindi/Marathi/Tamil/Telugu/etc.) —
mirror user's language for prose, keep tool args in English, keep
citation format canonical
Note: cost_optimize is a new primary_goal enum that Path B accepts via
brain_tools pass-through. Legacy normalizer (sales_brain_normalizer)
won't recognize it; OK because legacy path is only reached on first-turn
503 fallback.
SYSTEM_PROMPT char count: 9666 / 10000 budget (~2416 tokens / ~2500 budget).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/main.py +159 -30
- backend/single_brain.py +217 -5
|
@@ -18,6 +18,7 @@ from pathlib import Path
|
|
| 18 |
from typing import Optional
|
| 19 |
|
| 20 |
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
|
|
|
| 21 |
from fastapi.middleware.cors import CORSMiddleware
|
| 22 |
from fastapi.responses import FileResponse, JSONResponse
|
| 23 |
from fastapi.staticfiles import StaticFiles
|
|
@@ -196,6 +197,50 @@ app.add_middleware(
|
|
| 196 |
)
|
| 197 |
|
| 198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
# ---------- Admin panel + LLM health background loop ----------
|
| 200 |
# Mount the password-gated admin endpoints (KI-097). Unauthorized callers
|
| 201 |
# get 401 Unauthorized. The earlier IP allowlist gate (ADMIN_IP_ALLOWLIST +
|
|
@@ -233,6 +278,28 @@ async def _startup_llm_health_probe():
|
|
| 233 |
asyncio.create_task(llm_health.background_probe_loop())
|
| 234 |
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
async def _startup_purge_dangling_profile_chunks():
|
| 237 |
"""KI-117 — boot-time self-heal of dangling `doc_type='profile'` chunks.
|
| 238 |
|
|
@@ -607,36 +674,98 @@ async def chat(req: ChatRequest, request: Request):
|
|
| 607 |
log_turn({"session_id": session_id, "tts_error": f"{type(e).__name__}: {e}"})
|
| 608 |
audio_mime = None
|
| 609 |
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 640 |
|
| 641 |
|
| 642 |
@app.get("/api/coverage", response_model=CoverageResponse)
|
|
|
|
| 18 |
from typing import Optional
|
| 19 |
|
| 20 |
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
| 21 |
+
from fastapi.exceptions import RequestValidationError
|
| 22 |
from fastapi.middleware.cors import CORSMiddleware
|
| 23 |
from fastapi.responses import FileResponse, JSONResponse
|
| 24 |
from fastapi.staticfiles import StaticFiles
|
|
|
|
| 197 |
)
|
| 198 |
|
| 199 |
|
| 200 |
+
# Bug B (2026-05-15) — /api/chat raw 422 leak. Live smoke saw the frontend
|
| 201 |
+
# render `{"detail":[{"type":"missing","loc":["body","user_text"]...}]}` as
|
| 202 |
+
# the bot reply because a malformed POST (missing user_text) hit FastAPI's
|
| 203 |
+
# default RequestValidationError handler — that body bypasses our
|
| 204 |
+
# ChatResponse envelope and the frontend has no shape-mapping for it.
|
| 205 |
+
# We intercept the chat endpoint specifically and return a clean
|
| 206 |
+
# ChatResponse-shaped JSON so frontend parsing never errors out. Other
|
| 207 |
+
# endpoints keep FastAPI's default 422 behaviour (which their callers
|
| 208 |
+
# already handle).
|
| 209 |
+
@app.exception_handler(RequestValidationError)
|
| 210 |
+
async def _validation_exception_handler(request: Request, exc: RequestValidationError):
|
| 211 |
+
if request.url.path == "/api/chat":
|
| 212 |
+
logging.warning(
|
| 213 |
+
"chat endpoint received malformed body — returning graceful "
|
| 214 |
+
"ChatResponse-shaped 200 instead of raw 422. errors=%r",
|
| 215 |
+
exc.errors()[:3],
|
| 216 |
+
)
|
| 217 |
+
return JSONResponse(
|
| 218 |
+
status_code=200,
|
| 219 |
+
content={
|
| 220 |
+
"reply_text": (
|
| 221 |
+
"Sorry, something went wrong — try again."
|
| 222 |
+
),
|
| 223 |
+
"citations": [],
|
| 224 |
+
"brain_used": "error_fallback",
|
| 225 |
+
"intent": "qa",
|
| 226 |
+
"language": "en",
|
| 227 |
+
"latency_ms": 0,
|
| 228 |
+
"session_id": "",
|
| 229 |
+
"audio_base64": None,
|
| 230 |
+
"audio_mime": None,
|
| 231 |
+
"faithfulness_passed": True,
|
| 232 |
+
"faithfulness_reasons": [],
|
| 233 |
+
"blocked": False,
|
| 234 |
+
"profile_updates": {},
|
| 235 |
+
},
|
| 236 |
+
)
|
| 237 |
+
# Default behaviour for every other endpoint.
|
| 238 |
+
return JSONResponse(
|
| 239 |
+
status_code=422,
|
| 240 |
+
content={"detail": exc.errors()},
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
# ---------- Admin panel + LLM health background loop ----------
|
| 245 |
# Mount the password-gated admin endpoints (KI-097). Unauthorized callers
|
| 246 |
# get 401 Unauthorized. The earlier IP allowlist gate (ADMIN_IP_ALLOWLIST +
|
|
|
|
| 278 |
asyncio.create_task(llm_health.background_probe_loop())
|
| 279 |
|
| 280 |
|
| 281 |
+
@app.on_event("startup")
|
| 282 |
+
async def _startup_single_brain_warmup():
|
| 283 |
+
"""Pre-warm the Gemini single-brain connection so the FIRST /api/chat turn
|
| 284 |
+
doesn't eat 4-5s of cold-start latency (TLS + auth + cache init).
|
| 285 |
+
|
| 286 |
+
Wrapped in try/except — warmup is an optimization, not a boot
|
| 287 |
+
requirement. A failed warmup must NEVER crash the server.
|
| 288 |
+
"""
|
| 289 |
+
try:
|
| 290 |
+
from backend import single_brain
|
| 291 |
+
latency = await single_brain.warmup()
|
| 292 |
+
if latency is not None:
|
| 293 |
+
logging.info(
|
| 294 |
+
"single_brain warmup completed at boot (%.2fs)", latency,
|
| 295 |
+
)
|
| 296 |
+
except Exception as e: # noqa: BLE001
|
| 297 |
+
logging.warning(
|
| 298 |
+
"single_brain warmup raised at top level (%s: %s) — boot continues",
|
| 299 |
+
type(e).__name__, e,
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
async def _startup_purge_dangling_profile_chunks():
|
| 304 |
"""KI-117 — boot-time self-heal of dangling `doc_type='profile'` chunks.
|
| 305 |
|
|
|
|
| 674 |
log_turn({"session_id": session_id, "tts_error": f"{type(e).__name__}: {e}"})
|
| 675 |
audio_mime = None
|
| 676 |
|
| 677 |
+
try:
|
| 678 |
+
log_turn({
|
| 679 |
+
"session_id": session_id,
|
| 680 |
+
"user_text": req.user_text,
|
| 681 |
+
"reply_text": turn.reply_text,
|
| 682 |
+
"brain_used": turn.brain_used,
|
| 683 |
+
"intent": turn.intent,
|
| 684 |
+
"language": turn.language,
|
| 685 |
+
"latency_ms": turn.latency_ms,
|
| 686 |
+
"retrieved_chunk_ids": turn.retrieved_chunk_ids,
|
| 687 |
+
"citation_count": len(turn.citations),
|
| 688 |
+
"faithfulness_passed": turn.faithfulness_passed,
|
| 689 |
+
"faithfulness_reasons": turn.faithfulness_reasons,
|
| 690 |
+
"blocked": turn.blocked,
|
| 691 |
+
})
|
| 692 |
+
except Exception: # noqa: BLE001 — log IO must never block a reply
|
| 693 |
+
pass
|
| 694 |
+
|
| 695 |
+
# Bug B defense — CitationOut requires page_start/page_end as ints, but
|
| 696 |
+
# single_brain.TurnResult.citations dicts don't carry those fields (its
|
| 697 |
+
# citation shape is {chunk_id, policy_id, policy_name, insurer_slug,
|
| 698 |
+
# doc_type, source_url, score}). Without this normalisation the
|
| 699 |
+
# Pydantic constructor below would raise ValidationError, the
|
| 700 |
+
# exception would escape /api/chat, and FastAPI would return a raw
|
| 701 |
+
# 500 (or its default JSON error envelope) that the frontend can't
|
| 702 |
+
# parse as a ChatResponse. We patch every citation dict to satisfy
|
| 703 |
+
# CitationOut's required fields and wrap the whole response build in
|
| 704 |
+
# an explicit try/except so a malformed citation can never silently
|
| 705 |
+
# bypass our envelope.
|
| 706 |
+
try:
|
| 707 |
+
safe_citations: list[CitationOut] = []
|
| 708 |
+
for c in turn.citations or []:
|
| 709 |
+
if not isinstance(c, dict):
|
| 710 |
+
continue
|
| 711 |
+
try:
|
| 712 |
+
safe_citations.append(
|
| 713 |
+
CitationOut(
|
| 714 |
+
policy_id=str(c.get("policy_id", "") or ""),
|
| 715 |
+
policy_name=str(c.get("policy_name", "") or ""),
|
| 716 |
+
insurer_slug=str(c.get("insurer_slug", "") or ""),
|
| 717 |
+
page_start=int(c.get("page_start", 0) or 0),
|
| 718 |
+
page_end=int(c.get("page_end", 0) or 0),
|
| 719 |
+
source_url=str(c.get("source_url", "") or ""),
|
| 720 |
+
score=float(c.get("score", 0.0) or 0.0),
|
| 721 |
+
)
|
| 722 |
+
)
|
| 723 |
+
except Exception as _cite_err: # noqa: BLE001
|
| 724 |
+
logging.warning(
|
| 725 |
+
"drop malformed citation (session=%s): %s — payload=%r",
|
| 726 |
+
session_id, _cite_err, c,
|
| 727 |
+
)
|
| 728 |
+
|
| 729 |
+
return ChatResponse(
|
| 730 |
+
reply_text=turn.reply_text,
|
| 731 |
+
citations=safe_citations,
|
| 732 |
+
brain_used=turn.brain_used,
|
| 733 |
+
intent=turn.intent,
|
| 734 |
+
language=turn.language,
|
| 735 |
+
latency_ms=turn.latency_ms,
|
| 736 |
+
session_id=session_id,
|
| 737 |
+
audio_base64=audio_b64,
|
| 738 |
+
audio_mime=audio_mime,
|
| 739 |
+
faithfulness_passed=turn.faithfulness_passed,
|
| 740 |
+
faithfulness_reasons=turn.faithfulness_reasons,
|
| 741 |
+
blocked=turn.blocked,
|
| 742 |
+
profile_updates=turn.profile_updates,
|
| 743 |
+
)
|
| 744 |
+
except Exception as _resp_err: # noqa: BLE001
|
| 745 |
+
# Anything else (TypeError/AttributeError/ValidationError) on the
|
| 746 |
+
# response-build path — return the standard error_fallback shape
|
| 747 |
+
# so the frontend always parses cleanly. Bug B catch-all.
|
| 748 |
+
logging.exception(
|
| 749 |
+
"chat response-build failed (session=%s): %s",
|
| 750 |
+
session_id, _resp_err,
|
| 751 |
+
)
|
| 752 |
+
return ChatResponse(
|
| 753 |
+
reply_text=(
|
| 754 |
+
"Sorry, something went wrong — try again"
|
| 755 |
+
),
|
| 756 |
+
citations=[],
|
| 757 |
+
brain_used="error_fallback",
|
| 758 |
+
intent="qa",
|
| 759 |
+
language="en",
|
| 760 |
+
latency_ms=int((time.time() - t_chat0) * 1000),
|
| 761 |
+
session_id=session_id,
|
| 762 |
+
audio_base64=None,
|
| 763 |
+
audio_mime=None,
|
| 764 |
+
faithfulness_passed=True,
|
| 765 |
+
faithfulness_reasons=[],
|
| 766 |
+
blocked=False,
|
| 767 |
+
profile_updates={},
|
| 768 |
+
)
|
| 769 |
|
| 770 |
|
| 771 |
@app.get("/api/coverage", response_model=CoverageResponse)
|
|
@@ -76,6 +76,26 @@ YOUR JOB:
|
|
| 76 |
|
| 77 |
REQUIRED slots before recommending: name, age, dependents, location_tier, income_band, primary_goal, health_conditions.
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
═══════════════════════════════════════════════════════════
|
| 80 |
RULE 1 (HIGHEST PRIORITY) — save_profile_field is MANDATORY
|
| 81 |
═══════════════════════════════════════════════════════════
|
|
@@ -87,10 +107,18 @@ of these facts and call save_profile_field ONCE PER FACT:
|
|
| 87 |
(metro = Bangalore/Mumbai/Delhi/Chennai/Hyderabad/Kolkata/Pune/Ahmedabad)
|
| 88 |
• Family members ("wife", "husband", "kid", "parents") → save_profile_field(field="dependents", value="...")
|
| 89 |
• Income / salary / lakhs → save_profile_field(field="income_band", value="10L-25L" or similar)
|
| 90 |
-
•
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
→ You MUST call:
|
| 95 |
save_profile_field(field="name", value="Priya")
|
| 96 |
save_profile_field(field="age", value="34")
|
|
@@ -98,6 +126,12 @@ Worked example. User says: "Hi I'm Priya, 34, Bangalore, with husband and one ki
|
|
| 98 |
save_profile_field(field="dependents", value="self+spouse+1 kid")
|
| 99 |
→ THEN write a short prose reply asking for the remaining slots (income, goal, health).
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
NEVER ask the user for a fact you can already extract from their last message. Capture FIRST, then ask only for what's missing.
|
| 102 |
|
| 103 |
═══════════════════════════════════════════════════════════
|
|
@@ -124,6 +158,69 @@ RULE 3 — Follow-ups + mark_recommendation
|
|
| 124 |
- After producing a ranked shortlist, call mark_recommendation(policy_ids=[...ordered IDs you cited...]).
|
| 125 |
- For "tell me about #2" / "second one" follow-ups, call retrieve_policies(query, policy_filter_ids=[policy_id_of_#2]) to narrow to that policy.
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
═══════════════════════════════════════════════════════════
|
| 128 |
GROUND RULES
|
| 129 |
═══════════════════════════════════════════════════════════
|
|
@@ -131,7 +228,6 @@ GROUND RULES
|
|
| 131 |
- If retrieve_policies returns zero chunks after both attempts, ask the user one clarifying question.
|
| 132 |
- Be concise: 2-3 sentence turns. No emoji unless the user used one first.
|
| 133 |
- Indian context: use lakh / crore, ₹, IRDAI, Section 80D. NEVER say "dollars" / "$".
|
| 134 |
-
- Returning users may have a pre-populated profile — greet them by name, summarise what you remember, ask to confirm or update.
|
| 135 |
"""
|
| 136 |
|
| 137 |
|
|
@@ -523,6 +619,72 @@ async def _gemini_call(
|
|
| 523 |
)
|
| 524 |
|
| 525 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
def _extract_parts(payload: dict) -> list[dict]:
|
| 527 |
"""Pull the `parts` list out of the first candidate. Empty list on
|
| 528 |
any missing-key path so the caller decides what to do."""
|
|
@@ -548,6 +710,49 @@ def _parts_text(parts: list[dict]) -> str:
|
|
| 548 |
)
|
| 549 |
|
| 550 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
def _parts_function_calls(parts: list[dict]) -> list[dict]:
|
| 552 |
"""Pull every functionCall block out of parts. Each entry is
|
| 553 |
{"name": "...", "args": {...}}."""
|
|
@@ -739,6 +944,13 @@ async def handle_turn(
|
|
| 739 |
"Sorry — I lost my train of thought there. Could you say that again?"
|
| 740 |
)
|
| 741 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 742 |
# Citations: deduped by chunk_id; same shape orchestrator emits.
|
| 743 |
seen_ids: set[str] = set()
|
| 744 |
citations: list[dict] = []
|
|
|
|
| 76 |
|
| 77 |
REQUIRED slots before recommending: name, age, dependents, location_tier, income_band, primary_goal, health_conditions.
|
| 78 |
|
| 79 |
+
═══════════════════════════════════════════════════════════
|
| 80 |
+
ABSOLUTE RULE — NO POLICY NAMES WITHOUT RETRIEVE
|
| 81 |
+
═══════════════════════════════════════════════════════════
|
| 82 |
+
NEVER mention a policy name, UIN, insurer, or product (Star Health,
|
| 83 |
+
HDFC Ergo, Niva Bupa, Care, Aditya Birla, ICICI Lombard, Bajaj Allianz,
|
| 84 |
+
Manipal Cigna, Acko, Go Digit, Max Bupa, Reliance General, SBI General,
|
| 85 |
+
Tata AIG, etc.) UNLESS:
|
| 86 |
+
(a) retrieve_policies returned that exact policy_id in the current
|
| 87 |
+
session, AND
|
| 88 |
+
(b) you cite it in the format [Source: Policy Name (insurer), UIN].
|
| 89 |
+
|
| 90 |
+
If the user asks about a specific policy and you have NO retrieve_policies
|
| 91 |
+
result for it, say "I don't have that policy in my recommendations — let
|
| 92 |
+
me search for it" and call retrieve_policies with the policy name as the
|
| 93 |
+
query, top_k=1, policy_filter_ids=None.
|
| 94 |
+
|
| 95 |
+
If retrieve_policies returns nothing for that name, say "I couldn't find
|
| 96 |
+
that policy in our index. Let me suggest some alternatives" and call
|
| 97 |
+
retrieve_policies with a broader query based on the profile.
|
| 98 |
+
|
| 99 |
═══════════════════════════════════════════════════════════
|
| 100 |
RULE 1 (HIGHEST PRIORITY) — save_profile_field is MANDATORY
|
| 101 |
═══════════════════════════════════════════════════════════
|
|
|
|
| 107 |
(metro = Bangalore/Mumbai/Delhi/Chennai/Hyderabad/Kolkata/Pune/Ahmedabad)
|
| 108 |
• Family members ("wife", "husband", "kid", "parents") → save_profile_field(field="dependents", value="...")
|
| 109 |
• Income / salary / lakhs → save_profile_field(field="income_band", value="10L-25L" or similar)
|
| 110 |
+
• Primary-goal natural phrasings → save_profile_field(field="primary_goal", value=...):
|
| 111 |
+
"first policy" / "switching from corporate" / "leaving job" / "lost employer cover" → first_buy
|
| 112 |
+
"upgrade" / "better coverage" / "more cover" / "increase sum insured" → upgrade
|
| 113 |
+
"save tax" / "Section 80D" / "tax benefit" → tax_planning
|
| 114 |
+
"too expensive" / "cheaper option" / "premium too high" → cost_optimize
|
| 115 |
+
• "diabetes" / "BP" / pre-existing conditions → save_profile_field(field="health_conditions", value="diabetes" or "BP, thyroid")
|
| 116 |
+
• "no health issues" / "no medical issues" / "no PED" / "nothing" / "I'm healthy" / "no conditions" / "all good" →
|
| 117 |
+
save_profile_field(field="health_conditions", value="none")
|
| 118 |
+
← MANDATORY even though it's a negation. "none" tells the system the slot is captured.
|
| 119 |
+
Without this call the profile stays incomplete forever and the bot loops asking for PED.
|
| 120 |
+
|
| 121 |
+
Worked example A. User says: "Hi I'm Priya, 34, Bangalore, with husband and one kid"
|
| 122 |
→ You MUST call:
|
| 123 |
save_profile_field(field="name", value="Priya")
|
| 124 |
save_profile_field(field="age", value="34")
|
|
|
|
| 126 |
save_profile_field(field="dependents", value="self+spouse+1 kid")
|
| 127 |
→ THEN write a short prose reply asking for the remaining slots (income, goal, health).
|
| 128 |
|
| 129 |
+
Worked example B (negation — DO NOT SKIP). User says: "No medical issues"
|
| 130 |
+
→ You MUST call:
|
| 131 |
+
save_profile_field(field="health_conditions", value="none")
|
| 132 |
+
→ No exceptions. The same applies to "no health issues", "no PED",
|
| 133 |
+
"nothing", "I'm healthy", "no conditions", "all good".
|
| 134 |
+
|
| 135 |
NEVER ask the user for a fact you can already extract from their last message. Capture FIRST, then ask only for what's missing.
|
| 136 |
|
| 137 |
═══════════════════════════════════════════════════════════
|
|
|
|
| 158 |
- After producing a ranked shortlist, call mark_recommendation(policy_ids=[...ordered IDs you cited...]).
|
| 159 |
- For "tell me about #2" / "second one" follow-ups, call retrieve_policies(query, policy_filter_ids=[policy_id_of_#2]) to narrow to that policy.
|
| 160 |
|
| 161 |
+
═════════════════════════════════��═════════════════════════
|
| 162 |
+
RULE 4 — Returning-user greeting (pre-populated profile)
|
| 163 |
+
═══════════════════════════════════════════════════════════
|
| 164 |
+
If the KNOWN PROFILE block below is non-empty AT TURN 1 (no chat history,
|
| 165 |
+
session.profile arrived pre-populated from a prior conversation), your FIRST
|
| 166 |
+
reply MUST:
|
| 167 |
+
1. Greet by name: "Welcome back, [name]!"
|
| 168 |
+
2. Summarise what you remember in 1-2 short bullets (e.g. age, city,
|
| 169 |
+
dependents, primary_goal, health_conditions).
|
| 170 |
+
3. Ask: "Has anything changed since last time, or should we go with this
|
| 171 |
+
profile?"
|
| 172 |
+
4. WAIT for explicit user confirmation BEFORE calling retrieve_policies.
|
| 173 |
+
Do NOT skip the confirmation step even if all 7 slots look complete.
|
| 174 |
+
|
| 175 |
+
═══════════════════════════════════════════════════════════
|
| 176 |
+
RULE 5 — Comparison view ("compare #1 and #3")
|
| 177 |
+
═══════════════════════════════════════════════════════════
|
| 178 |
+
When the user asks to compare two or more shortlisted policies ("compare
|
| 179 |
+
#1 and #3", "what's the difference between Plan A and Plan B",
|
| 180 |
+
"#2 vs #4"):
|
| 181 |
+
1. Call retrieve_policies(policy_filter_ids=[id_of_A, id_of_B], top_k=4)
|
| 182 |
+
in ONE call so both policies' chunks come back together.
|
| 183 |
+
2. Produce an explicit side-by-side comparison — markdown table with
|
| 184 |
+
columns | Feature | Policy A | Policy B | OR paired bullets
|
| 185 |
+
("Sum insured: A = ₹10L, B = ₹15L"). Cover at minimum: sum insured,
|
| 186 |
+
premium, room rent, PED waiting period, key exclusions.
|
| 187 |
+
3. Cite each cell with [Source: ..., UIN]. Do NOT just dump retrieved
|
| 188 |
+
text — explicitly contrast.
|
| 189 |
+
|
| 190 |
+
═══════════════════════════════════════════════════════════
|
| 191 |
+
RULE 6 — Out-of-scope refusal (non-health products)
|
| 192 |
+
═══════════════════════════════════════════════════════════
|
| 193 |
+
You ONLY advise on Indian health insurance. If the user asks about life
|
| 194 |
+
insurance, term plans, ULIPs, car / motor / two-wheeler insurance, home
|
| 195 |
+
insurance, travel insurance, mutual funds, or any non-health product,
|
| 196 |
+
politely refuse and redirect:
|
| 197 |
+
"I specialise in Indian health insurance — for [life / car / ULIP / etc.],
|
| 198 |
+
you'd want a different advisor. Anything else I can help with on health
|
| 199 |
+
coverage?"
|
| 200 |
+
Do NOT call retrieve_policies for out-of-scope queries.
|
| 201 |
+
|
| 202 |
+
═══════════════════════════════════════════════════════════
|
| 203 |
+
RULE 7 — Soft close after the customer picks one
|
| 204 |
+
═══════════════════════════════════════════════════════════
|
| 205 |
+
Once you have recommended AND the user has chosen a single policy ("I'll
|
| 206 |
+
go with #2", "let's pick the HDFC one", "sounds good"):
|
| 207 |
+
1. Call mark_recommendation(policy_ids=[chosen_id], is_final=true).
|
| 208 |
+
2. Offer next steps in one short reply:
|
| 209 |
+
"Great choice! Would you like me to walk you through the purchase
|
| 210 |
+
steps, or summarise the key benefits one more time?"
|
| 211 |
+
Do not re-pitch alternatives after the user has chosen — only act on
|
| 212 |
+
their next instruction.
|
| 213 |
+
|
| 214 |
+
═══════════════════════════════════════════════════════════
|
| 215 |
+
RULE 8 — Indic-language mirroring
|
| 216 |
+
═══════════════════════════════════════════════════════════
|
| 217 |
+
If the user's last message is in an Indian language (Hindi, Marathi,
|
| 218 |
+
Tamil, Telugu, Bengali, Kannada, Gujarati, Punjabi, Malayalam, etc.) or
|
| 219 |
+
Hinglish (Latin-script Hindi), respond in the SAME language. Use the same
|
| 220 |
+
tools regardless of language — tool args (field names, policy queries)
|
| 221 |
+
remain English; only your prose reply mirrors the user's language.
|
| 222 |
+
Citations stay in the canonical [Source: ..., UIN] format.
|
| 223 |
+
|
| 224 |
═══════════════════════════════════════════════════════════
|
| 225 |
GROUND RULES
|
| 226 |
═══════════════════════════════════════════════════════════
|
|
|
|
| 228 |
- If retrieve_policies returns zero chunks after both attempts, ask the user one clarifying question.
|
| 229 |
- Be concise: 2-3 sentence turns. No emoji unless the user used one first.
|
| 230 |
- Indian context: use lakh / crore, ₹, IRDAI, Section 80D. NEVER say "dollars" / "$".
|
|
|
|
| 231 |
"""
|
| 232 |
|
| 233 |
|
|
|
|
| 619 |
)
|
| 620 |
|
| 621 |
|
| 622 |
+
# ---------- boot warmup -----------------------------------------------------
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
async def warmup() -> Optional[float]:
|
| 626 |
+
"""Pre-warm the Gemini connection on FastAPI startup.
|
| 627 |
+
|
| 628 |
+
The first real /api/chat turn carries 4-5s of cold-start latency:
|
| 629 |
+
HTTPS connection establishment, TLS handshake, Gemini auth, and the
|
| 630 |
+
first response cache init. Firing a tiny dummy request at boot pushes
|
| 631 |
+
that cost off the user's critical path.
|
| 632 |
+
|
| 633 |
+
Conditional on USE_SINGLE_BRAIN: if the flag is off, the cold start
|
| 634 |
+
will never matter because single_brain.handle_turn won't run; skip.
|
| 635 |
+
|
| 636 |
+
Returns the wall-clock latency in seconds on success, None on skip or
|
| 637 |
+
failure. Never raises — the caller (boot hook) treats any failure as
|
| 638 |
+
a non-fatal warning.
|
| 639 |
+
"""
|
| 640 |
+
flag = os.environ.get("USE_SINGLE_BRAIN", "false").strip().lower()
|
| 641 |
+
if flag not in ("1", "true", "yes", "on"):
|
| 642 |
+
_log.info("single_brain.warmup skipped — USE_SINGLE_BRAIN is off")
|
| 643 |
+
return None
|
| 644 |
+
|
| 645 |
+
api_key = os.environ.get("GOOGLE_API_KEY", "").strip()
|
| 646 |
+
if not api_key:
|
| 647 |
+
_log.warning("single_brain.warmup skipped — GOOGLE_API_KEY not set")
|
| 648 |
+
return None
|
| 649 |
+
|
| 650 |
+
model = _resolve_model()
|
| 651 |
+
url = f"{GEMINI_BASE_URL}/{model}:generateContent?key={api_key}"
|
| 652 |
+
body = {
|
| 653 |
+
"systemInstruction": {"parts": [{"text": "warmup ping"}]},
|
| 654 |
+
"contents": [{"role": "user", "parts": [{"text": "ping"}]}],
|
| 655 |
+
"generationConfig": {"maxOutputTokens": 10},
|
| 656 |
+
}
|
| 657 |
+
headers = {"Content-Type": "application/json"}
|
| 658 |
+
client_timeout = httpx.Timeout(connect=2.0, read=8.0, write=2.0, pool=2.0)
|
| 659 |
+
|
| 660 |
+
t0 = time.perf_counter()
|
| 661 |
+
try:
|
| 662 |
+
async with httpx.AsyncClient(timeout=client_timeout) as client:
|
| 663 |
+
resp = await client.post(url, headers=headers, json=body)
|
| 664 |
+
elapsed = time.perf_counter() - t0
|
| 665 |
+
if resp.status_code >= 400:
|
| 666 |
+
_log.warning(
|
| 667 |
+
"single_brain.warmup non-2xx (HTTP %d, %.2fs) — boot continues",
|
| 668 |
+
resp.status_code, elapsed,
|
| 669 |
+
)
|
| 670 |
+
return elapsed
|
| 671 |
+
# Discard payload; we only care about latency + that the round-trip
|
| 672 |
+
# succeeded so the next real call hits a warm socket + auth cache.
|
| 673 |
+
_ = resp.text
|
| 674 |
+
_log.info(
|
| 675 |
+
"single_brain.warmup OK (model=%s, latency=%.2fs)",
|
| 676 |
+
model, elapsed,
|
| 677 |
+
)
|
| 678 |
+
return elapsed
|
| 679 |
+
except Exception as e: # noqa: BLE001
|
| 680 |
+
elapsed = time.perf_counter() - t0
|
| 681 |
+
_log.warning(
|
| 682 |
+
"single_brain.warmup failed after %.2fs (%s: %s) — boot continues",
|
| 683 |
+
elapsed, type(e).__name__, str(e)[:200],
|
| 684 |
+
)
|
| 685 |
+
return None
|
| 686 |
+
|
| 687 |
+
|
| 688 |
def _extract_parts(payload: dict) -> list[dict]:
|
| 689 |
"""Pull the `parts` list out of the first candidate. Empty list on
|
| 690 |
any missing-key path so the caller decides what to do."""
|
|
|
|
| 710 |
)
|
| 711 |
|
| 712 |
|
| 713 |
+
# Bug C defensive detector. Brands/products that MUST come from a
|
| 714 |
+
# retrieve_policies result. If the bot emits any of these in its reply
|
| 715 |
+
# while session.last_retrieved_chunks is empty, log a WARNING so future
|
| 716 |
+
# smoke logs can flag hallucinations. Detection-only — does NOT block.
|
| 717 |
+
_BRAND_HALLUCINATION_TOKENS = (
|
| 718 |
+
"star health", "hdfc ergo", "niva bupa", "max bupa", "care health",
|
| 719 |
+
"aditya birla", "icici lombard", "bajaj allianz", "manipal cigna",
|
| 720 |
+
"manipalcigna", "acko", "go digit", "godigit", "reliance general",
|
| 721 |
+
"sbi general", "tata aig", "iffco tokio", "cholamandalam",
|
| 722 |
+
"national insurance", "new india assurance", "oriental insurance",
|
| 723 |
+
"united india", "family health optima", "optima secure",
|
| 724 |
+
"reassure", "health companion", "easy health", "activ health",
|
| 725 |
+
"health advantedge", "complete health",
|
| 726 |
+
)
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
def _scan_for_brand_hallucinations(reply_text: str, session) -> None:
|
| 730 |
+
"""If the bot mentions an insurer/product brand but session has no
|
| 731 |
+
retrieved chunks, log a WARNING. Detection-only (Bug C secondary
|
| 732 |
+
defense — the system-prompt rule is primary). Swallow any
|
| 733 |
+
exception — bookkeeping must never break a chat turn.
|
| 734 |
+
"""
|
| 735 |
+
try:
|
| 736 |
+
if not reply_text:
|
| 737 |
+
return
|
| 738 |
+
last_chunks = getattr(session, "last_retrieved_chunks", None) or []
|
| 739 |
+
if last_chunks:
|
| 740 |
+
return # retrieve_policies has run; brand mentions are sourced
|
| 741 |
+
haystack = reply_text.lower()
|
| 742 |
+
hits = [tok for tok in _BRAND_HALLUCINATION_TOKENS if tok in haystack]
|
| 743 |
+
if hits:
|
| 744 |
+
_log.warning(
|
| 745 |
+
"single_brain possible policy hallucination — "
|
| 746 |
+
"reply mentions brand(s)=%r but session.last_retrieved_chunks "
|
| 747 |
+
"is empty. session=%s reply_snippet=%r",
|
| 748 |
+
hits,
|
| 749 |
+
getattr(session, "session_id", "?"),
|
| 750 |
+
reply_text[:200],
|
| 751 |
+
)
|
| 752 |
+
except Exception: # noqa: BLE001 — observational only
|
| 753 |
+
pass
|
| 754 |
+
|
| 755 |
+
|
| 756 |
def _parts_function_calls(parts: list[dict]) -> list[dict]:
|
| 757 |
"""Pull every functionCall block out of parts. Each entry is
|
| 758 |
{"name": "...", "args": {...}}."""
|
|
|
|
| 944 |
"Sorry — I lost my train of thought there. Could you say that again?"
|
| 945 |
)
|
| 946 |
|
| 947 |
+
# Bug C secondary defense — log a WARNING if the reply name-drops an
|
| 948 |
+
# insurer/product brand even though no retrieve_policies result was
|
| 949 |
+
# cached on the session. The system-prompt ABSOLUTE RULE is the
|
| 950 |
+
# primary defense; this only exists so a future regression shows up
|
| 951 |
+
# in smoke logs instead of going silent.
|
| 952 |
+
_scan_for_brand_hallucinations(reply_text, session)
|
| 953 |
+
|
| 954 |
# Citations: deduped by chunk_id; same shape orchestrator emits.
|
| 955 |
seen_ids: set[str] = set()
|
| 956 |
citations: list[dict] = []
|