rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
1344492
·
1 Parent(s): fe7ca39

fix(single_brain): KI-245..KI-246 — Z6 + W2 bundle (3 bugs + 6 prompt edges)

Browse files

Z6 — 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>

Files changed (2) hide show
  1. backend/main.py +159 -30
  2. backend/single_brain.py +217 -5
backend/main.py CHANGED
@@ -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
- log_turn({
611
- "session_id": session_id,
612
- "user_text": req.user_text,
613
- "reply_text": turn.reply_text,
614
- "brain_used": turn.brain_used,
615
- "intent": turn.intent,
616
- "language": turn.language,
617
- "latency_ms": turn.latency_ms,
618
- "retrieved_chunk_ids": turn.retrieved_chunk_ids,
619
- "citation_count": len(turn.citations),
620
- "faithfulness_passed": turn.faithfulness_passed,
621
- "faithfulness_reasons": turn.faithfulness_reasons,
622
- "blocked": turn.blocked,
623
- })
624
-
625
- return ChatResponse(
626
- reply_text=turn.reply_text,
627
- citations=[CitationOut(**c) for c in turn.citations],
628
- brain_used=turn.brain_used,
629
- intent=turn.intent,
630
- language=turn.language,
631
- latency_ms=turn.latency_ms,
632
- session_id=session_id,
633
- audio_base64=audio_b64,
634
- audio_mime=audio_mime,
635
- faithfulness_passed=turn.faithfulness_passed,
636
- faithfulness_reasons=turn.faithfulness_reasons,
637
- blocked=turn.blocked,
638
- profile_updates=turn.profile_updates,
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)
backend/single_brain.py CHANGED
@@ -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
- "First policy" / "upgrade" / "tax planning" → save_profile_field(field="primary_goal", value="first_buy" or "upgrade" or "tax_planning")
91
- "diabetes" / "BP" / "no health issues" save_profile_field(field="health_conditions", value="diabetes" or "" or "none")
92
-
93
- Worked example. User says: "Hi I'm Priya, 34, Bangalore, with husband and one kid"
 
 
 
 
 
 
 
 
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] = []