Spaces:
Sleeping
refactor(brain): KI-167 — rip out fact_find_brain, replace with sales_brain
Browse filesReplaces scripted slot-walker (fact_find_brain + canonical_fallback +
scripted Question.prompt_en + "Got that —" prefix + <FF> trailer) with a
single LLM-driven sales agent using NIM response_format=json_object.
WHY:
8 patches (KI-150/155/156/158/161) all worked around <FF> trailer
fragility + canonical_fallback firing as the dominant path. Symptom:
"zero natural LLM chat — always defaults to script." Architectural
rot from reactive accretion.
NEW:
+ backend/sales_brain.py — single LLM call, response_format=json_object,
SalesBrainResult{reply_text, captured_updates, ready_for_recommendations,
brain_used}. NO scripted fallback. Fail-loud on errors.
+ backend/sales_brain_normalizer.py — deterministic post-processor:
city→tier, "5 lakhs"→"5L-10L", bad-name rejection, schema validation.
DELETED:
- backend/fact_find_brain.py (986 lines)
- backend/fact_find_normalizer.py (452 lines — cherry-picked into normalizer)
- tests/test_fact_find_loop_break.py (architecture retired)
CHANGED:
M backend/orchestrator.py — fact_find branch rewired to drive_sales_brain
M backend/needs_finder.py — GRAPH + Question + scripted prompt_en deleted,
next_question returns field-name str now
M backend/main.py — /api/profile/completeness uses field-name hint
M tests/test_name_persistence.py — CASE B mocks SalesBrainResult
VERIFICATION:
py_compile + import smoke clean. next_question(empty)→"name". Zero
stale fact_find_brain / fact_find_normalizer / GRAPH / Question /
FactFindOutcome / FIELD_TO_QUESTION_ID / _canonical_fallback imports
anywhere in backend/tests/tools/eval/.
DOCS CASCADE: held back per user request. ADR-039 + CLAUDE.md + README.md
stashed locally — will commit after live confirmation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/fact_find_brain.py +0 -986
- backend/fact_find_normalizer.py +0 -452
- backend/main.py +4 -3
- backend/needs_finder.py +102 -287
- backend/orchestrator.py +46 -79
- backend/sales_brain.py +470 -0
- backend/sales_brain_normalizer.py +687 -0
- tests/README.md +4 -0
- tests/test_fact_find_loop_break.py +0 -191
- tests/test_name_persistence.py +50 -42
|
@@ -1,986 +0,0 @@
|
|
| 1 |
-
"""KI-070 — Single-LLM-call fact-find brain.
|
| 2 |
-
|
| 3 |
-
Replaces three stitched layers that previously drove fact-find:
|
| 4 |
-
1. `backend/needs_finder.GRAPH` — hardcoded canonical question prompts
|
| 5 |
-
2. `backend/question_paraphraser.py` — per-turn LLM paraphrase pass
|
| 6 |
-
3. `backend/orchestrator._pick_opener` + `_family_aware_opener` —
|
| 7 |
-
acknowledger rotation prepended to each canonical question
|
| 8 |
-
|
| 9 |
-
Real-user testing on 2026-05-15 flagged the result as robotic + templated.
|
| 10 |
-
The fix is to do the whole job in ONE LLM call per turn that produces a
|
| 11 |
-
natural conversational reply AND tags which slots were captured / which
|
| 12 |
-
slot it's driving toward / whether fact-find is complete — all in a single
|
| 13 |
-
machine-parsable trailer block.
|
| 14 |
-
|
| 15 |
-
Public entry:
|
| 16 |
-
async def drive_fact_find(user_text, session, chat_history, session_id)
|
| 17 |
-
-> FactFindOutcome
|
| 18 |
-
|
| 19 |
-
Reliability guardrails:
|
| 20 |
-
1. JSON-block-must-parse — if `<FF>...</FF>` is missing or invalid,
|
| 21 |
-
return `ambiguous=true` so the orchestrator emits a soft canonical
|
| 22 |
-
fallback (`next_question` then prompt_en).
|
| 23 |
-
2. Capture validation — each captured value goes through
|
| 24 |
-
`fact_find_normalizer._validate` before commit. Validation failure
|
| 25 |
-
drops only that field; others still apply.
|
| 26 |
-
3. Slot-not-progressing safeguard — if the SAME `slot_driving` appears
|
| 27 |
-
in 3 consecutive turns, force `next_question(profile)` to pick a
|
| 28 |
-
different slot. State is held on the SessionState (`_ff_brain_history`).
|
| 29 |
-
4. Hard timeout 12 seconds. Timeout → ambiguous=True + the canonical
|
| 30 |
-
`next_question.prompt_en` as a fallback reply.
|
| 31 |
-
"""
|
| 32 |
-
|
| 33 |
-
from __future__ import annotations
|
| 34 |
-
|
| 35 |
-
import asyncio
|
| 36 |
-
import json
|
| 37 |
-
import logging
|
| 38 |
-
import re
|
| 39 |
-
import time
|
| 40 |
-
from dataclasses import dataclass, field
|
| 41 |
-
from typing import Any, Optional
|
| 42 |
-
|
| 43 |
-
from backend.providers.base import ChatMessage
|
| 44 |
-
from backend.providers.nvidia_nim_llm import get_fast_brain_llm
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
# ----------------------------------------------------------------------------
|
| 48 |
-
# Outcome dataclass
|
| 49 |
-
# ----------------------------------------------------------------------------
|
| 50 |
-
|
| 51 |
-
@dataclass
|
| 52 |
-
class FactFindOutcome:
|
| 53 |
-
reply_text: str
|
| 54 |
-
captured_updates: dict[str, Any] = field(default_factory=dict)
|
| 55 |
-
slot_driving: Optional[str] = None
|
| 56 |
-
fact_find_complete: bool = False
|
| 57 |
-
ambiguous: bool = False
|
| 58 |
-
# KI-078 (2026-05-15) — when `ambiguous=True` because the LLM brain
|
| 59 |
-
# bailed and `_canonical_fallback` was used, this stamps WHY so the
|
| 60 |
-
# orchestrator can append it to `brain_used` and admin telemetry can
|
| 61 |
-
# measure the fallback-reason mix. One of:
|
| 62 |
-
# "timeout" — asyncio.wait_for(_TIMEOUT_S) expired on FAST_BRAIN_CHAIN
|
| 63 |
-
# "llm_error" — FAST_BRAIN_CHAIN raised (non-timeout) before returning
|
| 64 |
-
# "no_trailer" — reply had no <FF>...</FF> JSON block, or it failed parse
|
| 65 |
-
# "empty_reply" — trailer stripped to an empty user-facing reply
|
| 66 |
-
# KI-079 (2026-05-15) — added two escalation-path reasons. When FAST
|
| 67 |
-
# chain times out we re-try once against BRAIN_CHAIN (heavier; Qwen 80B
|
| 68 |
-
# primary, more cross-provider fallbacks) inside a shorter budget. The
|
| 69 |
-
# _fallback_reason then captures the escalation outcome:
|
| 70 |
-
# "timeout_after_escalation" — both FAST + BRAIN chains timed out
|
| 71 |
-
# "llm_error_after_escalation" — BRAIN chain raised on the retry
|
| 72 |
-
# None when the brain succeeded (either FAST primary OR BRAIN escalation).
|
| 73 |
-
_fallback_reason: Optional[str] = None
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
# ----------------------------------------------------------------------------
|
| 77 |
-
# System prompt — the ENTIRE behavioural contract lives here. Keep tight.
|
| 78 |
-
# ----------------------------------------------------------------------------
|
| 79 |
-
|
| 80 |
-
_SYSTEM_PROMPT = """You are an insurance advisor having a natural conversation with someone considering a health policy in India. Be warm, brief, consultative — NOT closing-pitchy. Speak like a trusted Independent Financial Advisor, not a call-centre script.
|
| 81 |
-
|
| 82 |
-
You need to capture these 9 facts before recommending. Don't ask them all at once — weave them into the conversation based on what the user just said.
|
| 83 |
-
|
| 84 |
-
SLOTS YOU NEED TO FILL (field name : type : allowed values : why it matters)
|
| 85 |
-
- name : string (1-50 chars) : for personalisation + saving their profile so they don't repeat fact-find next visit
|
| 86 |
-
- age : int 18-99 : drives premium, eligibility, renewability
|
| 87 |
-
- dependents : enum {self, self+spouse, self+spouse+kids, self+kids, self+parents, self+spouse+parents, self+spouse+kids+parents} : drives policy structure (parent-specific plans often win when parents are on)
|
| 88 |
-
- income_band : enum {under_5L, 5L-10L, 10L-25L, 25L+} : drives sum-insured sizing
|
| 89 |
-
- existing_cover_inr : int >= 0 (0 if first-time buyer or no employer plan) : drives whether to recommend top-up vs full base plan
|
| 90 |
-
- primary_goal : enum {first_buy, upgrade, compare_specific, tax_planning} : drives whether to grade on price / breadth / claim experience / tax savings
|
| 91 |
-
- location_tier : enum {metro, tier1, tier2, tier3} : drives premium + cashless hospital density
|
| 92 |
-
- parents_age_max : int 30-99 (only relevant when dependents includes parents) : drives whether parent-specific plans should be included
|
| 93 |
-
- health_conditions : list of lowercase canonical names like ["diabetes", "hypertension", "thyroid", "asthma", "heart", "cancer"] (empty list = no conditions) : drives PED waiting + insurer fit. Honesty here protects the user's future claim.
|
| 94 |
-
- budget_band : enum {under_15k, 15k_30k, 30k_60k, 60k+} : drives policy shortlist
|
| 95 |
-
|
| 96 |
-
CONVERSATION RULES
|
| 97 |
-
1. Reply naturally. Don't say "Got it." or "Noted." every turn. Don't apologise robotically. Don't number your questions.
|
| 98 |
-
2. Acknowledge what the user just told you — reflect it back briefly so they feel heard.
|
| 99 |
-
3. Ask for at most ONE missing slot per reply, woven into the conversation.
|
| 100 |
-
4. If the user supplied multiple facts in one message (e.g., "I'm Rohit, 29, in Mumbai"), capture them ALL — don't re-ask for what they just volunteered.
|
| 101 |
-
5. If they go off-topic, give a brief honest answer, then steer back.
|
| 102 |
-
6. If they say "show me policies", "let me see options", "that's enough", "skip the questions" — set complete=true and slot_driving=null. Don't keep fact-finding forever.
|
| 103 |
-
7. When the profile feels complete enough to recommend (at minimum age + dependents + income + primary_goal captured), proactively summarise the captured profile + ask if they want a policy suggestion. Set complete=true.
|
| 104 |
-
8. Indian English is fine — "₹", "lakh", "metro/tier-2 city" are natural. Never use markdown bold or italics — the reply is read aloud by TTS too.
|
| 105 |
-
9. BFSI compliance: do not promise specific premium quotes, do not pressure-close. Light, advisory tone.
|
| 106 |
-
10. On health conditions — be straight: hiding a condition lowers premium today but turns into a denied claim later. Encourage honesty without lecturing.
|
| 107 |
-
|
| 108 |
-
OUTPUT FORMAT (strict)
|
| 109 |
-
After your natural reply, on a NEW LINE, emit exactly one machine-readable trailer:
|
| 110 |
-
<FF>{"captured": {<field>: <value>, ...}, "slot_driving": "<slot_field_or_null>", "complete": <true_or_false>}</FF>
|
| 111 |
-
|
| 112 |
-
The <FF> block is INVISIBLE to the user — the backend strips it before sending.
|
| 113 |
-
|
| 114 |
-
Strict requirements for the <FF> block:
|
| 115 |
-
- Captured values MUST match the slot schema (enums exact, ints parsed as JSON numbers, strings trimmed, list as JSON array). Missing slots = omit them; do NOT emit null values for fields you didn't capture this turn.
|
| 116 |
-
- `slot_driving` MUST be the FIELD NAME (e.g., "income_band"), not the canonical-question id. Use null when fact-find is complete OR when the user has steered to a different intent.
|
| 117 |
-
- `complete` MUST be a JSON boolean (true / false), no quotes.
|
| 118 |
-
- If the user's latest message yielded no new facts, emit "captured": {} (empty object, not omitted).
|
| 119 |
-
- Emit the <FF> block exactly once and at the END. Nothing after </FF>.
|
| 120 |
-
|
| 121 |
-
EXAMPLES OF GOOD REPLIES (reply line + trailer)
|
| 122 |
-
|
| 123 |
-
User: "Hi, I'm Rohit, 29, looking to buy my first health policy."
|
| 124 |
-
Bot reply: Hi Rohit — nice to meet you. First health policy is a good place to start. To shape the right options for you, who else would you want covered apart from yourself — spouse, kids, parents, or just you for now?
|
| 125 |
-
<FF>{"captured": {"name": "Rohit", "age": 29, "primary_goal": "first_buy"}, "slot_driving": "dependents", "complete": false}</FF>
|
| 126 |
-
|
| 127 |
-
User: "Just me and my wife. We're both in Bangalore."
|
| 128 |
-
Bot reply: Got that — you and your spouse, based in Bangalore (so metro hospital network is a big advantage for cashless). What income band are you in roughly — under ₹5L, ₹5-10L, ₹10-25L, or ₹25L+? Helps me suggest the right sum-insured size.
|
| 129 |
-
<FF>{"captured": {"dependents": "self+spouse", "location_tier": "metro"}, "slot_driving": "income_band", "complete": false}</FF>
|
| 130 |
-
|
| 131 |
-
User: "Around 18 lakh."
|
| 132 |
-
Bot reply: That puts you in the ₹10-25L band — gives us comfortable room on sum-insured. Do either of you have any pre-existing conditions — diabetes, BP, thyroid, anything chronic? Be straight here: it protects your future claim.
|
| 133 |
-
<FF>{"captured": {"income_band": "10L-25L"}, "slot_driving": "health_conditions", "complete": false}</FF>
|
| 134 |
-
|
| 135 |
-
User: "Just show me some policies already."
|
| 136 |
-
Bot reply: Fair enough — let me put together a shortlist based on what you've shared so far: age 29, covering you and your spouse, metro, income ₹10-25L, first-time buyer. Want me to suggest 2-3 policies that fit, or do you have specific insurers in mind to compare?
|
| 137 |
-
<FF>{"captured": {}, "slot_driving": null, "complete": true}</FF>
|
| 138 |
-
|
| 139 |
-
User: "What's the waiting period for PED usually?"
|
| 140 |
-
Bot reply: Most policies in India have a 24-36 month waiting period for pre-existing diseases — IRDAI caps the maximum, and a few insurers offer shorter waits as a premium upgrade. Happy to dig into a specific policy if you have one in mind. Meanwhile, what budget range are you comfortable with annually — under ₹15k, ₹15-30k, ₹30-60k, or ₹60k+?
|
| 141 |
-
<FF>{"captured": {}, "slot_driving": "budget_band", "complete": false}</FF>
|
| 142 |
-
|
| 143 |
-
User: "Hi"
|
| 144 |
-
Bot reply: Hi there — happy to help you find a health policy. What should I call you?
|
| 145 |
-
<FF>{"captured": {}, "slot_driving": "name", "complete": false}</FF>
|
| 146 |
-
|
| 147 |
-
User: "ok"
|
| 148 |
-
Bot reply: Sure. To get started, could you tell me your age?
|
| 149 |
-
<FF>{"captured": {}, "slot_driving": "age", "complete": false}</FF>
|
| 150 |
-
|
| 151 |
-
==========================================================
|
| 152 |
-
FINAL INSTRUCTION — READ THIS CAREFULLY. APPLIES TO EVERY REPLY.
|
| 153 |
-
==========================================================
|
| 154 |
-
EVERY reply you produce MUST end with the structured trailer in this EXACT format on its own line:
|
| 155 |
-
|
| 156 |
-
<FF>{"captured": {...}, "slot_driving": "...", "complete": false}</FF>
|
| 157 |
-
|
| 158 |
-
This rule is ABSOLUTE. There are NO exceptions:
|
| 159 |
-
- Short reply? Still emit the trailer.
|
| 160 |
-
- One-word user message ("ok", "hi", "yes")? Still emit the trailer.
|
| 161 |
-
- User asks an off-topic question? Still emit the trailer after your answer.
|
| 162 |
-
- User wants to end fact-find ("show me policies")? Emit trailer with "complete": true.
|
| 163 |
-
- Nothing new captured this turn? Emit "captured": {} (empty object). Still emit the trailer.
|
| 164 |
-
|
| 165 |
-
The trailer is the LAST thing in your output. Nothing comes after </FF>.
|
| 166 |
-
|
| 167 |
-
If you do not emit a valid <FF>...</FF> trailer, your entire reply is DISCARDED by the backend and the user sees a scripted fallback question. This is the single most important rule. NEVER omit the trailer.
|
| 168 |
-
"""
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
# ----------------------------------------------------------------------------
|
| 172 |
-
# Parser for <FF>...</FF> trailer
|
| 173 |
-
# ----------------------------------------------------------------------------
|
| 174 |
-
|
| 175 |
-
_FF_BLOCK_RE = re.compile(r"<FF>\s*(\{.*?\})\s*</FF>", re.DOTALL)
|
| 176 |
-
# KI-090 (2026-05-15) — lenient fallback for FF parsing. Many LLMs drop
|
| 177 |
-
# the literal <FF>...</FF> tags and emit a bare JSON tail (or inline
|
| 178 |
-
# fenced JSON). When the strict tag match fails, try these regexes in
|
| 179 |
-
# order so we accept whatever the brain actually produces.
|
| 180 |
-
_FF_FENCED_RE = re.compile(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", re.IGNORECASE)
|
| 181 |
-
_FF_TAIL_JSON_RE = re.compile(r"(\{[\s\S]*\})\s*\Z")
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
def _parse_ff_block(text: str) -> Optional[dict]:
|
| 185 |
-
"""Extract + parse the structured trailer dict from an LLM reply.
|
| 186 |
-
|
| 187 |
-
Returns the parsed dict on success, None when no parseable JSON object
|
| 188 |
-
can be located. Caller treats None as `ambiguous=True` and falls
|
| 189 |
-
through to the canonical fallback.
|
| 190 |
-
|
| 191 |
-
KI-090 (2026-05-15) — lenient parsing. The original strict
|
| 192 |
-
`<FF>{...}</FF>` regex was correct per the system-prompt contract, but
|
| 193 |
-
real LLMs (Qwen, Nemotron under load, Groq Llama-3.3 at times) drop
|
| 194 |
-
the literal tags and emit only the JSON. Pre-KI-090 those replies
|
| 195 |
-
fell to `fallback:no_trailer` even though the brain had produced a
|
| 196 |
-
perfectly valid structured tail. Now we try:
|
| 197 |
-
1. Strict `<FF>{...}</FF>` (the contract — still preferred).
|
| 198 |
-
2. ```` ```json {...} ``` ```` fenced (a common LLM habit).
|
| 199 |
-
3. Bare `{...}` at the very end of the reply.
|
| 200 |
-
Any candidate that parses as a JSON dict with at least one of the
|
| 201 |
-
expected keys (`captured` / `slot_driving` / `complete`) wins.
|
| 202 |
-
"""
|
| 203 |
-
if not text:
|
| 204 |
-
return None
|
| 205 |
-
candidates: list[str] = []
|
| 206 |
-
m = _FF_BLOCK_RE.search(text)
|
| 207 |
-
if m:
|
| 208 |
-
candidates.append(m.group(1).strip())
|
| 209 |
-
for fenced in _FF_FENCED_RE.finditer(text):
|
| 210 |
-
candidates.append(fenced.group(1).strip())
|
| 211 |
-
tail = _FF_TAIL_JSON_RE.search(text.rstrip())
|
| 212 |
-
if tail:
|
| 213 |
-
candidates.append(tail.group(1).strip())
|
| 214 |
-
seen: set[str] = set()
|
| 215 |
-
for raw in candidates:
|
| 216 |
-
if raw in seen:
|
| 217 |
-
continue
|
| 218 |
-
seen.add(raw)
|
| 219 |
-
try:
|
| 220 |
-
data = json.loads(raw)
|
| 221 |
-
except json.JSONDecodeError:
|
| 222 |
-
continue
|
| 223 |
-
if not isinstance(data, dict):
|
| 224 |
-
continue
|
| 225 |
-
# Require at least one of the contract keys so we don't accept
|
| 226 |
-
# a stray JSON object from the prose (e.g. a quoted example).
|
| 227 |
-
if any(k in data for k in ("captured", "slot_driving", "complete")):
|
| 228 |
-
return data
|
| 229 |
-
return None
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
def _strip_ff_block(text: str) -> str:
|
| 233 |
-
"""Remove the FF trailer (tagged, fenced, or bare-JSON tail) so the
|
| 234 |
-
user-facing reply doesn't leak the schema tag.
|
| 235 |
-
|
| 236 |
-
KI-090 — mirrors the lenient `_parse_ff_block` strategies in reverse:
|
| 237 |
-
strip strict `<FF>...</FF>`, then ```` ```json ... ``` ````, then any
|
| 238 |
-
bare-JSON tail that contains a contract key.
|
| 239 |
-
"""
|
| 240 |
-
if not text:
|
| 241 |
-
return text
|
| 242 |
-
cleaned = _FF_BLOCK_RE.sub("", text).strip()
|
| 243 |
-
cleaned = _FF_FENCED_RE.sub("", cleaned).strip()
|
| 244 |
-
# Strip a bare-JSON tail ONLY if it contains a contract key — otherwise
|
| 245 |
-
# we might delete prose that happens to end with a JSON-ish bracket.
|
| 246 |
-
tail = _FF_TAIL_JSON_RE.search(cleaned)
|
| 247 |
-
if tail:
|
| 248 |
-
try:
|
| 249 |
-
tail_json = json.loads(tail.group(1))
|
| 250 |
-
if isinstance(tail_json, dict) and any(
|
| 251 |
-
k in tail_json for k in ("captured", "slot_driving", "complete")
|
| 252 |
-
):
|
| 253 |
-
cleaned = cleaned[: tail.start()].rstrip()
|
| 254 |
-
except json.JSONDecodeError:
|
| 255 |
-
pass
|
| 256 |
-
# Defensive: any orphan partial tags
|
| 257 |
-
cleaned = re.sub(r"<FF>.*$", "", cleaned, flags=re.DOTALL).strip()
|
| 258 |
-
cleaned = re.sub(r"</FF>", "", cleaned).strip()
|
| 259 |
-
return cleaned
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
# ----------------------------------------------------------------------------
|
| 263 |
-
# Schema map — field name → validator schema in fact_find_normalizer terms
|
| 264 |
-
# ----------------------------------------------------------------------------
|
| 265 |
-
#
|
| 266 |
-
# The slot prompt promises enum values like "self+spouse" and "10L-25L", and
|
| 267 |
-
# `fact_find_normalizer._FIELD_SCHEMA` already encodes those. Reuse it. Note
|
| 268 |
-
# that the orchestrator's Profile fields are keyed by the FIELD NAME (e.g.,
|
| 269 |
-
# `income_band`), not the canonical question id (`income_band`), and those
|
| 270 |
-
# happen to match for most slots — exceptions handled below.
|
| 271 |
-
|
| 272 |
-
# Slot field → canonical-question id (for marking `profile.asked` so
|
| 273 |
-
# `next_question` doesn't re-pick the same slot).
|
| 274 |
-
FIELD_TO_QUESTION_ID: dict[str, str] = {
|
| 275 |
-
"name": "name",
|
| 276 |
-
"age": "age",
|
| 277 |
-
"dependents": "dependents",
|
| 278 |
-
"income_band": "income_band",
|
| 279 |
-
"existing_cover_inr": "existing_cover",
|
| 280 |
-
"primary_goal": "primary_goal",
|
| 281 |
-
"location_tier": "location",
|
| 282 |
-
"parents_age_max": "parents_age",
|
| 283 |
-
"health_conditions": "health_conditions",
|
| 284 |
-
"budget_band": "budget",
|
| 285 |
-
}
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
def _validate_capture(field_name: str, value: Any) -> Any:
|
| 289 |
-
"""Run `value` through the fact_find_normalizer schema for the matching
|
| 290 |
-
field. Returns the validated value (possibly coerced) or None when the
|
| 291 |
-
value fails validation."""
|
| 292 |
-
if value is None:
|
| 293 |
-
return None
|
| 294 |
-
# Name is free-text — apply the profile_store name validator.
|
| 295 |
-
if field_name == "name":
|
| 296 |
-
try:
|
| 297 |
-
from backend.profile_store import is_valid_name
|
| 298 |
-
v = str(value).strip()
|
| 299 |
-
if not is_valid_name(v):
|
| 300 |
-
return None
|
| 301 |
-
# KI-156 (2026-05-15) — reject LLM-hallucinated names that are
|
| 302 |
-
# actually status/negation phrases. Live bug: user said "I am
|
| 303 |
-
# currently not having any policy" and the LLM emitted
|
| 304 |
-
# captured={"name":"Currently Not Having Any"}. is_valid_name
|
| 305 |
-
# passes on length+alpha but doesn't catch semantic garbage.
|
| 306 |
-
_bad_starts = {
|
| 307 |
-
"currently", "not", "no", "none", "nothing", "never",
|
| 308 |
-
"without", "looking", "buying", "shopping",
|
| 309 |
-
"i", "we", "my", "this", "that", "the",
|
| 310 |
-
"first", "still", "yet", "haven", "haven't",
|
| 311 |
-
"don't", "don", "dont", "havent",
|
| 312 |
-
}
|
| 313 |
-
first = v.split()[0].lower().strip(".,!?")
|
| 314 |
-
if first in _bad_starts:
|
| 315 |
-
return None
|
| 316 |
-
return v
|
| 317 |
-
except Exception:
|
| 318 |
-
return None
|
| 319 |
-
|
| 320 |
-
# Translate field name → fact_find_normalizer question id for schema lookup.
|
| 321 |
-
qid = FIELD_TO_QUESTION_ID.get(field_name)
|
| 322 |
-
if qid is None:
|
| 323 |
-
# Unknown slot — drop.
|
| 324 |
-
return None
|
| 325 |
-
|
| 326 |
-
try:
|
| 327 |
-
from backend.fact_find_normalizer import _FIELD_SCHEMA, _validate
|
| 328 |
-
except Exception:
|
| 329 |
-
return value # if validator import fails, trust the LLM
|
| 330 |
-
|
| 331 |
-
schema = _FIELD_SCHEMA.get(qid)
|
| 332 |
-
if schema is None:
|
| 333 |
-
return value
|
| 334 |
-
|
| 335 |
-
# Try the strict validator first.
|
| 336 |
-
validated = _validate(value, schema)
|
| 337 |
-
if validated is not None:
|
| 338 |
-
return validated
|
| 339 |
-
|
| 340 |
-
# Last resort — for ints, accept strings that parse cleanly.
|
| 341 |
-
t = schema.get("type")
|
| 342 |
-
if t == "int":
|
| 343 |
-
try:
|
| 344 |
-
iv = int(str(value).strip())
|
| 345 |
-
except (TypeError, ValueError):
|
| 346 |
-
return None
|
| 347 |
-
if iv < schema.get("min", 0) or iv > schema.get("max", 1_000_000_000):
|
| 348 |
-
return None
|
| 349 |
-
return iv
|
| 350 |
-
if t == "list" and isinstance(value, str):
|
| 351 |
-
# LLM may emit a comma-separated string; coerce.
|
| 352 |
-
items = [x.strip().lower() for x in value.split(",") if x.strip()]
|
| 353 |
-
return items
|
| 354 |
-
return None
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
# ----------------------------------------------------------------------------
|
| 358 |
-
# Slot-not-progressing safeguard
|
| 359 |
-
# ----------------------------------------------------------------------------
|
| 360 |
-
|
| 361 |
-
def _bump_brain_history(session, slot_driving: Optional[str]) -> int:
|
| 362 |
-
"""Record `slot_driving` on the session and return how many consecutive
|
| 363 |
-
turns the same slot has been driven. Stored on the session under
|
| 364 |
-
`_ff_brain_history` as a list[str] capped at 8."""
|
| 365 |
-
if not hasattr(session, "_ff_brain_history"):
|
| 366 |
-
session._ff_brain_history = []
|
| 367 |
-
history: list[Optional[str]] = session._ff_brain_history
|
| 368 |
-
history.append(slot_driving)
|
| 369 |
-
if len(history) > 8:
|
| 370 |
-
del history[: len(history) - 8]
|
| 371 |
-
# Count run-length of the most recent slot at the tail.
|
| 372 |
-
if not slot_driving:
|
| 373 |
-
return 0
|
| 374 |
-
n = 0
|
| 375 |
-
for s in reversed(history):
|
| 376 |
-
if s == slot_driving:
|
| 377 |
-
n += 1
|
| 378 |
-
else:
|
| 379 |
-
break
|
| 380 |
-
return n
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
# ----------------------------------------------------------------------------
|
| 384 |
-
# KI-103 (2026-05-15) — Canonical-fallback loop breaker
|
| 385 |
-
# ----------------------------------------------------------------------------
|
| 386 |
-
# When the LLM brain returns `no_trailer` (or any reason that drives
|
| 387 |
-
# `_canonical_fallback`) on consecutive turns, the legacy code re-surfaced
|
| 388 |
-
# the SAME unfilled slot indefinitely. Live 15-persona smoke test caught
|
| 389 |
-
# the brain repeating "First, your age?" 7-8 times in a row even after the
|
| 390 |
-
# user explicitly stated their age. Root cause: `next_question(profile)`
|
| 391 |
-
# iterates GRAPH skipping ids in `profile.asked` and filled fields — but
|
| 392 |
-
# the greedy capture in `_canonical_fallback` only appends to `asked` when
|
| 393 |
-
# a value was successfully extracted, so a failed capture loop keeps the
|
| 394 |
-
# slot un-asked forever.
|
| 395 |
-
#
|
| 396 |
-
# Fix: track per-slot failed-fallback counts on the session. After
|
| 397 |
-
# `_MAX_FAILED_ATTEMPTS` (=2) consecutive surfaces of the same unfilled
|
| 398 |
-
# slot via canonical fallback WITHOUT capturing anything, mark the slot as
|
| 399 |
-
# asked (so `next_question` advances) AND record it on `session._ff_skipped_slots`
|
| 400 |
-
# so the orchestrator / scorecard can render it as intentionally unanswered.
|
| 401 |
-
#
|
| 402 |
-
# Counters live on the SessionState (not the Profile) — they are transient
|
| 403 |
-
# fact-find-turn state, not part of the persisted profile schema. Resetting
|
| 404 |
-
# them happens automatically on greedy-capture success below.
|
| 405 |
-
_MAX_FAILED_ATTEMPTS = 2
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
def _failed_attempts(session) -> dict[str, int]:
|
| 409 |
-
"""Per-slot canonical-fallback failure counter, lazily attached to the
|
| 410 |
-
session. Maps `question.id` (NOT field name) → consecutive failure count.
|
| 411 |
-
"""
|
| 412 |
-
if not hasattr(session, "_ff_failed_attempts"):
|
| 413 |
-
session._ff_failed_attempts = {}
|
| 414 |
-
return session._ff_failed_attempts
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
def _skipped_slots(session) -> list[str]:
|
| 418 |
-
"""Per-session list of slot ids the loop-breaker has marked SKIPPED
|
| 419 |
-
after `_MAX_FAILED_ATTEMPTS` failed canonical-fallback surfaces. The
|
| 420 |
-
orchestrator / scorecard reads this to render the skipped slots as
|
| 421 |
-
intentionally unanswered (not silently dropped).
|
| 422 |
-
"""
|
| 423 |
-
if not hasattr(session, "_ff_skipped_slots"):
|
| 424 |
-
session._ff_skipped_slots = []
|
| 425 |
-
return session._ff_skipped_slots
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
# ----------------------------------------------------------------------------
|
| 429 |
-
# Public entry
|
| 430 |
-
# ----------------------------------------------------------------------------
|
| 431 |
-
|
| 432 |
-
# KI-075 (2026-05-15) — bumped 12s → 25s. Live probe on production showed
|
| 433 |
-
# 4 of 5 fact-find turns hitting the 12s wait_for cap at exactly 13.2s
|
| 434 |
-
# latency, falling to canonical fallback even though FAST_BRAIN_CHAIN's
|
| 435 |
-
# total_budget_s is 22s. NIM cold-start eats 10-15s on the first call
|
| 436 |
-
# after a Space rebuild; the 12s wait_for was killing the brain BEFORE
|
| 437 |
-
# the cross-provider fallback links (Groq, OpenRouter) ever got tried.
|
| 438 |
-
# 25s gives NIM cold-start headroom + leaves room for one chain fallback.
|
| 439 |
-
_TIMEOUT_S = 25.0 # FAST_BRAIN_CHAIN primary attempt budget
|
| 440 |
-
# KI-079 (2026-05-15) — if FAST exhausted, try BRAIN_CHAIN (Qwen 80B primary
|
| 441 |
-
# + more cross-provider fallbacks incl. OpenRouter + Groq) with a SHORTER
|
| 442 |
-
# budget so the user doesn't wait 25+15=40s on a fully-dead network. Total
|
| 443 |
-
# worst-case latency before canonical fallback: 25 + 15 = 40s, but the FAST
|
| 444 |
-
# chain only hits 25s when NIM is wedged — the BRAIN escalation then has a
|
| 445 |
-
# Qwen primary on a different NIM pool + OpenRouter + Groq, so realistic
|
| 446 |
-
# escalation success cases land in 3-8s.
|
| 447 |
-
_TIMEOUT_S_ESCALATION = 15.0
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
async def drive_fact_find(
|
| 451 |
-
user_text: str,
|
| 452 |
-
session,
|
| 453 |
-
chat_history: Optional[list[dict]],
|
| 454 |
-
session_id: Optional[str],
|
| 455 |
-
) -> FactFindOutcome:
|
| 456 |
-
"""Single LLM call per fact-find turn.
|
| 457 |
-
|
| 458 |
-
Returns a FactFindOutcome the orchestrator applies to session state.
|
| 459 |
-
|
| 460 |
-
Behaviour:
|
| 461 |
-
- Calls `get_fast_brain_llm()` with a system prompt that defines the
|
| 462 |
-
slot schema, conversation rules, and trailer-block format.
|
| 463 |
-
- Parses the trailer `<FF>...</FF>` JSON to learn what was captured,
|
| 464 |
-
which slot the brain is driving toward, and whether fact-find is
|
| 465 |
-
complete.
|
| 466 |
-
- Validates each captured value through `fact_find_normalizer`.
|
| 467 |
-
- On timeout / malformed JSON / no trailer → ambiguous=True and a
|
| 468 |
-
canonical-question fallback reply.
|
| 469 |
-
"""
|
| 470 |
-
t0 = time.time()
|
| 471 |
-
# Build current profile JSON (only set fields) so the prompt sees what we
|
| 472 |
-
# already know.
|
| 473 |
-
profile = session.profile
|
| 474 |
-
profile_snapshot: dict[str, Any] = {}
|
| 475 |
-
for field_name in (
|
| 476 |
-
"name", "age", "dependents", "income_band", "existing_cover_inr",
|
| 477 |
-
"primary_goal", "location_tier", "parents_age_max", "health_conditions",
|
| 478 |
-
"budget_band",
|
| 479 |
-
):
|
| 480 |
-
v = getattr(profile, field_name, None)
|
| 481 |
-
if v not in (None, "", []):
|
| 482 |
-
profile_snapshot[field_name] = v
|
| 483 |
-
|
| 484 |
-
system_with_state = _SYSTEM_PROMPT + (
|
| 485 |
-
"\n\nCURRENT PROFILE (already captured; do NOT re-ask for these):\n"
|
| 486 |
-
+ json.dumps(profile_snapshot, ensure_ascii=False)
|
| 487 |
-
)
|
| 488 |
-
|
| 489 |
-
# Build messages: system + last ~10 chat history turns + user_text
|
| 490 |
-
messages: list[ChatMessage] = [ChatMessage(role="system", content=system_with_state)]
|
| 491 |
-
if chat_history:
|
| 492 |
-
for turn in chat_history[-10:]:
|
| 493 |
-
role = turn.get("role")
|
| 494 |
-
content = turn.get("content")
|
| 495 |
-
if role in ("user", "assistant") and content:
|
| 496 |
-
messages.append(ChatMessage(role=role, content=content))
|
| 497 |
-
messages.append(ChatMessage(role="user", content=user_text or ""))
|
| 498 |
-
|
| 499 |
-
# Hard 25-second timeout on FAST_BRAIN_CHAIN. The fast-brain chain already
|
| 500 |
-
# has its own per-link + total-chain budget but we wrap with asyncio.wait_for
|
| 501 |
-
# as a belt-and-braces stop.
|
| 502 |
-
llm_fast = get_fast_brain_llm()
|
| 503 |
-
try:
|
| 504 |
-
result = await asyncio.wait_for(
|
| 505 |
-
# KI-150 (2026-05-15) — bumped 420 → 700. Diagnosis showed the
|
| 506 |
-
# contract requires prose (~250 tok) + <FF>...</FF> trailer
|
| 507 |
-
# (~80 tok); 420 truncated verbose multi-slot replies before
|
| 508 |
-
# the trailer, parser failed, canonical fallback fired, user
|
| 509 |
-
# saw scripted prompt_en (the "robotic language" complaint).
|
| 510 |
-
llm_fast.chat(messages=messages, temperature=0.6, max_tokens=700),
|
| 511 |
-
timeout=_TIMEOUT_S,
|
| 512 |
-
)
|
| 513 |
-
except asyncio.TimeoutError:
|
| 514 |
-
# KI-079 (2026-05-15) — fast brain timed out. Before falling to the
|
| 515 |
-
# canonical-question fallback, escalate ONCE to BRAIN_CHAIN (Qwen 80B
|
| 516 |
-
# primary, more cross-provider fallbacks) with a shorter budget so the
|
| 517 |
-
# user doesn't wait 25+15=40s on a dead network.
|
| 518 |
-
logging.warning(
|
| 519 |
-
"KI-079: fast brain timeout (session=%s, %.1fs) → escalating to heavy brain",
|
| 520 |
-
session_id, time.time() - t0,
|
| 521 |
-
)
|
| 522 |
-
from backend.providers.nvidia_nim_llm import get_brain_llm
|
| 523 |
-
llm_heavy = get_brain_llm()
|
| 524 |
-
try:
|
| 525 |
-
result = await asyncio.wait_for(
|
| 526 |
-
# KI-150 — same bump on escalation path for consistency.
|
| 527 |
-
llm_heavy.chat(messages=messages, temperature=0.6, max_tokens=700),
|
| 528 |
-
timeout=_TIMEOUT_S_ESCALATION,
|
| 529 |
-
)
|
| 530 |
-
except asyncio.TimeoutError:
|
| 531 |
-
logging.warning(
|
| 532 |
-
"KI-079: heavy brain ALSO timed out (session=%s, total=%.1fs)",
|
| 533 |
-
session_id, time.time() - t0,
|
| 534 |
-
)
|
| 535 |
-
return _canonical_fallback(
|
| 536 |
-
session, user_text, reason="timeout_after_escalation"
|
| 537 |
-
)
|
| 538 |
-
except Exception as e2:
|
| 539 |
-
logging.warning(
|
| 540 |
-
"KI-079: heavy brain escalation failed (session=%s): %s: %s",
|
| 541 |
-
session_id, type(e2).__name__, str(e2)[:200],
|
| 542 |
-
)
|
| 543 |
-
return _canonical_fallback(
|
| 544 |
-
session, user_text, reason="llm_error_after_escalation"
|
| 545 |
-
)
|
| 546 |
-
except Exception as e:
|
| 547 |
-
logging.warning(
|
| 548 |
-
"fact_find_brain LLM call failed (session=%s): %s: %s",
|
| 549 |
-
session_id, type(e).__name__, str(e)[:200],
|
| 550 |
-
)
|
| 551 |
-
return _canonical_fallback(session, user_text, reason="llm_error")
|
| 552 |
-
|
| 553 |
-
raw = (result.text or "").strip()
|
| 554 |
-
parsed = _parse_ff_block(raw)
|
| 555 |
-
if parsed is None:
|
| 556 |
-
logging.info(
|
| 557 |
-
"fact_find_brain trailer missing/malformed (session=%s, raw=%r)",
|
| 558 |
-
session_id, raw[:200],
|
| 559 |
-
)
|
| 560 |
-
return _canonical_fallback(session, user_text, reason="no_trailer")
|
| 561 |
-
|
| 562 |
-
reply_text = _strip_ff_block(raw)
|
| 563 |
-
if not reply_text.strip():
|
| 564 |
-
return _canonical_fallback(session, user_text, reason="empty_reply")
|
| 565 |
-
|
| 566 |
-
captured_raw = parsed.get("captured") or {}
|
| 567 |
-
if not isinstance(captured_raw, dict):
|
| 568 |
-
captured_raw = {}
|
| 569 |
-
slot_driving_raw = parsed.get("slot_driving")
|
| 570 |
-
if slot_driving_raw in ("", "null", "None"):
|
| 571 |
-
slot_driving_raw = None
|
| 572 |
-
complete_raw = bool(parsed.get("complete", False))
|
| 573 |
-
|
| 574 |
-
# Validate each capture; drop fields that fail.
|
| 575 |
-
captured: dict[str, Any] = {}
|
| 576 |
-
for field_name, raw_value in captured_raw.items():
|
| 577 |
-
validated = _validate_capture(field_name, raw_value)
|
| 578 |
-
if validated is None:
|
| 579 |
-
logging.info(
|
| 580 |
-
"fact_find_brain dropped invalid capture (session=%s, field=%s, raw=%r)",
|
| 581 |
-
session_id, field_name, raw_value,
|
| 582 |
-
)
|
| 583 |
-
continue
|
| 584 |
-
captured[field_name] = validated
|
| 585 |
-
|
| 586 |
-
# Slot-not-progressing safeguard — if same slot has been driven 3 turns
|
| 587 |
-
# in a row, force the orchestrator to pick a different slot.
|
| 588 |
-
repeat_count = _bump_brain_history(session, slot_driving_raw)
|
| 589 |
-
if repeat_count >= 3 and slot_driving_raw and not complete_raw:
|
| 590 |
-
try:
|
| 591 |
-
from backend.needs_finder import next_question
|
| 592 |
-
alt = next_question(profile)
|
| 593 |
-
if alt and alt.field != slot_driving_raw:
|
| 594 |
-
logging.info(
|
| 595 |
-
"fact_find_brain stuck on slot=%s for %d turns → forcing %s",
|
| 596 |
-
slot_driving_raw, repeat_count, alt.field,
|
| 597 |
-
)
|
| 598 |
-
slot_driving_raw = alt.field
|
| 599 |
-
except Exception:
|
| 600 |
-
pass
|
| 601 |
-
|
| 602 |
-
return FactFindOutcome(
|
| 603 |
-
reply_text=reply_text,
|
| 604 |
-
captured_updates=captured,
|
| 605 |
-
slot_driving=slot_driving_raw,
|
| 606 |
-
fact_find_complete=complete_raw,
|
| 607 |
-
ambiguous=False,
|
| 608 |
-
)
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
def _normalize_for_slot(slot_id: str, raw_text: str) -> Any:
|
| 612 |
-
"""Synchronous slot normalizer used by KI-072 canonical fallback.
|
| 613 |
-
|
| 614 |
-
Mirrors the keyword/int/cover fast-paths from `fact_find_normalizer.py`
|
| 615 |
-
but stays sync so it can be called from the non-async `_canonical_fallback`.
|
| 616 |
-
LLM-only enum normalization is skipped — fallback prefers re-asking over
|
| 617 |
-
blocking on another LLM call (which is presumably already failing if we
|
| 618 |
-
got here).
|
| 619 |
-
"""
|
| 620 |
-
if not raw_text or not raw_text.strip():
|
| 621 |
-
return None
|
| 622 |
-
text = raw_text.strip()
|
| 623 |
-
|
| 624 |
-
# NAME slot — KI-074 + KI-092 (2026-05-15) — two-mode parsing.
|
| 625 |
-
# MODE A (lenient, when caller explicitly tagged awaiting=name): accept
|
| 626 |
-
# any plain alphabetic name. Used when fallback fires after the bot
|
| 627 |
-
# asked "what should I call you?" — the reply IS the name by
|
| 628 |
-
# construction; no intro phrase needed. Avoids the KI-074 false-
|
| 629 |
-
# reject on "rohit sar" (plain reply to the name prompt).
|
| 630 |
-
# MODE B (strict, default — used by greedy multi-slot capture): requires
|
| 631 |
-
# explicit "I'm X" / "my name is X" / "this is X" / "call me X" intro
|
| 632 |
-
# to avoid false positives on "this is correct" / "29 years old" etc.
|
| 633 |
-
# The caller signals MODE A by passing slot_id="name__awaiting" (a
|
| 634 |
-
# synthetic tag); the canonical fallback maps awaiting_question_id=name
|
| 635 |
-
# to this tag.
|
| 636 |
-
if slot_id in ("name", "name__awaiting"):
|
| 637 |
-
lenient_mode = (slot_id == "name__awaiting")
|
| 638 |
-
slot_id = "name" # normalise so downstream logic still sees "name"
|
| 639 |
-
import re as _re
|
| 640 |
-
# Strip leading greeting first
|
| 641 |
-
s = text.strip().strip(".,!?")
|
| 642 |
-
s = _re.sub(r"^(hi|hello|hey|namaste|yo)[,!.\s]+", "", s, flags=_re.IGNORECASE)
|
| 643 |
-
|
| 644 |
-
# KI-092 — LENIENT MODE: the bot just asked "what should I call you?"
|
| 645 |
-
# so any plain alphabetic reply should be accepted as the name.
|
| 646 |
-
# Examples to accept: "rohit sar" / "Anjali" / "Dr. Priya" / "Sam"
|
| 647 |
-
# Examples to reject: empty, all-digit, mostly-symbol, way too long.
|
| 648 |
-
if lenient_mode:
|
| 649 |
-
# Drop common polite-prefix scraps if present.
|
| 650 |
-
s_lc = s.lower()
|
| 651 |
-
for prefix in ("i'm ", "i am ", "this is ", "my name is ", "name is ",
|
| 652 |
-
"name's ", "call me ", "im ", "mr ", "mrs ", "ms ", "dr "):
|
| 653 |
-
if s_lc.startswith(prefix):
|
| 654 |
-
s = s[len(prefix):].strip()
|
| 655 |
-
break
|
| 656 |
-
# Validation: 1-50 chars, ≥50% alphabetic, no embedded digits
|
| 657 |
-
if not s or len(s) > 50 or any(c.isdigit() for c in s):
|
| 658 |
-
return None
|
| 659 |
-
alpha = sum(1 for c in s if c.isalpha())
|
| 660 |
-
if alpha < 2 or alpha / max(1, len(s)) < 0.5:
|
| 661 |
-
return None
|
| 662 |
-
# Limit to 1-4 word tokens
|
| 663 |
-
tokens = s.split()
|
| 664 |
-
if not (1 <= len(tokens) <= 4):
|
| 665 |
-
return None
|
| 666 |
-
# Capitalise if all-lower
|
| 667 |
-
if not any(c.isupper() for c in s):
|
| 668 |
-
s = " ".join(w.capitalize() for w in tokens)
|
| 669 |
-
return s
|
| 670 |
-
|
| 671 |
-
# STRICT MODE (greedy multi-slot capture) — original KI-074 behaviour:
|
| 672 |
-
# require an explicit intro phrase to avoid false positives.
|
| 673 |
-
m = _re.search(
|
| 674 |
-
r"\b(?:i'?m|i\s+am|this\s+is|my\s+name\s+is|name\s+is|call\s+me|name'?s)\s+"
|
| 675 |
-
r"([a-zA-Z][a-zA-Z'\-]{1,30}(?:\s+[a-zA-Z][a-zA-Z'\-]{1,30}){0,3})\b",
|
| 676 |
-
s,
|
| 677 |
-
flags=_re.IGNORECASE,
|
| 678 |
-
)
|
| 679 |
-
if not m:
|
| 680 |
-
return None
|
| 681 |
-
candidate = m.group(1).strip()
|
| 682 |
-
# KI-074 — chop at conjunctions / boundary words so "Rohit Sar and"
|
| 683 |
-
# in "My name is Rohit Sar and I am 32" becomes "Rohit Sar".
|
| 684 |
-
candidate = _re.split(
|
| 685 |
-
r"\s+(?:and|but|with|plus|also|or|&|,)\s+",
|
| 686 |
-
candidate, maxsplit=1, flags=_re.IGNORECASE,
|
| 687 |
-
)[0].strip()
|
| 688 |
-
# Also strip a TRAILING conjunction (when the connector is the
|
| 689 |
-
# last token in the captured span — e.g. "Rohit Sar and").
|
| 690 |
-
candidate = _re.sub(
|
| 691 |
-
r"\s+(?:and|but|with|plus|also|or|&)\s*$",
|
| 692 |
-
"", candidate, flags=_re.IGNORECASE,
|
| 693 |
-
).strip()
|
| 694 |
-
# Reject if any digit or trailing words look like an age phrase
|
| 695 |
-
if any(c.isdigit() for c in candidate):
|
| 696 |
-
return None
|
| 697 |
-
first_word = candidate.split()[0].lower()
|
| 698 |
-
if first_word in {
|
| 699 |
-
# Articles + possessives + demonstratives
|
| 700 |
-
"a", "an", "the", "my", "your", "his", "her", "their", "our",
|
| 701 |
-
"this", "that", "these", "those",
|
| 702 |
-
# Ordinals (commonly follow "this is my first ...")
|
| 703 |
-
"first", "second", "third", "fourth", "fifth", "last", "next",
|
| 704 |
-
# Common adjectives mistaken for names
|
| 705 |
-
"looking", "buying", "shopping", "interested", "trying", "thinking",
|
| 706 |
-
"happy", "sad", "tired", "busy", "ready", "done", "new", "old",
|
| 707 |
-
"good", "fine", "ok", "okay", "sure", "right",
|
| 708 |
-
"correct", "wrong", "alone", "single", "married",
|
| 709 |
-
"years", "year", "from", "very", "really", "just",
|
| 710 |
-
}:
|
| 711 |
-
return None
|
| 712 |
-
# Capitalise if all-lower (STT often outputs lowercase)
|
| 713 |
-
if not any(c.isupper() for c in candidate):
|
| 714 |
-
candidate = " ".join(w.capitalize() for w in candidate.split())
|
| 715 |
-
return candidate
|
| 716 |
-
|
| 717 |
-
# KI-074 — slot-specific triggers prevent cross-contamination during
|
| 718 |
-
# greedy multi-slot capture. Without these guards, "29 years old" was
|
| 719 |
-
# getting written into existing_cover_inr AND parents_age_max AND age.
|
| 720 |
-
import re as _re2
|
| 721 |
-
lc = text.lower()
|
| 722 |
-
try:
|
| 723 |
-
from backend.fact_find_normalizer import (
|
| 724 |
-
_parse_int, _parse_existing_cover, _keyword_normalize,
|
| 725 |
-
_validate, _FIELD_SCHEMA,
|
| 726 |
-
)
|
| 727 |
-
schema = _FIELD_SCHEMA.get(slot_id)
|
| 728 |
-
|
| 729 |
-
if slot_id == "age":
|
| 730 |
-
# Require an age trigger so a bare "29" in "₹29L cover" doesn't fire.
|
| 731 |
-
if not _re2.search(r"\b(?:i'?m|i\s+am|i\s+am\s+about|age|aged|years?\s*old|yrs?\s*old|y/?o)\b", lc):
|
| 732 |
-
# Bare number on its own line is also OK (typed "29")
|
| 733 |
-
if not _re2.match(r"^\s*\d{1,3}\s*\.?\s*$", text):
|
| 734 |
-
return None
|
| 735 |
-
val = _parse_int(text, schema or {"min": 1, "max": 120})
|
| 736 |
-
# Age sanity: 18-99
|
| 737 |
-
if val is not None and 18 <= val <= 99:
|
| 738 |
-
return val
|
| 739 |
-
return None
|
| 740 |
-
|
| 741 |
-
if slot_id == "parents_age":
|
| 742 |
-
# MUST explicitly mention parent context.
|
| 743 |
-
if not _re2.search(r"\b(parent|mom|mum|mother|dad|father|mama|papa)", lc):
|
| 744 |
-
return None
|
| 745 |
-
val = _parse_int(text, schema or {"min": 30, "max": 110})
|
| 746 |
-
if val is not None and 30 <= val <= 110:
|
| 747 |
-
return val
|
| 748 |
-
return None
|
| 749 |
-
|
| 750 |
-
if slot_id == "existing_cover":
|
| 751 |
-
# KI-074 — require a cover-context trigger. Word "cover" alone
|
| 752 |
-
# is too weak (matches "looking for cover" with no amount).
|
| 753 |
-
# Need either an explicit denial OR a currency/unit token.
|
| 754 |
-
denial = _re2.search(
|
| 755 |
-
r"\b(no|none|nothing|zero|nope|nah|never|haven'?t|don'?t\s+have|first\s+(?:policy|insurance|one|time|buy)|new\s+to\s+insurance|don'?t\s+have\s+any)\b",
|
| 756 |
-
lc,
|
| 757 |
-
)
|
| 758 |
-
# Currency or unit cue. Plain "cover" word doesn't count.
|
| 759 |
-
unit_cue = _re2.search(
|
| 760 |
-
r"(₹|\brs\.?\s*\d|\d+\s*(?:lakh|lac|crore|cr)\b|"
|
| 761 |
-
r"\bsum\s+insured\b|\bcovered\s+for\b|\bemployer.*\bcover|"
|
| 762 |
-
r"\bfrom\s+work\b)",
|
| 763 |
-
lc,
|
| 764 |
-
)
|
| 765 |
-
if not denial and not unit_cue:
|
| 766 |
-
return None
|
| 767 |
-
return _parse_existing_cover(text)
|
| 768 |
-
|
| 769 |
-
# Enum / list slots — keyword fast path only, no LLM.
|
| 770 |
-
# Each slot has its own trigger keywords inside _keyword_normalize.
|
| 771 |
-
kw = _keyword_normalize(slot_id, text)
|
| 772 |
-
if kw is not None and schema is not None:
|
| 773 |
-
return _validate(kw, schema)
|
| 774 |
-
return kw
|
| 775 |
-
except Exception:
|
| 776 |
-
return None
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
def _canonical_fallback(session, user_text: str, *, reason: str) -> FactFindOutcome:
|
| 780 |
-
"""Produce a deterministic, on-rails fallback reply when the LLM brain
|
| 781 |
-
times out or returns unparseable output. Uses the canonical needs_finder
|
| 782 |
-
question for the next missing slot so the user always sees a coherent
|
| 783 |
-
next step.
|
| 784 |
-
|
| 785 |
-
KI-072 (2026-05-15) — applies the user's message to the previously-asked
|
| 786 |
-
slot via the legacy normalizer BEFORE picking the next slot.
|
| 787 |
-
|
| 788 |
-
KI-074 (2026-05-15) — GREEDY multi-slot capture. Previous KI-072 logic
|
| 789 |
-
only checked `awaiting_question_id` → dropped facts when brain failed on
|
| 790 |
-
a multi-fact message ("I am 29 and just me" lost age + dependents
|
| 791 |
-
because the LLM happened to be driving the `name` slot). Now we try
|
| 792 |
-
every unfilled slot against the user_text and capture whatever
|
| 793 |
-
matches — same multi-fact spirit as KI-070, just without the LLM call.
|
| 794 |
-
"""
|
| 795 |
-
profile = session.profile
|
| 796 |
-
captured: dict[str, Any] = {}
|
| 797 |
-
|
| 798 |
-
if (user_text or "").strip():
|
| 799 |
-
try:
|
| 800 |
-
from backend.needs_finder import GRAPH
|
| 801 |
-
|
| 802 |
-
# KI-092 — if the bot was explicitly awaiting the name slot,
|
| 803 |
-
# try the LENIENT name parser FIRST so a plain "rohit sar"
|
| 804 |
-
# reply gets captured instead of falling through to the strict
|
| 805 |
-
# intro-phrase requirement and never matching.
|
| 806 |
-
awaiting = getattr(session, "awaiting_question_id", None)
|
| 807 |
-
if awaiting == "name" and not getattr(profile, "name", None):
|
| 808 |
-
try:
|
| 809 |
-
lenient_name = _normalize_for_slot("name__awaiting", user_text)
|
| 810 |
-
except Exception:
|
| 811 |
-
lenient_name = None
|
| 812 |
-
if lenient_name:
|
| 813 |
-
q_obj = next((q for q in GRAPH if q.id == "name"), None)
|
| 814 |
-
if q_obj is not None:
|
| 815 |
-
captured[q_obj.field] = lenient_name
|
| 816 |
-
setattr(profile, q_obj.field, lenient_name)
|
| 817 |
-
if "name" not in profile.asked:
|
| 818 |
-
profile.asked.append("name")
|
| 819 |
-
|
| 820 |
-
# Build the prioritised slot order — try high-signal slots first
|
| 821 |
-
# (numbers, enums) before name (which has explicit-intro guard).
|
| 822 |
-
# KI-158 (2026-05-15) — added "health_conditions". Its absence
|
| 823 |
-
# meant a user replying "I'm not having any pre-existing condition"
|
| 824 |
-
# NEVER got the slot captured here; the LLM brain failing on a
|
| 825 |
-
# `no_trailer` reply for that turn (common under NIM load) bumped
|
| 826 |
-
# _failed_attempts['health_conditions'] each turn until the
|
| 827 |
-
# loop-breaker force-skipped the slot, which then made
|
| 828 |
-
# next_question() return None and triggered the gentle hand-off
|
| 829 |
-
# mid-fact-find.
|
| 830 |
-
_GREEDY_ORDER = [
|
| 831 |
-
"age", "dependents", "income_band", "existing_cover",
|
| 832 |
-
"primary_goal", "location", "parents_age", "health_conditions",
|
| 833 |
-
"budget", "name",
|
| 834 |
-
]
|
| 835 |
-
ordered_slots: list[str] = [
|
| 836 |
-
sid for sid in _GREEDY_ORDER
|
| 837 |
-
if any(q.id == sid for q in GRAPH)
|
| 838 |
-
]
|
| 839 |
-
|
| 840 |
-
for slot_id in ordered_slots:
|
| 841 |
-
q_obj = next((q for q in GRAPH if q.id == slot_id), None)
|
| 842 |
-
if q_obj is None:
|
| 843 |
-
continue
|
| 844 |
-
# Skip already-filled slots
|
| 845 |
-
current = getattr(profile, q_obj.field, None)
|
| 846 |
-
if current not in (None, "", []):
|
| 847 |
-
continue
|
| 848 |
-
try:
|
| 849 |
-
val = _normalize_for_slot(slot_id, user_text)
|
| 850 |
-
except Exception:
|
| 851 |
-
val = None
|
| 852 |
-
if val is None:
|
| 853 |
-
continue
|
| 854 |
-
captured[q_obj.field] = val
|
| 855 |
-
# KI-095 — route through session.update_profile_field for
|
| 856 |
-
# consistency with the rest of the codebase (centralised
|
| 857 |
-
# write + flush); val is guarded non-None just above.
|
| 858 |
-
session.update_profile_field(q_obj.field, val)
|
| 859 |
-
if slot_id not in profile.asked:
|
| 860 |
-
profile.asked.append(slot_id)
|
| 861 |
-
# KI-103 — successful capture resets the failed-attempt
|
| 862 |
-
# counter for this slot so a temporary loss of the LLM
|
| 863 |
-
# brain doesn't permanently ghost the slot.
|
| 864 |
-
_failed_attempts(session).pop(slot_id, None)
|
| 865 |
-
except Exception as e:
|
| 866 |
-
logging.info("canonical_fallback greedy capture failed: %s", e)
|
| 867 |
-
|
| 868 |
-
# KI-103 (2026-05-15) — loop breaker. The legacy code surfaced the SAME
|
| 869 |
-
# unfilled slot every turn the brain returned `no_trailer`, even when
|
| 870 |
-
# the user explicitly answered it on turn 1. Now we track per-slot
|
| 871 |
-
# consecutive failed-fallback surfaces on the session; after
|
| 872 |
-
# _MAX_FAILED_ATTEMPTS consecutive surfaces of slot S with NO capture
|
| 873 |
-
# for S, we mark S as asked (so `next_question` advances) and append
|
| 874 |
-
# it to `_ff_skipped_slots` so the orchestrator/scorecard can render
|
| 875 |
-
# it as intentionally unanswered.
|
| 876 |
-
try:
|
| 877 |
-
from backend.needs_finder import next_question
|
| 878 |
-
attempts = _failed_attempts(session)
|
| 879 |
-
skipped = _skipped_slots(session)
|
| 880 |
-
# Bounded loop: at most len(GRAPH)+1 iterations so we can never
|
| 881 |
-
# infinite-loop on a pathological state. In practice we exit on
|
| 882 |
-
# the first slot that's either fresh OR has been skipped now.
|
| 883 |
-
q = None
|
| 884 |
-
for _ in range(20):
|
| 885 |
-
q = next_question(profile)
|
| 886 |
-
if q is None:
|
| 887 |
-
break
|
| 888 |
-
slot_id = q.id
|
| 889 |
-
# If this turn's greedy capture filled this slot, surface
|
| 890 |
-
# the NEXT slot (we already captured the answer; don't re-ask).
|
| 891 |
-
if q.field in captured:
|
| 892 |
-
profile.asked.append(slot_id) if slot_id not in profile.asked else None
|
| 893 |
-
continue
|
| 894 |
-
# Count this as a failed surface for the slot — we're about
|
| 895 |
-
# to re-ask it without having captured a value for it.
|
| 896 |
-
attempts[slot_id] = attempts.get(slot_id, 0) + 1
|
| 897 |
-
if attempts[slot_id] > _MAX_FAILED_ATTEMPTS:
|
| 898 |
-
# 3rd-or-later attempt: degrade gracefully — skip the slot
|
| 899 |
-
# entirely so we don't re-ask. Mark asked + record in
|
| 900 |
-
# _ff_skipped_slots and loop to pick the next unfilled slot.
|
| 901 |
-
logging.info(
|
| 902 |
-
"KI-103: canonical fallback skipping slot=%s after %d failed attempts",
|
| 903 |
-
slot_id, attempts[slot_id] - 1,
|
| 904 |
-
)
|
| 905 |
-
if slot_id not in profile.asked:
|
| 906 |
-
profile.asked.append(slot_id)
|
| 907 |
-
if slot_id not in skipped:
|
| 908 |
-
skipped.append(slot_id)
|
| 909 |
-
# Reset the counter so if we ever do capture later via a
|
| 910 |
-
# different code path, the slot can be re-introduced cleanly.
|
| 911 |
-
attempts.pop(slot_id, None)
|
| 912 |
-
continue
|
| 913 |
-
# Within tolerance — surface this slot to the user.
|
| 914 |
-
break
|
| 915 |
-
except Exception:
|
| 916 |
-
q = None
|
| 917 |
-
if q is not None:
|
| 918 |
-
# KI-156 (2026-05-15) — when the LLM bailed but we DID greedy-capture
|
| 919 |
-
# something this turn, prepend a brief acknowledgement so the user
|
| 920 |
-
# doesn't perceive the bot as ignoring them. Pre-fix the fallback
|
| 921 |
-
# emitted only the bare scripted prompt_en, making the conversation
|
| 922 |
-
# look unresponsive ("user gives city → bot asks name again" pattern).
|
| 923 |
-
ack_parts: list[str] = []
|
| 924 |
-
_PRETTY = {
|
| 925 |
-
"name": "name",
|
| 926 |
-
"age": "age",
|
| 927 |
-
"dependents": "who you're covering",
|
| 928 |
-
"income_band": "income band",
|
| 929 |
-
"existing_cover_inr": "existing cover",
|
| 930 |
-
"primary_goal": "goal",
|
| 931 |
-
"location_tier": "city",
|
| 932 |
-
"parents_age_max": "parents' age",
|
| 933 |
-
"health_conditions": "health conditions",
|
| 934 |
-
"budget_band": "budget",
|
| 935 |
-
}
|
| 936 |
-
for k in ("name", "age", "dependents", "location_tier", "income_band",
|
| 937 |
-
"existing_cover_inr", "primary_goal", "budget_band"):
|
| 938 |
-
if k in captured:
|
| 939 |
-
ack_parts.append(_PRETTY.get(k, k))
|
| 940 |
-
if ack_parts:
|
| 941 |
-
ack = f"Got that — {', '.join(ack_parts)}. "
|
| 942 |
-
reply = ack + q.prompt_en
|
| 943 |
-
else:
|
| 944 |
-
reply = q.prompt_en
|
| 945 |
-
slot = q.field
|
| 946 |
-
return FactFindOutcome(
|
| 947 |
-
reply_text=reply,
|
| 948 |
-
captured_updates=captured, # KI-072 — propagate the captured answer
|
| 949 |
-
slot_driving=slot,
|
| 950 |
-
fact_find_complete=False,
|
| 951 |
-
ambiguous=True,
|
| 952 |
-
_fallback_reason=reason, # KI-078 — telemetry stamp
|
| 953 |
-
)
|
| 954 |
-
# Nothing left to ask — fact-find is genuinely complete (every applicable
|
| 955 |
-
# slot is filled OR has been intentionally skipped by the loop-breaker).
|
| 956 |
-
#
|
| 957 |
-
# KI-158 (2026-05-15) — was emitting the "Let me know a bit about yourself
|
| 958 |
-
# — your age..." gentle hand-off which is the WRONG message at this point:
|
| 959 |
-
# the user just answered the LAST slot, every prior slot is on file, but
|
| 960 |
-
# the canonical-fallback path landed here because the LLM brain failed on
|
| 961 |
-
# the trailer block (no_trailer / empty_reply). Re-asking "tell me a bit
|
| 962 |
-
# about yourself" makes the bot look forgetful mid-conversation.
|
| 963 |
-
#
|
| 964 |
-
# Fix:
|
| 965 |
-
# 1. Propagate `captured` (was discarded as `{}`) so any greedy capture
|
| 966 |
-
# made this turn — most commonly the no-PED `health_conditions=[]` —
|
| 967 |
-
# is applied to the profile by the orchestrator post-loop.
|
| 968 |
-
# 2. Flip `fact_find_complete=True` so the orchestrator switches the
|
| 969 |
-
# session into free-form mode and the NEXT user turn routes to the
|
| 970 |
-
# retrieval brain (which can recommend), not back here.
|
| 971 |
-
# 3. Replace the awkward hand-off prose with a clean acknowledge +
|
| 972 |
-
# transition that mirrors the brain's `complete=true` summary path
|
| 973 |
-
# — the user just told us they're healthy, we have everything else
|
| 974 |
-
# already, so transition to recommendations.
|
| 975 |
-
reply = (
|
| 976 |
-
"Got it — no pre-existing conditions noted. I have everything I need. "
|
| 977 |
-
"Want me to suggest a couple of policies that fit your profile?"
|
| 978 |
-
)
|
| 979 |
-
return FactFindOutcome(
|
| 980 |
-
reply_text=reply,
|
| 981 |
-
captured_updates=captured, # KI-158 — propagate greedy captures
|
| 982 |
-
slot_driving=None,
|
| 983 |
-
fact_find_complete=True, # KI-158 — flip to free-form
|
| 984 |
-
ambiguous=True,
|
| 985 |
-
_fallback_reason=reason, # KI-078 — telemetry stamp
|
| 986 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,452 +0,0 @@
|
|
| 1 |
-
"""LLM-based normalizer for fact-find answers.
|
| 2 |
-
|
| 3 |
-
Translates raw natural-language user replies into the schema values expected
|
| 4 |
-
by `backend/needs_finder.py::Profile`. Plus a non-answer detector that skips
|
| 5 |
-
recording when the input is an STT failure template, empty, or off-topic.
|
| 6 |
-
|
| 7 |
-
This fixes two symptoms surfaced in production on 2026-05-14:
|
| 8 |
-
|
| 9 |
-
1. Free-text answers being stored verbatim instead of mapped to enums.
|
| 10 |
-
Example: user said "for now, just me" → stored as
|
| 11 |
-
`dependents="Um, for now, just me."` instead of `dependents="self"`.
|
| 12 |
-
The frontend Profile panel's enum-button comparison then never matches,
|
| 13 |
-
so the sidebar shows no selected option even though chat captured it.
|
| 14 |
-
|
| 15 |
-
2. STT-failure fallback messages (or empty transcripts) being recorded as
|
| 16 |
-
the user's answer to the in-flight question. The next question silently
|
| 17 |
-
moves on with garbage.
|
| 18 |
-
|
| 19 |
-
Architecture:
|
| 20 |
-
- `is_valid_answer(text)` — cheap guard that filters non-answers BEFORE
|
| 21 |
-
any LLM call.
|
| 22 |
-
- `normalize_answer(question_id, raw)` — async; fast-path regex for
|
| 23 |
-
numeric fields (age, parents_age, existing_cover); LLM call (NIM
|
| 24 |
-
Llama-3.3-70B at temperature 0) for enum and list fields.
|
| 25 |
-
- Returns None when the input can't be mapped → the orchestrator should
|
| 26 |
-
NOT clear `awaiting_question_id` so the bot re-asks the same question
|
| 27 |
-
(the "ask me again" behavior the human asked for).
|
| 28 |
-
"""
|
| 29 |
-
|
| 30 |
-
from __future__ import annotations
|
| 31 |
-
|
| 32 |
-
import json
|
| 33 |
-
import logging
|
| 34 |
-
import re
|
| 35 |
-
from typing import Any
|
| 36 |
-
|
| 37 |
-
# ----------------------------------------------------------------------------
|
| 38 |
-
# Field schema — what each fact-find question expects after normalization.
|
| 39 |
-
# Question IDs must match `backend/needs_finder.py::GRAPH[i].id` exactly.
|
| 40 |
-
# ----------------------------------------------------------------------------
|
| 41 |
-
|
| 42 |
-
_FIELD_SCHEMA: dict[str, dict] = {
|
| 43 |
-
"age": {"type": "int", "min": 1, "max": 120},
|
| 44 |
-
"dependents": {
|
| 45 |
-
"type": "enum",
|
| 46 |
-
"values": [
|
| 47 |
-
"self",
|
| 48 |
-
"self+spouse",
|
| 49 |
-
"self+spouse+kids",
|
| 50 |
-
"self+parents",
|
| 51 |
-
"self+spouse+kids+parents",
|
| 52 |
-
],
|
| 53 |
-
},
|
| 54 |
-
"income_band": {
|
| 55 |
-
"type": "enum",
|
| 56 |
-
"values": ["under_5L", "5L-10L", "10L-25L", "25L+"],
|
| 57 |
-
},
|
| 58 |
-
"existing_cover": {"type": "int", "min": 0, "max": 100_000_000},
|
| 59 |
-
"primary_goal": {
|
| 60 |
-
"type": "enum",
|
| 61 |
-
"values": ["first_buy", "upgrade", "compare_specific", "tax_planning"],
|
| 62 |
-
},
|
| 63 |
-
"location": {
|
| 64 |
-
"type": "enum",
|
| 65 |
-
"values": ["metro", "tier1", "tier2", "tier3"],
|
| 66 |
-
},
|
| 67 |
-
"parents_age": {"type": "int", "min": 30, "max": 120},
|
| 68 |
-
"health_conditions": {
|
| 69 |
-
"type": "list",
|
| 70 |
-
"common_values": ["diabetes", "hypertension", "thyroid", "asthma", "heart", "cancer"],
|
| 71 |
-
},
|
| 72 |
-
"budget": {
|
| 73 |
-
"type": "enum",
|
| 74 |
-
"values": ["under_15k", "15k_30k", "30k_60k", "60k+"],
|
| 75 |
-
},
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
# Non-answer fingerprints — these strings will skip the LLM and the recording.
|
| 79 |
-
_NON_ANSWER_PATTERNS = [
|
| 80 |
-
"sorry, i couldn't hear",
|
| 81 |
-
"couldn't hear that clearly",
|
| 82 |
-
"transcribe error",
|
| 83 |
-
"transcribe failed",
|
| 84 |
-
"stt failed",
|
| 85 |
-
"no audio",
|
| 86 |
-
"[transcription failed]",
|
| 87 |
-
"please try again",
|
| 88 |
-
]
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
def is_valid_answer(text: str) -> bool:
|
| 92 |
-
"""Return False when text is empty, too short, or a known failure template."""
|
| 93 |
-
if not text or not text.strip():
|
| 94 |
-
return False
|
| 95 |
-
s = text.strip().lower()
|
| 96 |
-
if len(s) < 2:
|
| 97 |
-
return False
|
| 98 |
-
if any(p in s for p in _NON_ANSWER_PATTERNS):
|
| 99 |
-
return False
|
| 100 |
-
return True
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
async def normalize_answer(question_id: str, raw_text: str) -> Any:
|
| 104 |
-
"""Map natural-language `raw_text` to the schema value for `question_id`.
|
| 105 |
-
|
| 106 |
-
Returns:
|
| 107 |
-
- parsed value (int / enum string / list[str]) on success
|
| 108 |
-
- None when the answer can't be confidently mapped (caller should re-ask)
|
| 109 |
-
"""
|
| 110 |
-
if not is_valid_answer(raw_text):
|
| 111 |
-
return None
|
| 112 |
-
|
| 113 |
-
schema = _FIELD_SCHEMA.get(question_id)
|
| 114 |
-
if schema is None:
|
| 115 |
-
return raw_text.strip() or None
|
| 116 |
-
|
| 117 |
-
# Fast paths — no LLM needed for plain integers / cover-amount parsing.
|
| 118 |
-
if question_id in ("age", "parents_age"):
|
| 119 |
-
return _parse_int(raw_text, schema)
|
| 120 |
-
if question_id == "existing_cover":
|
| 121 |
-
return _parse_existing_cover(raw_text)
|
| 122 |
-
|
| 123 |
-
# KEYWORD FAST PATH — robust to NIM rate-limit and to LLM hiccups.
|
| 124 |
-
# Try matching common patterns BEFORE the LLM call. Catches ~80% of
|
| 125 |
-
# answers without consuming a NIM request and is deterministic under
|
| 126 |
-
# load. The LLM is the fall-back for nuanced/edge phrasings.
|
| 127 |
-
kw = _keyword_normalize(question_id, raw_text)
|
| 128 |
-
if kw is not None:
|
| 129 |
-
validated = _validate(kw, schema)
|
| 130 |
-
if validated is not None:
|
| 131 |
-
return validated
|
| 132 |
-
|
| 133 |
-
# Enum + list fields — let the LLM map natural language to canonical value.
|
| 134 |
-
return await _llm_normalize(question_id, raw_text, schema)
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
# ----------------------------------------------------------------------------
|
| 138 |
-
# Keyword fast-path — hand-curated common phrasing → schema value.
|
| 139 |
-
# Order matters within each field: more-specific patterns first.
|
| 140 |
-
# Case-insensitive substring matches on a normalized version of the text.
|
| 141 |
-
# ----------------------------------------------------------------------------
|
| 142 |
-
|
| 143 |
-
def _keyword_normalize(question_id: str, raw_text: str) -> Any:
|
| 144 |
-
s = raw_text.lower()
|
| 145 |
-
|
| 146 |
-
if question_id == "dependents":
|
| 147 |
-
# KI-014 — vague terms like "family" / "everyone" must NOT auto-map.
|
| 148 |
-
# User testing surfaced: user said "family" → bot assumed self+spouse+kids
|
| 149 |
-
# → wrong (user may have meant parents, siblings, joint-family etc.).
|
| 150 |
-
# Returning None here forces the LLM normalizer (or re-ask) to clarify.
|
| 151 |
-
VAGUE_TERMS = ["family", "everyone", "all of us", "everybody", "whole family", "joint family"]
|
| 152 |
-
if any(v in s for v in VAGUE_TERMS) and not any(
|
| 153 |
-
k in s for k in ["spouse", "wife", "husband", "kid", "child", "parent"]
|
| 154 |
-
):
|
| 155 |
-
return None # force clarification
|
| 156 |
-
|
| 157 |
-
if any(k in s for k in ["spouse", "wife", "husband"]) and "kid" in s and "parent" in s:
|
| 158 |
-
return "self+spouse+kids+parents"
|
| 159 |
-
if any(k in s for k in ["spouse", "wife", "husband"]) and "kid" in s:
|
| 160 |
-
return "self+spouse+kids"
|
| 161 |
-
if any(k in s for k in ["spouse", "wife", "husband"]) and "parent" in s:
|
| 162 |
-
return "self+spouse+kids+parents"
|
| 163 |
-
if any(k in s for k in ["spouse", "wife", "husband"]):
|
| 164 |
-
return "self+spouse"
|
| 165 |
-
if "parent" in s and "no" not in s.split():
|
| 166 |
-
return "self+parents"
|
| 167 |
-
if "just me" in s or "only me" in s or "myself" in s or "only self" in s or s.strip() in {"me", "self"}:
|
| 168 |
-
return "self"
|
| 169 |
-
|
| 170 |
-
elif question_id == "income_band":
|
| 171 |
-
# KI-149 (2026-05-15) — delegate to needs_finder._parse_income_band so
|
| 172 |
-
# bare digits ("500000"), "thousand"/"grand"/"k" suffixes, and "₹"
|
| 173 |
-
# prefixes all parse identically here AND in the GRAPH parser used by
|
| 174 |
-
# `record_answer`. Single source of truth.
|
| 175 |
-
from backend.needs_finder import _parse_income_band
|
| 176 |
-
return _parse_income_band(raw_text)
|
| 177 |
-
|
| 178 |
-
elif question_id == "primary_goal":
|
| 179 |
-
if any(k in s for k in ["first policy", "first one", "first time", "first buy", "new policy", "buying my first"]):
|
| 180 |
-
return "first_buy"
|
| 181 |
-
if any(k in s for k in ["upgrade", "upgrading", "better cover", "more cover", "increase cover"]):
|
| 182 |
-
return "upgrade"
|
| 183 |
-
if any(k in s for k in ["compare", "comparison", " vs ", " vs.", "versus"]):
|
| 184 |
-
return "compare_specific"
|
| 185 |
-
if any(k in s for k in [" tax ", "80d", "deduction", "tax planning"]):
|
| 186 |
-
return "tax_planning"
|
| 187 |
-
|
| 188 |
-
elif question_id == "location":
|
| 189 |
-
metro = ["bangalore", "bengaluru", "mumbai", "delhi", "new delhi", "chennai", "kolkata", "hyderabad", "pune"]
|
| 190 |
-
tier1 = ["ahmedabad", "jaipur", "lucknow", "kanpur", "nagpur", "indore", "thane", "bhopal", "visakhapatnam", "patna", "vadodara", "ghaziabad", "ludhiana", "agra", "nashik"]
|
| 191 |
-
tier2 = ["surat", "kochi", "trivandrum", "thiruvananthapuram", "coimbatore", "vijayawada", "madurai", "rajkot", "ranchi", "amritsar", "allahabad", "prayagraj", "jodhpur", "raipur"]
|
| 192 |
-
for c in metro:
|
| 193 |
-
if c in s: return "metro"
|
| 194 |
-
for c in tier1:
|
| 195 |
-
if c in s: return "tier1"
|
| 196 |
-
for c in tier2:
|
| 197 |
-
if c in s: return "tier2"
|
| 198 |
-
if "metro" in s: return "metro"
|
| 199 |
-
if "tier 1" in s or "tier1" in s: return "tier1"
|
| 200 |
-
if "tier 2" in s or "tier2" in s: return "tier2"
|
| 201 |
-
if "tier 3" in s or "tier3" in s or "village" in s or "small town" in s: return "tier3"
|
| 202 |
-
|
| 203 |
-
elif question_id == "budget":
|
| 204 |
-
# KI-149 (2026-05-15) — delegate to needs_finder._parse_budget_band so
|
| 205 |
-
# "I maximum 30000 I can pay" / "30 thousand" / "₹30,000" / "30 grand"
|
| 206 |
-
# / bare "30000" all parse the same way the GRAPH parser does. The
|
| 207 |
-
# keyword path used to require a "k" suffix and lost bare-digit
|
| 208 |
-
# answers (user reported re-ask bug on live HF Space).
|
| 209 |
-
from backend.needs_finder import _parse_budget_band
|
| 210 |
-
return _parse_budget_band(raw_text)
|
| 211 |
-
|
| 212 |
-
elif question_id == "health_conditions":
|
| 213 |
-
# KI-158 (2026-05-15) — broadened no-PED denial detection. The previous
|
| 214 |
-
# substring list missed "I'm not having any pre-existing condition",
|
| 215 |
-
# "no pre-existing condition" (singular), "don't have any", "nothing
|
| 216 |
-
# chronic", etc. Each miss let _canonical_fallback's loop-breaker tick
|
| 217 |
-
# the health_conditions slot toward force-skip, eventually making
|
| 218 |
-
# next_question() return None → gentle hand-off fires mid-fact-find.
|
| 219 |
-
# The fix uses regex to cover the natural phrasings without
|
| 220 |
-
# over-triggering on actual conditions like "I have diabetes".
|
| 221 |
-
_NO_PED_PATTERNS = [
|
| 222 |
-
r"\b(?:no|none|nothing|nope|nah|negative)\b\s*(?:condition|pre[-\s]?exist|health\s+issue|chronic|illness)?",
|
| 223 |
-
r"\b(?:not|don'?t|do\s+not|haven'?t|have\s+not|isn'?t|aren'?t)\s+(?:got|have|having|had|got\s+any|have\s+any)\b",
|
| 224 |
-
r"\b(?:no|zero|nil)\s+(?:pre[-\s]?exist\w*|condition|chronic|health\s+issue|illness|disease)",
|
| 225 |
-
r"\b(?:i\s+am|i'm)\s+(?:healthy|fine|fit|alright|all\s+good|good|ok|okay)\b",
|
| 226 |
-
r"\bnothing\s+(?:chronic|major|serious|to\s+report|like\s+that)\b",
|
| 227 |
-
r"\ball\s+(?:good|fine|clear|healthy)\b",
|
| 228 |
-
r"\bclean\s+bill\s+of\s+health\b",
|
| 229 |
-
]
|
| 230 |
-
if any(re.search(p, s) for p in _NO_PED_PATTERNS):
|
| 231 |
-
# Belt-and-braces: ensure the user didn't ALSO mention a real
|
| 232 |
-
# condition in the same message (e.g., "no diabetes but I have BP").
|
| 233 |
-
# If a condition keyword is present below, the canonical list
|
| 234 |
-
# branch will pick it up regardless.
|
| 235 |
-
return []
|
| 236 |
-
canonical = []
|
| 237 |
-
cond_keywords = {
|
| 238 |
-
"diabetes": ["diabetes", "diabetic", "sugar"],
|
| 239 |
-
"hypertension": ["hypertension", " bp ", "blood pressure", "high bp"],
|
| 240 |
-
"thyroid": ["thyroid", "hypothyroid", "hyperthyroid"],
|
| 241 |
-
"asthma": ["asthma"],
|
| 242 |
-
"heart": ["heart problem", "heart disease", "cardiac"],
|
| 243 |
-
"cancer": ["cancer", "tumor"],
|
| 244 |
-
}
|
| 245 |
-
for cond, kws in cond_keywords.items():
|
| 246 |
-
if any(k in s for k in kws):
|
| 247 |
-
canonical.append(cond)
|
| 248 |
-
if canonical:
|
| 249 |
-
return canonical
|
| 250 |
-
|
| 251 |
-
return None
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
# ----------------------------------------------------------------------------
|
| 255 |
-
# Fast-path parsers (no LLM)
|
| 256 |
-
# ----------------------------------------------------------------------------
|
| 257 |
-
|
| 258 |
-
def _parse_int(text: str, schema: dict) -> int | None:
|
| 259 |
-
digits = "".join(c for c in str(text) if c.isdigit())
|
| 260 |
-
if not digits:
|
| 261 |
-
return None
|
| 262 |
-
try:
|
| 263 |
-
v = int(digits[:3])
|
| 264 |
-
except ValueError:
|
| 265 |
-
return None
|
| 266 |
-
if v < schema.get("min", 0) or v > schema.get("max", 9_999):
|
| 267 |
-
return None
|
| 268 |
-
return v
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
def _parse_existing_cover(text: str) -> int | None:
|
| 272 |
-
"""Handle "no" / "none" / "5 lakh" / "₹500000" / "5L" / "haven't got any" / "30k".
|
| 273 |
-
|
| 274 |
-
KI-067 (2026-05-15) — also recognises first-time-buyer phrasings
|
| 275 |
-
("first policy", "first time buying", "new to insurance"). The previous
|
| 276 |
-
pattern caught "No, this is my first policy" via the "no" word but
|
| 277 |
-
rejected "This is my first policy" alone — user had to retry.
|
| 278 |
-
"""
|
| 279 |
-
s = text.lower().strip()
|
| 280 |
-
# Negative answers map to 0 (no existing cover).
|
| 281 |
-
# KI-156 (2026-05-15) — extended to cover gerund/auxiliary phrasings.
|
| 282 |
-
# Pre-fix "I am not having any policy" / "currently not having" / "not
|
| 283 |
-
# got any" / "without any" all returned None, causing the fact-find
|
| 284 |
-
# loop to keep re-asking the existing_cover slot.
|
| 285 |
-
if re.search(
|
| 286 |
-
r"\b(no|none|nothing|zero|nope|nah|haven'?t|don'?t|never|"
|
| 287 |
-
r"not\s+having|not\s+got|not\s+have|without\s+(?:any|a))\b",
|
| 288 |
-
s,
|
| 289 |
-
):
|
| 290 |
-
return 0
|
| 291 |
-
# KI-067 — first-time-buyer signals also imply zero existing cover.
|
| 292 |
-
_first_time_patterns = (
|
| 293 |
-
r"first\s+(?:policy|insurance|one|time|buy|cover)",
|
| 294 |
-
r"buying\s+(?:my\s+)?first",
|
| 295 |
-
r"new\s+(?:to\s+)?(?:health\s+)?insurance",
|
| 296 |
-
r"never\s+(?:bought|had|got|taken)",
|
| 297 |
-
r"haven'?t\s+(?:bought|had|got|taken)",
|
| 298 |
-
r"don'?t\s+have\s+(?:any|insurance|cover|a\s+policy)",
|
| 299 |
-
)
|
| 300 |
-
if any(re.search(p, s) for p in _first_time_patterns):
|
| 301 |
-
return 0
|
| 302 |
-
|
| 303 |
-
# Look for a number followed by a unit suffix (digit-attached OR separated).
|
| 304 |
-
# crore > lakh > thousand priority so longer units win the alternation.
|
| 305 |
-
cr_match = re.search(r"(\d+(?:\.\d+)?)\s*(?:cr|crore|crores)\b", s)
|
| 306 |
-
if cr_match:
|
| 307 |
-
try:
|
| 308 |
-
return int(float(cr_match.group(1)) * 10_000_000)
|
| 309 |
-
except ValueError:
|
| 310 |
-
return None
|
| 311 |
-
lakh_match = re.search(r"(\d+(?:\.\d+)?)\s*(?:l(?:akh|ac)?s?)\b", s)
|
| 312 |
-
if lakh_match:
|
| 313 |
-
try:
|
| 314 |
-
return int(float(lakh_match.group(1)) * 100_000)
|
| 315 |
-
except ValueError:
|
| 316 |
-
return None
|
| 317 |
-
k_match = re.search(r"(\d+(?:\.\d+)?)\s*k\b", s)
|
| 318 |
-
if k_match:
|
| 319 |
-
try:
|
| 320 |
-
return int(float(k_match.group(1)) * 1_000)
|
| 321 |
-
except ValueError:
|
| 322 |
-
return None
|
| 323 |
-
|
| 324 |
-
# Plain digit-only amount (e.g., "500000").
|
| 325 |
-
digits = "".join(c for c in text if c.isdigit())
|
| 326 |
-
if not digits:
|
| 327 |
-
return None
|
| 328 |
-
try:
|
| 329 |
-
amount = int(digits[:7])
|
| 330 |
-
except ValueError:
|
| 331 |
-
return None
|
| 332 |
-
if amount < 0 or amount > 100_000_000:
|
| 333 |
-
return None
|
| 334 |
-
return amount
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
# ----------------------------------------------------------------------------
|
| 338 |
-
# LLM-backed normalizer for enum + list fields
|
| 339 |
-
# ----------------------------------------------------------------------------
|
| 340 |
-
|
| 341 |
-
_LLM_SYSTEM_TEMPLATE = """You map a user's natural-language answer to a structured value.
|
| 342 |
-
|
| 343 |
-
Question ID: {qid}
|
| 344 |
-
Expected schema: {schema}
|
| 345 |
-
|
| 346 |
-
Rules:
|
| 347 |
-
1. If type=enum, return EXACTLY one of the allowed values (a JSON string), or null if no clear match.
|
| 348 |
-
2. If type=list, return a JSON array of canonical lowercase condition strings. For "no", "none", "nothing" → [].
|
| 349 |
-
3. If the user clearly didn't answer the question (off-topic, asking back, gibberish), return null.
|
| 350 |
-
4. Output ONLY the JSON value — no prose, no code fences, no <think> blocks.
|
| 351 |
-
|
| 352 |
-
Examples for guidance:
|
| 353 |
-
- dependents enum, user "just me" → "self"
|
| 354 |
-
- dependents enum, user "me and my wife" → "self+spouse"
|
| 355 |
-
- dependents enum, user "I want coverage for my parents too" → "self+parents"
|
| 356 |
-
- income_band enum, user "around 18 lakhs" → "10L-25L"
|
| 357 |
-
- income_band enum, user "more than 25 lakhs" → "25L+"
|
| 358 |
-
- primary_goal enum, user "I'm buying my first one" → "first_buy"
|
| 359 |
-
- primary_goal enum, user "want to compare HDFC and ICICI" → "compare_specific"
|
| 360 |
-
- location enum, user "Bangalore" → "metro"
|
| 361 |
-
- location enum, user "Patna" → "tier2"
|
| 362 |
-
- budget enum, user "around 20k a year" → "15k_30k"
|
| 363 |
-
- health_conditions list, user "none" → []
|
| 364 |
-
- health_conditions list, user "diabetes and BP" → ["diabetes", "hypertension"]
|
| 365 |
-
- health_conditions list, user "I have asthma" → ["asthma"]
|
| 366 |
-
"""
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
async def _llm_normalize(question_id: str, raw_text: str, schema: dict) -> Any:
|
| 370 |
-
from backend.providers.base import ChatMessage
|
| 371 |
-
from backend.providers.nvidia_nim_llm import FAST_BRAIN_CHAIN, NimChainLLM
|
| 372 |
-
|
| 373 |
-
sys_msg = _LLM_SYSTEM_TEMPLATE.format(qid=question_id, schema=json.dumps(schema))
|
| 374 |
-
user_msg = f'User said: "{raw_text[:600]}"\n\nReturn the JSON value.'
|
| 375 |
-
|
| 376 |
-
try:
|
| 377 |
-
# KI-033 (2026-05-14) — was hardcoded NvidiaNimLLM(meta/llama-3.3-70b);
|
| 378 |
-
# moved to fast-brain chain so when that one NIM pool rate-limits we
|
| 379 |
-
# fall through to Qwen/Nemotron/Groq instead of silently returning None
|
| 380 |
-
# (which made valid Indian-accented answers like "twenty-five" appear
|
| 381 |
-
# to fail the verifier, then trip the 2-reask cap, then move on with
|
| 382 |
-
# no profile captured — the D-005 cascade).
|
| 383 |
-
llm = NimChainLLM(chain=FAST_BRAIN_CHAIN, timeout=10.0,
|
| 384 |
-
role="fact_find_normalizer", total_budget_s=15.0)
|
| 385 |
-
result = await llm.chat(
|
| 386 |
-
messages=[
|
| 387 |
-
ChatMessage(role="system", content=sys_msg),
|
| 388 |
-
ChatMessage(role="user", content=user_msg),
|
| 389 |
-
],
|
| 390 |
-
temperature=0.0,
|
| 391 |
-
max_tokens=120,
|
| 392 |
-
)
|
| 393 |
-
raw = (result.text or "").strip()
|
| 394 |
-
except Exception as e:
|
| 395 |
-
logging.warning(
|
| 396 |
-
"fact_find_normalizer LLM call failed (qid=%s, raw=%r): %s",
|
| 397 |
-
question_id, raw_text[:80], e,
|
| 398 |
-
)
|
| 399 |
-
return None
|
| 400 |
-
|
| 401 |
-
# Strip <think> blocks and code fences that some models add despite instructions.
|
| 402 |
-
if "<think>" in raw and "</think>" in raw:
|
| 403 |
-
raw = raw.split("</think>", 1)[1].strip()
|
| 404 |
-
if raw.startswith("```"):
|
| 405 |
-
raw = "\n".join(l for l in raw.split("\n") if not l.startswith("```")).strip()
|
| 406 |
-
if not raw or raw.lower() == "null":
|
| 407 |
-
return None
|
| 408 |
-
|
| 409 |
-
try:
|
| 410 |
-
parsed = json.loads(raw)
|
| 411 |
-
except json.JSONDecodeError:
|
| 412 |
-
# Some models return bare strings without JSON quoting; tolerate.
|
| 413 |
-
if schema["type"] == "enum" and raw.strip('"') in schema["values"]:
|
| 414 |
-
return raw.strip('"')
|
| 415 |
-
return None
|
| 416 |
-
|
| 417 |
-
return _validate(parsed, schema)
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
def _validate(value: Any, schema: dict) -> Any:
|
| 421 |
-
"""Type + enum + bounds check. Returns None on failure."""
|
| 422 |
-
t = schema.get("type")
|
| 423 |
-
|
| 424 |
-
if t == "enum":
|
| 425 |
-
if isinstance(value, str) and value in schema["values"]:
|
| 426 |
-
return value
|
| 427 |
-
return None
|
| 428 |
-
|
| 429 |
-
if t == "int":
|
| 430 |
-
if isinstance(value, bool):
|
| 431 |
-
return None
|
| 432 |
-
try:
|
| 433 |
-
v = int(value)
|
| 434 |
-
except (TypeError, ValueError):
|
| 435 |
-
return None
|
| 436 |
-
if v < schema.get("min", -1_000_000_000) or v > schema.get("max", 1_000_000_000):
|
| 437 |
-
return None
|
| 438 |
-
return v
|
| 439 |
-
|
| 440 |
-
if t == "list":
|
| 441 |
-
if not isinstance(value, list):
|
| 442 |
-
return None
|
| 443 |
-
cleaned = [str(x).strip().lower() for x in value if x and isinstance(x, (str, int))]
|
| 444 |
-
cleaned = [c for c in cleaned if c]
|
| 445 |
-
return cleaned # [] is a valid answer (= "no conditions")
|
| 446 |
-
|
| 447 |
-
if t == "bool":
|
| 448 |
-
if isinstance(value, bool):
|
| 449 |
-
return value
|
| 450 |
-
return None
|
| 451 |
-
|
| 452 |
-
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1160,9 +1160,10 @@ async def profile_completeness_view(session_id: Optional[str] = None):
|
|
| 1160 |
missing = [k for k, v in profile_dict.items() if v in (None, "", [])]
|
| 1161 |
hint = None
|
| 1162 |
try:
|
| 1163 |
-
|
| 1164 |
-
|
| 1165 |
-
|
|
|
|
| 1166 |
except Exception:
|
| 1167 |
pass
|
| 1168 |
return ProfileCompletenessResponse(
|
|
|
|
| 1160 |
missing = [k for k, v in profile_dict.items() if v in (None, "", [])]
|
| 1161 |
hint = None
|
| 1162 |
try:
|
| 1163 |
+
# KI-167 WS3 (2026-05-15) — next_question now returns the field name
|
| 1164 |
+
# (str) of the next missing slot, not a Question object. Frontend uses
|
| 1165 |
+
# this as a slot-hint; the actual phrasing is generated by sales_brain.
|
| 1166 |
+
hint = next_question(p)
|
| 1167 |
except Exception:
|
| 1168 |
pass
|
| 1169 |
return ProfileCompletenessResponse(
|
|
@@ -1,24 +1,22 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
|
| 8 |
-
|
| 9 |
-
-
|
| 10 |
-
-
|
| 11 |
-
-
|
| 12 |
-
-
|
|
|
|
|
|
|
| 13 |
|
| 14 |
Public API:
|
| 15 |
-
- Profile dataclass
|
| 16 |
-
- next_question(profile) -> str | None (None =
|
| 17 |
-
- record_answer(profile,
|
| 18 |
-
- readback_summary(profile) -> str
|
| 19 |
-
|
| 20 |
-
The orchestrator can choose to drive the fact-find OR let the user
|
| 21 |
-
free-form questions — the graph supports both.
|
| 22 |
"""
|
| 23 |
|
| 24 |
from __future__ import annotations
|
|
@@ -43,7 +41,7 @@ class Profile:
|
|
| 43 |
parents_has_ped: Optional[bool] = None # if parents_to_insure
|
| 44 |
budget_band: Optional[str] = None # "under_15k", "15k_30k", "30k_60k", "60k+"
|
| 45 |
health_conditions: Optional[list[str]] = field(default_factory=list) # ["diabetes", "hypertension", ...]
|
| 46 |
-
asked: list[str] = field(default_factory=list) # question IDs already asked
|
| 47 |
free_form_session: bool = False # True = user asks free questions, not driven by us
|
| 48 |
# KI-063 (2026-05-15) — per-user policy interaction log so the bot
|
| 49 |
# remembers which policies were shown / selected / rejected across
|
|
@@ -56,26 +54,6 @@ class Profile:
|
|
| 56 |
rejected_policies: list[dict] = field(default_factory=list) # KI-063
|
| 57 |
|
| 58 |
|
| 59 |
-
# ----------------------------------------------------------------------------
|
| 60 |
-
# Question graph — each node has an id, a prompt, and a condition for being
|
| 61 |
-
# asked. Conditions are pure functions of the current Profile.
|
| 62 |
-
# ----------------------------------------------------------------------------
|
| 63 |
-
|
| 64 |
-
@dataclass
|
| 65 |
-
class Question:
|
| 66 |
-
id: str
|
| 67 |
-
prompt_en: str
|
| 68 |
-
prompt_hi: str # optional Hindi rendering (used when language='indic')
|
| 69 |
-
field: str
|
| 70 |
-
is_core: bool = False
|
| 71 |
-
condition: Any = None # callable(profile) -> bool, default: always ask
|
| 72 |
-
parser: Any = None # callable(text) -> value, default: text as-is
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
def _always(p: Profile) -> bool:
|
| 76 |
-
return True
|
| 77 |
-
|
| 78 |
-
|
| 79 |
# ----------------------------------------------------------------------------
|
| 80 |
# KI-149 (2026-05-15) — free-text INR amount parser for budget + income.
|
| 81 |
# User said "I maximum 30000 I can pay" → bot re-asked budget because no
|
|
@@ -219,97 +197,27 @@ def _parse_income_band(text: str) -> Optional[str]:
|
|
| 219 |
return "25L+"
|
| 220 |
|
| 221 |
|
| 222 |
-
GRAPH: list[Question] = [
|
| 223 |
-
Question(
|
| 224 |
-
id="name",
|
| 225 |
-
prompt_en="First — what should I call you? I'll save your profile so the next time you visit, you won't have to answer all this again.",
|
| 226 |
-
prompt_hi="सबसे पहले — आपको क्या कहूँ? आपकी profile save हो जाएगी, तो अगली बार दोबारा सवाल नहीं पूछूँगा।",
|
| 227 |
-
field="name",
|
| 228 |
-
is_core=True,
|
| 229 |
-
# Free-text — no parser; the orchestrator validates 1-50 chars
|
| 230 |
-
),
|
| 231 |
-
Question(
|
| 232 |
-
id="age",
|
| 233 |
-
prompt_en="First, your age? (Premium + eligibility + how long you can renew all hinge on this.)",
|
| 234 |
-
prompt_hi="पहला — आपकी उम्र? (Premium और renewal age इसी पर निर्भर हैं।)",
|
| 235 |
-
field="age",
|
| 236 |
-
is_core=True,
|
| 237 |
-
parser=lambda s: int("".join(c for c in str(s) if c.isdigit())[:3] or 0) or None,
|
| 238 |
-
),
|
| 239 |
-
Question(
|
| 240 |
-
id="dependents",
|
| 241 |
-
prompt_en="Who else needs cover — just you, spouse, kids, parents, or a mix? (Covering parents shifts which policies fit; parent-specific plans often win.)",
|
| 242 |
-
prompt_hi="किसको cover करना है — सिर्फ आप, पति/पत्नी, बच्चे, माता-पिता, या मिलाकर? (माता-पिता के साथ recommendations काफी बदलते हैं।)",
|
| 243 |
-
field="dependents",
|
| 244 |
-
is_core=True,
|
| 245 |
-
),
|
| 246 |
-
Question(
|
| 247 |
-
id="income_band",
|
| 248 |
-
prompt_en="Annual income band — under ₹5L, ₹5-10L, ₹10-25L, or ₹25L+? (Helps us suggest the right sum-insured size. Not shared with anyone.)",
|
| 249 |
-
prompt_hi="सालाना आय — ₹5L से कम, ₹5-10L, ₹10-25L, या ₹25L+? (हम सिर्फ sum insured size suggest करने के लिए पूछते हैं।)",
|
| 250 |
-
field="income_band",
|
| 251 |
-
is_core=True,
|
| 252 |
-
parser=_parse_income_band,
|
| 253 |
-
),
|
| 254 |
-
Question(
|
| 255 |
-
id="existing_cover",
|
| 256 |
-
prompt_en="Already have any health insurance — employer-provided or your own? If yes, roughly what sum insured? (If you have ₹5L from work, a top-up plan may fit better than a full base plan.)",
|
| 257 |
-
prompt_hi="पहले से कोई health insurance है — employer का या personal? Sum insured कितना? (Top-up plan ज़्यादा सही हो सकता है।)",
|
| 258 |
-
field="existing_cover_inr",
|
| 259 |
-
is_core=True,
|
| 260 |
-
parser=lambda s: (
|
| 261 |
-
0 if any(k in str(s).lower() for k in ("no", "none", "nothing", "zero", "not"))
|
| 262 |
-
else (
|
| 263 |
-
(int("".join(c for c in str(s) if c.isdigit())[:6] or 0) *
|
| 264 |
-
(100000 if any(k in str(s).lower() for k in ("l", "lakh", "lac")) else 1))
|
| 265 |
-
or None
|
| 266 |
-
)
|
| 267 |
-
),
|
| 268 |
-
),
|
| 269 |
-
Question(
|
| 270 |
-
id="primary_goal",
|
| 271 |
-
prompt_en="What's brought you here — first health policy, upgrading existing cover, comparing specific policies, or tax planning? (Tells us whether to grade you on price, breadth of cover, claim experience, or tax savings.)",
|
| 272 |
-
prompt_hi="आप यहाँ क्यों हैं — पहली policy, upgrade, specific compare, या tax planning? (इससे हम तय करते हैं कि आपको price, coverage, claim experience या tax savings पर grade करें।)",
|
| 273 |
-
field="primary_goal",
|
| 274 |
-
is_core=True,
|
| 275 |
-
),
|
| 276 |
-
Question(
|
| 277 |
-
id="location",
|
| 278 |
-
prompt_en="Which city? (Cashless hospital network density varies wildly — a '16,000-hospital network' claim means nothing if none are near you.)",
|
| 279 |
-
prompt_hi="कौन सा शहर? (Cashless network आपके शहर में कितना deep है यह बहुत matter करता है।)",
|
| 280 |
-
field="location_tier",
|
| 281 |
-
is_core=True,
|
| 282 |
-
),
|
| 283 |
-
# Conditional deep-dives
|
| 284 |
-
Question(
|
| 285 |
-
id="parents_age",
|
| 286 |
-
prompt_en="Your parents' ages, and any pre-existing conditions — diabetes, BP, heart, anything chronic? Be honest here: hiding a parent's condition is the #1 reason senior-cover claims get denied later.",
|
| 287 |
-
prompt_hi="माता-पिता की उम्र, और कोई pre-existing condition — diabetes, BP, heart? सच बताइए: hide करना senior-claims denied होने की #1 वजह है।",
|
| 288 |
-
field="parents_age_max",
|
| 289 |
-
condition=lambda p: bool(p.dependents and "parent" in p.dependents.lower()),
|
| 290 |
-
),
|
| 291 |
-
Question(
|
| 292 |
-
id="health_conditions",
|
| 293 |
-
prompt_en="Any pre-existing conditions on your side — diabetes, BP, thyroid, asthma, anything chronic? Be straight with me here — hiding it lowers your premium ₹500 today and turns into a ₹8 lakh denied claim later when the insurer matches your disclosure against hospital records. Your honest answer protects YOUR claim, not the insurer's profit.",
|
| 294 |
-
prompt_hi="आपकी side से कोई pre-existing condition — diabetes, BP, thyroid? सच बताइए — hide करने से premium तो कम होगा, but claim time पर ₹8 lakh denied हो सकते हैं। आपकी ईमानदारी आपकी claim बचाती है।",
|
| 295 |
-
field="health_conditions",
|
| 296 |
-
condition=_always,
|
| 297 |
-
),
|
| 298 |
-
Question(
|
| 299 |
-
id="budget",
|
| 300 |
-
prompt_en="Annual premium budget — under ₹15k, ₹15-30k, ₹30-60k, or ₹60k+? (If a slightly higher budget materially improves your protection, I'll flag it.)",
|
| 301 |
-
prompt_hi="Premium के लिए सालाना — ₹15k से कम, ₹15-30k, ₹30-60k, या ₹60k+? (अगर थोड़ा ज़्यादा budget बेहतर protection देगा, बताऊंगा।)",
|
| 302 |
-
field="budget_band",
|
| 303 |
-
is_core=True,
|
| 304 |
-
parser=_parse_budget_band,
|
| 305 |
-
),
|
| 306 |
-
]
|
| 307 |
-
|
| 308 |
-
|
| 309 |
# ----------------------------------------------------------------------------
|
| 310 |
# Engine
|
| 311 |
# ----------------------------------------------------------------------------
|
| 312 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
def is_field_set(profile: Profile, field_name: str) -> bool:
|
| 314 |
v = getattr(profile, field_name, None)
|
| 315 |
if v is None:
|
|
@@ -319,189 +227,96 @@ def is_field_set(profile: Profile, field_name: str) -> bool:
|
|
| 319 |
return True
|
| 320 |
|
| 321 |
|
| 322 |
-
def next_question(profile: Profile
|
| 323 |
-
"""Return the
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
handles question phrasing natively. This is now used as a fallback
|
| 328 |
-
(canonical reply when the brain times out / emits malformed JSON), as a
|
| 329 |
-
slot-not-progressing safeguard, and by `/api/profile/completeness` to
|
| 330 |
-
surface "next question hint" to the frontend.
|
| 331 |
"""
|
| 332 |
if profile.free_form_session:
|
| 333 |
return None
|
| 334 |
|
| 335 |
-
for
|
| 336 |
-
if
|
| 337 |
-
|
| 338 |
-
if is_field_set(profile, q.field):
|
| 339 |
-
continue
|
| 340 |
-
cond = q.condition or _always
|
| 341 |
-
if cond(profile):
|
| 342 |
-
return q
|
| 343 |
-
|
| 344 |
-
# All applicable questions asked
|
| 345 |
return None
|
| 346 |
|
| 347 |
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
return profile
|
| 353 |
value: Any = raw_answer
|
| 354 |
-
|
|
|
|
| 355 |
try:
|
| 356 |
-
value =
|
| 357 |
except Exception:
|
| 358 |
value = None
|
| 359 |
if value is not None and value != "":
|
| 360 |
-
setattr(profile,
|
| 361 |
# KI-095 — only mark slot asked once setattr succeeds, so a parse
|
| 362 |
# failure doesn't leave the slot in an asked-but-empty desync state.
|
| 363 |
-
profile.asked
|
|
|
|
| 364 |
return profile
|
| 365 |
|
| 366 |
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
# information has been thrown away. This helper detects family mentions in any
|
| 375 |
-
# free-text turn so the orchestrator can pre-fill `profile.dependents`.
|
| 376 |
-
#
|
| 377 |
-
# Returns one of the canonical `dependents` enum values, or None if no clear
|
| 378 |
-
# family signal is present. Conservative on purpose — only the explicit
|
| 379 |
-
# combinations are recognised.
|
| 380 |
-
|
| 381 |
-
_FAMILY_TERM_RE = re.compile(
|
| 382 |
-
r"\b(wife|husband|spouse|partner|kids?|children|child|parents?)\b",
|
| 383 |
-
re.IGNORECASE,
|
| 384 |
-
)
|
| 385 |
-
_SPOUSE_RE = re.compile(r"\b(wife|husband|spouse|partner)\b", re.IGNORECASE)
|
| 386 |
-
_KIDS_RE = re.compile(r"\b(kids?|children|child)\b", re.IGNORECASE)
|
| 387 |
-
_PARENTS_RE = re.compile(r"\bparents?\b", re.IGNORECASE)
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
def infer_dependents_from_text(text: str) -> Optional[str]:
|
| 391 |
-
"""Detect spouse/kids/parents mentions in a free-text user message and
|
| 392 |
-
return the matching canonical `dependents` enum value, or None.
|
| 393 |
-
|
| 394 |
-
KI-056 (2026-05-15). Used by the orchestrator to pre-fill the dependents
|
| 395 |
-
slot opportunistically when the user volunteers family information while
|
| 396 |
-
answering a different slot.
|
| 397 |
-
|
| 398 |
-
Decision tree (in order of specificity):
|
| 399 |
-
- spouse + kids → "self+spouse+kids"
|
| 400 |
-
- spouse + parents → "self+spouse+parents"
|
| 401 |
-
- spouse only → "self+spouse"
|
| 402 |
-
- kids only → "self+kids"
|
| 403 |
-
- parents only → "self+parents"
|
| 404 |
-
- nothing recognised → None
|
| 405 |
-
"""
|
| 406 |
-
if not text or not _FAMILY_TERM_RE.search(text):
|
| 407 |
return None
|
| 408 |
-
has_spouse = bool(_SPOUSE_RE.search(text))
|
| 409 |
-
has_kids = bool(_KIDS_RE.search(text))
|
| 410 |
-
has_parents = bool(_PARENTS_RE.search(text))
|
| 411 |
-
if has_spouse and has_kids:
|
| 412 |
-
return "self+spouse+kids"
|
| 413 |
-
if has_spouse and has_parents:
|
| 414 |
-
return "self+spouse+parents"
|
| 415 |
-
if has_spouse:
|
| 416 |
-
return "self+spouse"
|
| 417 |
-
if has_kids:
|
| 418 |
-
return "self+kids"
|
| 419 |
-
if has_parents:
|
| 420 |
-
return "self+parents"
|
| 421 |
-
return None
|
| 422 |
-
|
| 423 |
|
| 424 |
-
# KI-068 (2026-05-15) — enum → human-readable labels for the readback
|
| 425 |
-
# summary. Previously the bot read back "primary goal: first_buy; metro;
|
| 426 |
-
# budget 30k_60k" verbatim, which sounds and reads like a schema dump.
|
| 427 |
-
_PRIMARY_GOAL_LABELS = {
|
| 428 |
-
"first_buy": "first health policy",
|
| 429 |
-
"upgrade": "upgrading existing cover",
|
| 430 |
-
"compare_specific": "comparing specific policies",
|
| 431 |
-
"tax_planning": "tax planning",
|
| 432 |
-
}
|
| 433 |
-
_LOCATION_TIER_LABELS = {
|
| 434 |
-
"metro": "metro city",
|
| 435 |
-
"tier1": "tier-1 city",
|
| 436 |
-
"tier2": "tier-2 city",
|
| 437 |
-
"tier3": "tier-3 city",
|
| 438 |
-
}
|
| 439 |
-
_BUDGET_BAND_LABELS = {
|
| 440 |
-
"under_15k": "under ₹15,000/year",
|
| 441 |
-
"15k_30k": "₹15,000–30,000/year",
|
| 442 |
-
"30k_60k": "₹30,000–60,000/year",
|
| 443 |
-
"60k+": "₹60,000+/year",
|
| 444 |
-
}
|
| 445 |
-
_INCOME_BAND_LABELS = {
|
| 446 |
-
"under_5L": "under ₹5L",
|
| 447 |
-
"5_10L": "₹5–10L",
|
| 448 |
-
"10_25L": "₹10–25L",
|
| 449 |
-
"25L+": "₹25L+",
|
| 450 |
-
}
|
| 451 |
-
_DEPENDENTS_LABELS = {
|
| 452 |
-
"self": "just yourself",
|
| 453 |
-
"self+spouse": "you and your spouse",
|
| 454 |
-
"self+spouse+kids": "you, your spouse, and kids",
|
| 455 |
-
"self+kids": "you and your kids",
|
| 456 |
-
"self+parents": "you and your parents",
|
| 457 |
-
"self+spouse+parents": "you, your spouse, and parents",
|
| 458 |
-
"self+spouse+kids+parents": "you, your spouse, kids, and parents",
|
| 459 |
-
}
|
| 460 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
|
| 462 |
-
def readback_summary(profile: Profile) -> str:
|
| 463 |
-
"""One-paragraph human-readable summary of the gathered profile.
|
| 464 |
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
""
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
bits.append(f"{profile.age} years old")
|
| 473 |
-
if profile.dependents:
|
| 474 |
-
bits.append(f"covering {_DEPENDENTS_LABELS.get(profile.dependents, profile.dependents)}")
|
| 475 |
-
if profile.income_band:
|
| 476 |
-
bits.append(f"income {_INCOME_BAND_LABELS.get(profile.income_band, profile.income_band)}")
|
| 477 |
-
ec = profile.existing_cover_inr
|
| 478 |
-
if isinstance(ec, str):
|
| 479 |
-
try:
|
| 480 |
-
ec = int("".join(c for c in ec if c.isdigit())[:8] or 0)
|
| 481 |
-
except Exception:
|
| 482 |
-
ec = None
|
| 483 |
-
if isinstance(ec, (int, float)):
|
| 484 |
-
bits.append(
|
| 485 |
-
f"existing cover ₹{int(ec):,}"
|
| 486 |
-
if ec > 0
|
| 487 |
-
else "no existing cover"
|
| 488 |
-
)
|
| 489 |
-
if profile.primary_goal:
|
| 490 |
-
bits.append(f"goal: {_PRIMARY_GOAL_LABELS.get(profile.primary_goal, profile.primary_goal)}")
|
| 491 |
-
if profile.location_tier:
|
| 492 |
-
bits.append(_LOCATION_TIER_LABELS.get(profile.location_tier, profile.location_tier))
|
| 493 |
-
if profile.parents_age_max:
|
| 494 |
-
bits.append(f"parents up to age {profile.parents_age_max}")
|
| 495 |
-
if profile.health_conditions:
|
| 496 |
-
hc = profile.health_conditions
|
| 497 |
-
# Defensive: if a string accidentally landed here, wrap it so we don't
|
| 498 |
-
# split it character-by-character in the join. Production hit this on
|
| 499 |
-
# 2026-05-14 — a verbatim STT transcript was stored as a string, then
|
| 500 |
-
# ', '.join(str) emitted "d, i, f, f, e, r, e, n, c, e, ...".
|
| 501 |
-
if isinstance(hc, str):
|
| 502 |
-
hc = [hc] if hc.strip() else []
|
| 503 |
-
if hc:
|
| 504 |
-
bits.append(f"conditions: {', '.join(str(c) for c in hc)}")
|
| 505 |
-
if profile.budget_band:
|
| 506 |
-
bits.append(f"budget {_BUDGET_BAND_LABELS.get(profile.budget_band, profile.budget_band)}")
|
| 507 |
-
return "; ".join(bits) if bits else "(no profile yet)"
|
|
|
|
| 1 |
+
"""User profile state + slot-order hint for the fact-find.
|
| 2 |
|
| 3 |
+
KI-167 WS3 (2026-05-15) — the legacy rules-engine question GRAPH and the
|
| 4 |
+
matching `Question` dataclass have been removed. Fact-find phrasing now
|
| 5 |
+
lives in `backend/sales_brain.py` (the single-LLM-call brain); this module
|
| 6 |
+
keeps only:
|
| 7 |
|
| 8 |
+
- `Profile` — accumulated user state (still imported widely)
|
| 9 |
+
- `record_answer` — slot-write helper used by session_state
|
| 10 |
+
- INR / budget / income parsers — used by `sales_brain_normalizer.py`
|
| 11 |
+
- `is_field_set` — local helper for record_answer + next_question
|
| 12 |
+
- `next_question` — returns the field NAME (str) of the next
|
| 13 |
+
missing slot in canonical order. Used by
|
| 14 |
+
`/api/profile/completeness`'s `next_question_hint`.
|
| 15 |
|
| 16 |
Public API:
|
| 17 |
+
- Profile dataclass
|
| 18 |
+
- next_question(profile) -> str | None (field name, None = complete)
|
| 19 |
+
- record_answer(profile, field_name, raw_value) -> Profile
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
"""
|
| 21 |
|
| 22 |
from __future__ import annotations
|
|
|
|
| 41 |
parents_has_ped: Optional[bool] = None # if parents_to_insure
|
| 42 |
budget_band: Optional[str] = None # "under_15k", "15k_30k", "30k_60k", "60k+"
|
| 43 |
health_conditions: Optional[list[str]] = field(default_factory=list) # ["diabetes", "hypertension", ...]
|
| 44 |
+
asked: list[str] = field(default_factory=list) # question IDs / field names already asked
|
| 45 |
free_form_session: bool = False # True = user asks free questions, not driven by us
|
| 46 |
# KI-063 (2026-05-15) — per-user policy interaction log so the bot
|
| 47 |
# remembers which policies were shown / selected / rejected across
|
|
|
|
| 54 |
rejected_policies: list[dict] = field(default_factory=list) # KI-063
|
| 55 |
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
# ----------------------------------------------------------------------------
|
| 58 |
# KI-149 (2026-05-15) — free-text INR amount parser for budget + income.
|
| 59 |
# User said "I maximum 30000 I can pay" → bot re-asked budget because no
|
|
|
|
| 197 |
return "25L+"
|
| 198 |
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
# ----------------------------------------------------------------------------
|
| 201 |
# Engine
|
| 202 |
# ----------------------------------------------------------------------------
|
| 203 |
|
| 204 |
+
# KI-167 WS3 (2026-05-15) — canonical slot order for the fact-find hint API.
|
| 205 |
+
# The actual question phrasing lives in `sales_brain.py`; this list only
|
| 206 |
+
# encodes which Profile attribute we want to fill next when nothing else
|
| 207 |
+
# is driving the conversation.
|
| 208 |
+
_SLOT_ORDER: list[str] = [
|
| 209 |
+
"name",
|
| 210 |
+
"age",
|
| 211 |
+
"dependents",
|
| 212 |
+
"location_tier",
|
| 213 |
+
"income_band",
|
| 214 |
+
"primary_goal",
|
| 215 |
+
"existing_cover_inr",
|
| 216 |
+
"budget_band",
|
| 217 |
+
"health_conditions",
|
| 218 |
+
]
|
| 219 |
+
|
| 220 |
+
|
| 221 |
def is_field_set(profile: Profile, field_name: str) -> bool:
|
| 222 |
v = getattr(profile, field_name, None)
|
| 223 |
if v is None:
|
|
|
|
| 227 |
return True
|
| 228 |
|
| 229 |
|
| 230 |
+
def next_question(profile: Profile) -> Optional[str]:
|
| 231 |
+
"""Return the field NAME of the next missing slot, or None if complete.
|
| 232 |
+
|
| 233 |
+
KI-167 WS3 (2026-05-15) — refactored from `Optional[Question]` to
|
| 234 |
+
`Optional[str]` after the rules-engine GRAPH was deleted. The caller in
|
| 235 |
+
`backend/main.py:/api/profile/completeness` uses this only to hint to the
|
| 236 |
+
frontend which slot to ask next; the actual question phrasing is now
|
| 237 |
+
produced by `sales_brain.py`.
|
| 238 |
|
| 239 |
+
A free-form session (user driving free questions) returns None so the
|
| 240 |
+
hint endpoint reports "nothing to ask".
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
"""
|
| 242 |
if profile.free_form_session:
|
| 243 |
return None
|
| 244 |
|
| 245 |
+
for slot in _SLOT_ORDER:
|
| 246 |
+
if not is_field_set(profile, slot):
|
| 247 |
+
return slot
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
return None
|
| 249 |
|
| 250 |
|
| 251 |
+
# Legacy GRAPH question-id → Profile field-name aliases. Some callers
|
| 252 |
+
# (notably `session_state.record_answer` driven by `awaiting_question_id`)
|
| 253 |
+
# still pass the old question IDs from the deleted GRAPH. Mapping them here
|
| 254 |
+
# keeps that call path working without resurrecting the GRAPH.
|
| 255 |
+
_QID_TO_FIELD: dict[str, str] = {
|
| 256 |
+
"existing_cover": "existing_cover_inr",
|
| 257 |
+
"location": "location_tier",
|
| 258 |
+
"parents_age": "parents_age_max",
|
| 259 |
+
"budget": "budget_band",
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def record_answer(profile: Profile, question_id: str, raw_answer: Any) -> Profile:
|
| 264 |
+
"""Mutate profile in place with a raw answer for a named slot.
|
| 265 |
+
|
| 266 |
+
KI-167 WS3 (2026-05-15) — parser dispatch used to live on `Question`
|
| 267 |
+
objects in the deleted GRAPH; with the GRAPH gone we apply the same
|
| 268 |
+
parser map inline. `question_id` may be either a Profile attribute name
|
| 269 |
+
(preferred) or one of the legacy GRAPH question IDs from `_QID_TO_FIELD`.
|
| 270 |
+
"""
|
| 271 |
+
field_name = _QID_TO_FIELD.get(question_id, question_id)
|
| 272 |
+
if not hasattr(profile, field_name):
|
| 273 |
return profile
|
| 274 |
value: Any = raw_answer
|
| 275 |
+
parser = _PARSERS.get(field_name)
|
| 276 |
+
if parser is not None:
|
| 277 |
try:
|
| 278 |
+
value = parser(raw_answer)
|
| 279 |
except Exception:
|
| 280 |
value = None
|
| 281 |
if value is not None and value != "":
|
| 282 |
+
setattr(profile, field_name, value)
|
| 283 |
# KI-095 — only mark slot asked once setattr succeeds, so a parse
|
| 284 |
# failure doesn't leave the slot in an asked-but-empty desync state.
|
| 285 |
+
if question_id not in profile.asked:
|
| 286 |
+
profile.asked.append(question_id)
|
| 287 |
return profile
|
| 288 |
|
| 289 |
|
| 290 |
+
def _parse_age(s: Any) -> Optional[int]:
|
| 291 |
+
digits = "".join(c for c in str(s) if c.isdigit())[:3]
|
| 292 |
+
if not digits:
|
| 293 |
+
return None
|
| 294 |
+
try:
|
| 295 |
+
return int(digits) or None
|
| 296 |
+
except ValueError:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
|
| 300 |
+
def _parse_existing_cover(s: Any) -> Optional[int]:
|
| 301 |
+
text = str(s).lower()
|
| 302 |
+
if any(k in text for k in ("no", "none", "nothing", "zero", "not")):
|
| 303 |
+
return 0
|
| 304 |
+
digits = "".join(c for c in text if c.isdigit())[:6]
|
| 305 |
+
if not digits:
|
| 306 |
+
return None
|
| 307 |
+
try:
|
| 308 |
+
amt = int(digits)
|
| 309 |
+
except ValueError:
|
| 310 |
+
return None
|
| 311 |
+
if any(k in text for k in ("l", "lakh", "lac")):
|
| 312 |
+
amt *= 100_000
|
| 313 |
+
return amt or None
|
| 314 |
|
|
|
|
|
|
|
| 315 |
|
| 316 |
+
# Parser dispatch by Profile field name. Slots not listed accept the raw value.
|
| 317 |
+
_PARSERS: dict[str, Any] = {
|
| 318 |
+
"age": _parse_age,
|
| 319 |
+
"income_band": _parse_income_band,
|
| 320 |
+
"existing_cover_inr": _parse_existing_cover,
|
| 321 |
+
"budget_band": _parse_budget_band,
|
| 322 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -553,72 +553,57 @@ async def handle_turn(
|
|
| 553 |
)
|
| 554 |
|
| 555 |
if treat_as_fact_find:
|
| 556 |
-
# KI-
|
| 557 |
-
#
|
| 558 |
-
#
|
| 559 |
-
#
|
| 560 |
-
#
|
| 561 |
-
#
|
| 562 |
-
from backend.
|
| 563 |
-
FIELD_TO_QUESTION_ID,
|
| 564 |
-
drive_fact_find,
|
| 565 |
-
)
|
| 566 |
|
| 567 |
-
# KI-100 — bound drive_fact_find at the orchestrator. KI-079's
|
| 568 |
-
# internal FAST(25s) + BRAIN(15s) escalation can compound to ~40s on
|
| 569 |
-
# a doubly-saturated chain, which breaches the user's <30s per-turn
|
| 570 |
-
# target. Cap at 25s; on outer timeout, fall through to the
|
| 571 |
-
# canonical fallback that drive_fact_find itself uses on internal
|
| 572 |
-
# timeout (build the outcome from current session state).
|
| 573 |
try:
|
| 574 |
-
|
| 575 |
-
|
| 576 |
user_text=user_text,
|
| 577 |
-
|
| 578 |
-
chat_history=chat_history,
|
| 579 |
session_id=session_id,
|
| 580 |
),
|
| 581 |
timeout=25.0,
|
| 582 |
)
|
| 583 |
except asyncio.TimeoutError:
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
user_text=user_text,
|
| 592 |
-
reason="outer_timeout_25s",
|
| 593 |
)
|
| 594 |
|
| 595 |
-
# Apply captured updates to profile
|
| 596 |
-
#
|
| 597 |
-
#
|
|
|
|
| 598 |
fact_find_profile_updates: dict = {}
|
| 599 |
-
for field_name, value in
|
| 600 |
if value in (None, "", []):
|
| 601 |
continue
|
| 602 |
session.update_profile_field(field_name, value)
|
| 603 |
fact_find_profile_updates[field_name] = value
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
#
|
| 609 |
-
#
|
| 610 |
-
#
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
if
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
session.set_awaiting(None)
|
| 618 |
-
|
| 619 |
-
# If the brain decided fact-find is complete, flip free-form so
|
| 620 |
-
# subsequent turns route to retrieval+brain (not back here).
|
| 621 |
-
if outcome.fact_find_complete:
|
| 622 |
session.free_form_session = True
|
| 623 |
session._flush()
|
| 624 |
|
|
@@ -626,7 +611,8 @@ async def handle_turn(
|
|
| 626 |
# name. Anonymous sessions never write to Chroma; the corruption
|
| 627 |
# surface (profile_anonymous dangling row) is eliminated. Named
|
| 628 |
# users get their chunk keyed by canonical name slug, not session_id.
|
| 629 |
-
|
|
|
|
| 630 |
try:
|
| 631 |
from backend.profile_rag import upsert_profile_chunk
|
| 632 |
from backend.profile_store import _normalise_name
|
|
@@ -648,7 +634,7 @@ async def handle_turn(
|
|
| 648 |
})
|
| 649 |
except Exception as e:
|
| 650 |
logging.warning(
|
| 651 |
-
"
|
| 652 |
session_id, type(e).__name__, str(e)[:200],
|
| 653 |
)
|
| 654 |
|
|
@@ -671,39 +657,20 @@ async def handle_turn(
|
|
| 671 |
except Exception:
|
| 672 |
pass
|
| 673 |
|
| 674 |
-
if outcome.ambiguous:
|
| 675 |
-
# KI-078 (2026-05-15) — append fallback reason so admin telemetry
|
| 676 |
-
# can measure the fallback-cause mix (timeout vs llm_error vs
|
| 677 |
-
# no_trailer vs empty_reply). Essential for measuring KI-075 +
|
| 678 |
-
# KI-078 impact in production.
|
| 679 |
-
reason = getattr(outcome, "_fallback_reason", None) or "unknown"
|
| 680 |
-
brain_tag = f"fact_find_brain::fallback:{reason}"
|
| 681 |
-
elif outcome.fact_find_complete:
|
| 682 |
-
brain_tag = "fact_find_brain::complete"
|
| 683 |
-
else:
|
| 684 |
-
brain_tag = "fact_find_brain::continue"
|
| 685 |
-
|
| 686 |
# KI-108 (2026-05-15) — strip CoT preamble / instruction-echo leakage
|
| 687 |
-
# from the
|
| 688 |
-
#
|
| 689 |
-
#
|
| 690 |
-
|
| 691 |
-
# the only producer of user-facing text NOT routed through
|
| 692 |
-
# strip_think_tags (which internally calls strip_cot_preamble via
|
| 693 |
-
# voice_format). KI-104 had wired the strip into tts_preprocess
|
| 694 |
-
# (audio) + persona.strip_think_tags (qa/recommendation paths) but
|
| 695 |
-
# missed this branch. Apply uniformly here so every reply_text the
|
| 696 |
-
# API returns is leak-free regardless of brain used.
|
| 697 |
-
ff_reply_clean = strip_think_tags(outcome.reply_text)
|
| 698 |
return TurnResult(
|
| 699 |
-
reply_text=
|
| 700 |
citations=[],
|
| 701 |
retrieved_chunk_ids=[],
|
| 702 |
-
brain_used=
|
| 703 |
intent="fact_find",
|
| 704 |
language=language,
|
| 705 |
latency_ms=int((time.time() - t0) * 1000),
|
| 706 |
-
raw_reply=
|
| 707 |
faithfulness_passed=True,
|
| 708 |
blocked=False,
|
| 709 |
profile_updates=fact_find_profile_updates,
|
|
|
|
| 553 |
)
|
| 554 |
|
| 555 |
if treat_as_fact_find:
|
| 556 |
+
# KI-167 (2026-05-15) — WS2: replaces drive_fact_find with
|
| 557 |
+
# drive_sales_brain. The new brain owns conversation flow end-to-end
|
| 558 |
+
# (no slot_driving / awaiting_question machinery, no canonical
|
| 559 |
+
# fallback): one LLM call emits natural prose + canonical
|
| 560 |
+
# captured_updates + a completeness signal. Failures fail loud (no
|
| 561 |
+
# scripted fallback).
|
| 562 |
+
from backend.sales_brain import drive_sales_brain, SalesBrainResult
|
|
|
|
|
|
|
|
|
|
| 563 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
try:
|
| 565 |
+
sb_result: SalesBrainResult = await asyncio.wait_for(
|
| 566 |
+
drive_sales_brain(
|
| 567 |
user_text=user_text,
|
| 568 |
+
profile=session.profile,
|
| 569 |
+
chat_history=chat_history[-10:],
|
| 570 |
session_id=session_id,
|
| 571 |
),
|
| 572 |
timeout=25.0,
|
| 573 |
)
|
| 574 |
except asyncio.TimeoutError:
|
| 575 |
+
# No scripted fallback. Fail loud — let the operator see it.
|
| 576 |
+
sb_result = SalesBrainResult(
|
| 577 |
+
reply_text="Sorry, the system is taking too long. Try again in a moment.",
|
| 578 |
+
captured_updates={},
|
| 579 |
+
ready_for_recommendations=False,
|
| 580 |
+
brain_used="sales_brain::error:timeout_25s",
|
| 581 |
+
error_reason="outer_timeout_25s",
|
|
|
|
|
|
|
| 582 |
)
|
| 583 |
|
| 584 |
+
# Apply captured updates to profile. captured_updates is already
|
| 585 |
+
# post-processed and canonical (WS1 contract): pass values straight to
|
| 586 |
+
# session.update_profile_field. For the new path, the question id IS
|
| 587 |
+
# the field name — no FIELD_TO_QUESTION_ID translation.
|
| 588 |
fact_find_profile_updates: dict = {}
|
| 589 |
+
for field_name, value in sb_result.captured_updates.items():
|
| 590 |
if value in (None, "", []):
|
| 591 |
continue
|
| 592 |
session.update_profile_field(field_name, value)
|
| 593 |
fact_find_profile_updates[field_name] = value
|
| 594 |
+
if field_name not in session.profile.asked:
|
| 595 |
+
session.profile.asked.append(field_name)
|
| 596 |
+
|
| 597 |
+
# Completion gate. The LLM owns conversation flow; we only flip
|
| 598 |
+
# free_form_session when its own readiness signal is True AND every
|
| 599 |
+
# required slot is filled. Otherwise leave free_form_session=False so
|
| 600 |
+
# the next turn routes back here and the LLM continues gathering.
|
| 601 |
+
_REQUIRED_SLOTS = ("name", "age", "dependents", "location_tier",
|
| 602 |
+
"income_band", "primary_goal")
|
| 603 |
+
if sb_result.ready_for_recommendations and all(
|
| 604 |
+
getattr(session.profile, slot, None) not in (None, "", [])
|
| 605 |
+
for slot in _REQUIRED_SLOTS
|
| 606 |
+
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
session.free_form_session = True
|
| 608 |
session._flush()
|
| 609 |
|
|
|
|
| 611 |
# name. Anonymous sessions never write to Chroma; the corruption
|
| 612 |
# surface (profile_anonymous dangling row) is eliminated. Named
|
| 613 |
# users get their chunk keyed by canonical name slug, not session_id.
|
| 614 |
+
# KI-167 — trigger when any slot was captured this turn.
|
| 615 |
+
if bool(sb_result.captured_updates) and session.profile.name:
|
| 616 |
try:
|
| 617 |
from backend.profile_rag import upsert_profile_chunk
|
| 618 |
from backend.profile_store import _normalise_name
|
|
|
|
| 634 |
})
|
| 635 |
except Exception as e:
|
| 636 |
logging.warning(
|
| 637 |
+
"sales_brain profile-chunk upsert failed (session=%s): %s: %s",
|
| 638 |
session_id, type(e).__name__, str(e)[:200],
|
| 639 |
)
|
| 640 |
|
|
|
|
| 657 |
except Exception:
|
| 658 |
pass
|
| 659 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
# KI-108 (2026-05-15) — strip CoT preamble / instruction-echo leakage
|
| 661 |
+
# from the brain reply BEFORE returning to ChatResponse. The LLM may
|
| 662 |
+
# still emit <think> tags despite JSON mode; strip_think_tags is the
|
| 663 |
+
# uniform guard applied across every brain path.
|
| 664 |
+
reply_text = strip_think_tags(sb_result.reply_text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
return TurnResult(
|
| 666 |
+
reply_text=reply_text,
|
| 667 |
citations=[],
|
| 668 |
retrieved_chunk_ids=[],
|
| 669 |
+
brain_used=sb_result.brain_used,
|
| 670 |
intent="fact_find",
|
| 671 |
language=language,
|
| 672 |
latency_ms=int((time.time() - t0) * 1000),
|
| 673 |
+
raw_reply=sb_result.reply_text,
|
| 674 |
faithfulness_passed=True,
|
| 675 |
blocked=False,
|
| 676 |
profile_updates=fact_find_profile_updates,
|
|
@@ -0,0 +1,470 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""KI-167 WS1 — LLM-driven sales brain (replaces the scripted slot-walker).
|
| 2 |
+
|
| 3 |
+
Architecture shift from `backend/fact_find_brain.py`:
|
| 4 |
+
|
| 5 |
+
PRE-KI-167 (fact_find_brain.py):
|
| 6 |
+
System prompt asks the LLM for prose + a `<FF>{...}</FF>` JSON trailer.
|
| 7 |
+
Backend strips the trailer, parses it, validates each capture. Works
|
| 8 |
+
but: (a) some models drop the trailer despite the strict instruction
|
| 9 |
+
(KI-090 surfaced this; KI-150 raised max_tokens to 700 to mitigate),
|
| 10 |
+
(b) the canonical-fallback ladder is heavy machinery that competes
|
| 11 |
+
with the brain for the same conversation.
|
| 12 |
+
|
| 13 |
+
POST-KI-167 (this module):
|
| 14 |
+
Single LLM call with `response_format={"type":"json_object"}` so the
|
| 15 |
+
NIM model is GUARANTEED to emit a parseable JSON object — no trailer
|
| 16 |
+
tag, no `<FF>` regex, no fallback ladder. JSON contains the natural
|
| 17 |
+
reply, captured slots, and a `ready_for_recommendations` boolean.
|
| 18 |
+
The deterministic post-processor in `sales_brain_normalizer.py`
|
| 19 |
+
cleans the captures dict.
|
| 20 |
+
|
| 21 |
+
Public API (the CONTRACT — WS2 will call this exactly):
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class SalesBrainResult:
|
| 25 |
+
reply_text: str
|
| 26 |
+
captured_updates: dict
|
| 27 |
+
ready_for_recommendations: bool
|
| 28 |
+
brain_used: str
|
| 29 |
+
raw_json: Optional[dict] = None
|
| 30 |
+
error_reason: Optional[str] = None
|
| 31 |
+
|
| 32 |
+
async def drive_sales_brain(
|
| 33 |
+
user_text: str,
|
| 34 |
+
profile: Profile,
|
| 35 |
+
chat_history: list[dict],
|
| 36 |
+
session_id: Optional[str] = None,
|
| 37 |
+
) -> SalesBrainResult: ...
|
| 38 |
+
|
| 39 |
+
Failure mode:
|
| 40 |
+
On JSON parse failure (should be near-zero with `response_format`) OR
|
| 41 |
+
LLM call failure / timeout, this returns a SalesBrainResult with
|
| 42 |
+
`brain_used="sales_brain::error:<reason>"`, empty reply_text, empty
|
| 43 |
+
captured_updates, and `ready_for_recommendations=False`. The caller
|
| 44 |
+
(WS2 orchestrator) decides what to do — NO scripted fallback lives
|
| 45 |
+
here.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
from __future__ import annotations
|
| 49 |
+
|
| 50 |
+
import asyncio
|
| 51 |
+
import json
|
| 52 |
+
import logging
|
| 53 |
+
import time
|
| 54 |
+
from dataclasses import dataclass, field
|
| 55 |
+
from typing import Any, Optional
|
| 56 |
+
|
| 57 |
+
from backend.needs_finder import Profile
|
| 58 |
+
from backend.providers.base import ChatMessage
|
| 59 |
+
from backend.providers.nvidia_nim_llm import get_fast_brain_llm
|
| 60 |
+
from backend.sales_brain_normalizer import normalize_captures
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ----------------------------------------------------------------------------
|
| 64 |
+
# Public result dataclass — the contract WS2 will consume.
|
| 65 |
+
# ----------------------------------------------------------------------------
|
| 66 |
+
|
| 67 |
+
@dataclass
|
| 68 |
+
class SalesBrainResult:
|
| 69 |
+
reply_text: str
|
| 70 |
+
captured_updates: dict = field(default_factory=dict)
|
| 71 |
+
ready_for_recommendations: bool = False
|
| 72 |
+
brain_used: str = ""
|
| 73 |
+
raw_json: Optional[dict] = None
|
| 74 |
+
error_reason: Optional[str] = None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ----------------------------------------------------------------------------
|
| 78 |
+
# Timeouts
|
| 79 |
+
# ----------------------------------------------------------------------------
|
| 80 |
+
# 25s mirrors fact_find_brain's KI-075 setting — gives NIM cold-start headroom
|
| 81 |
+
# + leaves room for one chain fallback. The fast-brain chain already has its
|
| 82 |
+
# own per-link + total-chain budget; this wait_for is a belt-and-braces stop.
|
| 83 |
+
_TIMEOUT_S: float = 25.0
|
| 84 |
+
_MAX_TOKENS: int = 700 # prose + structured JSON object with safety margin
|
| 85 |
+
_TEMPERATURE: float = 0.6 # mirrors fact_find_brain — conversational warmth
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ----------------------------------------------------------------------------
|
| 89 |
+
# Slot metadata — descriptions surfaced to the LLM so it knows what to ask for
|
| 90 |
+
# ----------------------------------------------------------------------------
|
| 91 |
+
|
| 92 |
+
_SLOT_DESCRIPTIONS: dict[str, str] = {
|
| 93 |
+
"name": "User's preferred name (1-50 chars, no leading 'currently'/'not'/'no'/'looking', single or two-word first/last name).",
|
| 94 |
+
"age": "User's age as an integer 16-99.",
|
| 95 |
+
"dependents": "Who needs cover. Enum: 'self', 'self+spouse', 'self+spouse+kids', 'self+parents', 'self+spouse+parents', 'self+spouse+kids+parents', 'self+kids'.",
|
| 96 |
+
"income_band": "Annual income band. Enum: 'under_5L', '5L-10L', '10L-25L', '25L+'.",
|
| 97 |
+
"existing_cover_inr": "Current health-insurance sum-insured in rupees. 0 = no existing cover. Integer.",
|
| 98 |
+
"primary_goal": "Why they're shopping. Enum: 'first_buy', 'upgrade', 'compare_specific', 'tax_planning'.",
|
| 99 |
+
"location_tier": "City tier. Enum: 'metro', 'tier2', 'tier3', 'rural'.",
|
| 100 |
+
"parents_to_insure": "Whether to include parents on the policy. Boolean.",
|
| 101 |
+
"parents_age_max": "Older parent's age (40-99). Only relevant if parents_to_insure is true.",
|
| 102 |
+
"parents_has_ped": "Whether either parent has a pre-existing condition. Boolean. Only relevant if parents_to_insure is true.",
|
| 103 |
+
"budget_band": "Annual budget band. Enum: 'under_15k', '15k_30k', '30k_60k', '60k+'.",
|
| 104 |
+
"health_conditions": "User's pre-existing conditions, lowercase list. Common values: diabetes, hypertension, thyroid, asthma, heart, cancer. Empty list = no conditions.",
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
# Order of slots — used to determine "required remaining" so the LLM has a
|
| 108 |
+
# stable sense of what to ask next. The first six are the recommendation-
|
| 109 |
+
# readiness minimum; the rest are deepening signals.
|
| 110 |
+
_REQUIRED_FOR_READY: tuple = (
|
| 111 |
+
"name", "age", "dependents", "location_tier", "income_band", "primary_goal",
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
_NICE_TO_HAVE: tuple = (
|
| 115 |
+
"existing_cover_inr", "budget_band", "health_conditions",
|
| 116 |
+
"parents_to_insure", "parents_age_max", "parents_has_ped",
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ----------------------------------------------------------------------------
|
| 121 |
+
# System prompt
|
| 122 |
+
# ----------------------------------------------------------------------------
|
| 123 |
+
|
| 124 |
+
_BASE_SYSTEM_PROMPT = """You are a friendly Indian health-insurance broker having a natural, warm conversation with a prospective customer. Your tone is consultative — never pushy, never robotic, never a form. Speak like a trusted family friend who happens to know insurance, not a call-centre script.
|
| 125 |
+
|
| 126 |
+
YOUR JOB:
|
| 127 |
+
1. Get to know the user warmly across a short conversation.
|
| 128 |
+
2. Capture the slots listed below at your own pace, in any order that feels natural.
|
| 129 |
+
3. When you have enough to recommend (the minimum set is named below), set ready_for_recommendations: true.
|
| 130 |
+
|
| 131 |
+
CONVERSATION RULES:
|
| 132 |
+
- Use the user's first name when you know it. Sound human.
|
| 133 |
+
- Ask AT MOST ONE thing per turn — but weave it naturally into the conversation, don't fire it like a survey question.
|
| 134 |
+
- Don't say canned acknowledgements like "Got that —", "Noted.", "Perfect!", or "Great!". Acknowledge naturally by reflecting back what they said.
|
| 135 |
+
- Don't restart the conversation. Continue from wherever it is — pick up from the user's last message.
|
| 136 |
+
- If the user volunteers multiple facts in one message ("Hi I'm Rohit, 29, in Mumbai, looking for my first policy"), CAPTURE ALL OF THEM in this turn — don't re-ask for what they just told you.
|
| 137 |
+
- If the user goes off-topic (asks about waiting periods, claims, hospital networks, anything else), give a brief honest answer first, then steer naturally back to what you still need to know.
|
| 138 |
+
- BFSI compliance: do NOT promise specific premium quotes. Do NOT pressure-close. Stay advisory.
|
| 139 |
+
- Indian English is fine — "₹", "lakh", "metro/tier-2 city", "BP", "diabetes" all natural.
|
| 140 |
+
- On health conditions, be straight: hiding a condition lowers premium today but turns into a denied claim later. Encourage honesty without lecturing.
|
| 141 |
+
- Never use markdown bold/italics — your reply may be read aloud by TTS.
|
| 142 |
+
|
| 143 |
+
OUTPUT FORMAT — STRICT:
|
| 144 |
+
Return a SINGLE JSON object with exactly these three keys:
|
| 145 |
+
|
| 146 |
+
{
|
| 147 |
+
"reply": "<your natural reply to the user — prose, no markdown, no JSON tags, no preamble like 'Got that —'>",
|
| 148 |
+
"captures": { <field>: <value>, ... },
|
| 149 |
+
"ready_for_recommendations": <true | false>
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
Rules for the JSON:
|
| 153 |
+
- "reply" — natural conversational prose. NO scripted prefixes ("Got that —", "Noted —", "Sure —", "Perfect —"). Acknowledge what they said by reflecting it back, then continue.
|
| 154 |
+
- "captures" — only fields the user CHANGED or NEWLY revealed in their LAST message. If they didn't reveal anything new this turn, emit `"captures": {}` (empty object). Do NOT re-emit slots that haven't changed. Do NOT include null / empty-string values.
|
| 155 |
+
- "ready_for_recommendations" — set to true ONLY when you have AT LEAST name, age, dependents, location, income_band, primary_goal. Otherwise false.
|
| 156 |
+
- All enum values must match the slot schema EXACTLY (case-sensitive). All ints as JSON numbers, not strings. Lists as JSON arrays.
|
| 157 |
+
|
| 158 |
+
EXAMPLES OF GOOD REPLIES (illustrative — the JSON shape is what matters):
|
| 159 |
+
|
| 160 |
+
User: "Hi, I'm Rohit, 29, looking to buy my first health policy."
|
| 161 |
+
{
|
| 162 |
+
"reply": "Hi Rohit — nice to meet you. First health policy is a smart move at your age. To shape the right options, who else would you want covered apart from yourself — spouse, kids, parents, or just you for now?",
|
| 163 |
+
"captures": {"name": "Rohit", "age": 29, "primary_goal": "first_buy"},
|
| 164 |
+
"ready_for_recommendations": false
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
User: "Just me and my wife. We're both in Bangalore."
|
| 168 |
+
{
|
| 169 |
+
"reply": "You and your spouse in Bangalore — good, metro cashless networks are deep there. Roughly what's your annual income band: under ₹5L, ₹5-10L, ₹10-25L, or ₹25L+? Helps me size the right sum-insured.",
|
| 170 |
+
"captures": {"dependents": "self+spouse", "location_tier": "metro"},
|
| 171 |
+
"ready_for_recommendations": false
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
User: "around 18 lakh."
|
| 175 |
+
{
|
| 176 |
+
"reply": "That puts you in the ₹10-25L band — gives us comfortable room on sum-insured. Last thing before I shortlist some options for you — any pre-existing conditions on either side, like BP, diabetes, thyroid, anything chronic? Being straight here protects your future claim.",
|
| 177 |
+
"captures": {"income_band": "10L-25L"},
|
| 178 |
+
"ready_for_recommendations": false
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
User: "No, both healthy."
|
| 182 |
+
{
|
| 183 |
+
"reply": "Good to hear. So Rohit, here's what I'm working with: 29, you and your wife in Bangalore, ₹10-25L income, first-time buyer, no pre-existing conditions. I have enough to put together a tailored shortlist now — want me to suggest 2-3 policies that fit?",
|
| 184 |
+
"captures": {"health_conditions": []},
|
| 185 |
+
"ready_for_recommendations": true
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
User: "What's the waiting period for PED usually?"
|
| 189 |
+
{
|
| 190 |
+
"reply": "Most policies in India have a 24-36 month waiting period for pre-existing diseases — IRDAI caps the maximum, and a few insurers offer shorter waits as a premium upgrade. Happy to dig into a specific one if you have it in mind. Meanwhile — what budget range are you comfortable with annually: under ₹15k, ₹15-30k, ₹30-60k, or ₹60k+?",
|
| 191 |
+
"captures": {},
|
| 192 |
+
"ready_for_recommendations": false
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
User: "Hi"
|
| 196 |
+
{
|
| 197 |
+
"reply": "Hi there — happy to help you find a health policy that actually fits. What should I call you?",
|
| 198 |
+
"captures": {},
|
| 199 |
+
"ready_for_recommendations": false
|
| 200 |
+
}
|
| 201 |
+
"""
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ----------------------------------------------------------------------------
|
| 205 |
+
# Profile snapshot helpers
|
| 206 |
+
# ----------------------------------------------------------------------------
|
| 207 |
+
|
| 208 |
+
_TRACKED_FIELDS: tuple = (
|
| 209 |
+
"name", "age", "dependents", "income_band", "existing_cover_inr",
|
| 210 |
+
"primary_goal", "location_tier", "parents_to_insure", "parents_age_max",
|
| 211 |
+
"parents_has_ped", "health_conditions", "budget_band",
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _profile_known(profile: Profile) -> dict[str, Any]:
|
| 216 |
+
"""Return a JSON-friendly dict of currently-captured profile fields.
|
| 217 |
+
|
| 218 |
+
OMITS None / empty-string / empty-list fields — so the prompt only
|
| 219 |
+
shows the LLM what's actually known. This avoids polluting the
|
| 220 |
+
context with `"age": null, "income_band": null, ...` etc.
|
| 221 |
+
"""
|
| 222 |
+
snap: dict[str, Any] = {}
|
| 223 |
+
for f in _TRACKED_FIELDS:
|
| 224 |
+
v = getattr(profile, f, None)
|
| 225 |
+
if v is None:
|
| 226 |
+
continue
|
| 227 |
+
if isinstance(v, str) and not v.strip():
|
| 228 |
+
continue
|
| 229 |
+
if isinstance(v, list) and not v:
|
| 230 |
+
# Explicit empty health_conditions list IS meaningful ("user said
|
| 231 |
+
# they have nothing"); include it so the LLM won't re-ask.
|
| 232 |
+
if f == "health_conditions":
|
| 233 |
+
snap[f] = []
|
| 234 |
+
continue
|
| 235 |
+
snap[f] = v
|
| 236 |
+
return snap
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _required_remaining(known: dict[str, Any]) -> list[str]:
|
| 240 |
+
"""List required slots still missing, in canonical asking order."""
|
| 241 |
+
return [f for f in _REQUIRED_FOR_READY if f not in known]
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def _nice_to_have_remaining(known: dict[str, Any], profile: Profile) -> list[str]:
|
| 245 |
+
"""List nice-to-have slots still missing. parents_age_max / parents_has_ped
|
| 246 |
+
only count as missing when parents_to_insure is True (or dependents says
|
| 247 |
+
parents)."""
|
| 248 |
+
out: list[str] = []
|
| 249 |
+
parents_in_scope = bool(
|
| 250 |
+
known.get("parents_to_insure") is True
|
| 251 |
+
or (isinstance(known.get("dependents"), str) and "parent" in known["dependents"])
|
| 252 |
+
)
|
| 253 |
+
for f in _NICE_TO_HAVE:
|
| 254 |
+
if f in known:
|
| 255 |
+
continue
|
| 256 |
+
if f in ("parents_age_max", "parents_has_ped") and not parents_in_scope:
|
| 257 |
+
continue
|
| 258 |
+
out.append(f)
|
| 259 |
+
return out
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _format_slot_list(slots: list[str]) -> str:
|
| 263 |
+
"""Render a slot list as a bulleted summary the LLM can read."""
|
| 264 |
+
if not slots:
|
| 265 |
+
return "(none)"
|
| 266 |
+
return "\n".join(f" - {f}: {_SLOT_DESCRIPTIONS.get(f, '')}" for f in slots)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _build_system_prompt(profile: Profile) -> str:
|
| 270 |
+
"""Assemble the full system prompt: base rules + KNOWN block + missing slots."""
|
| 271 |
+
known = _profile_known(profile)
|
| 272 |
+
required_remaining = _required_remaining(known)
|
| 273 |
+
nice_to_have_remaining = _nice_to_have_remaining(known, profile)
|
| 274 |
+
|
| 275 |
+
known_block = (
|
| 276 |
+
json.dumps(known, ensure_ascii=False) if known else "{}"
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
return (
|
| 280 |
+
_BASE_SYSTEM_PROMPT
|
| 281 |
+
+ "\n\n--- SLOT SCHEMA (the fields you may capture) ---\n"
|
| 282 |
+
+ _format_slot_list(list(_SLOT_DESCRIPTIONS.keys()))
|
| 283 |
+
+ f"\n\nKNOWN: {known_block}"
|
| 284 |
+
+ "\n(These fields are ALREADY captured. Do NOT re-ask for them. Use the user's name when known.)"
|
| 285 |
+
+ "\n\nREQUIRED SLOTS STILL MISSING (you need these before setting ready_for_recommendations=true):\n"
|
| 286 |
+
+ _format_slot_list(required_remaining)
|
| 287 |
+
+ "\n\nNICE-TO-HAVE SLOTS STILL MISSING (capture if the user volunteers; don't force):\n"
|
| 288 |
+
+ _format_slot_list(nice_to_have_remaining)
|
| 289 |
+
+ (
|
| 290 |
+
"\n\nYou have enough to recommend now. If the user signals they want options "
|
| 291 |
+
"(\"show me policies\", \"what do you suggest\"), set ready_for_recommendations=true."
|
| 292 |
+
if not required_remaining else
|
| 293 |
+
"\n\nKeep gathering naturally — you don't yet have the minimum required set."
|
| 294 |
+
)
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# ----------------------------------------------------------------------------
|
| 299 |
+
# JSON parsing — response_format=json_object should make this near-zero-fail
|
| 300 |
+
# ----------------------------------------------------------------------------
|
| 301 |
+
|
| 302 |
+
def _parse_brain_json(raw_text: str) -> Optional[dict]:
|
| 303 |
+
"""Parse the LLM's JSON-object response.
|
| 304 |
+
|
| 305 |
+
With NIM `response_format={"type":"json_object"}` the response is
|
| 306 |
+
guaranteed to be a JSON object. Still defensively handle the rare
|
| 307 |
+
cases where a model emits stray whitespace, code fences, or a
|
| 308 |
+
leading `<think>` block.
|
| 309 |
+
"""
|
| 310 |
+
if not raw_text:
|
| 311 |
+
return None
|
| 312 |
+
text = raw_text.strip()
|
| 313 |
+
# Strip `<think>` blocks some models emit despite instructions.
|
| 314 |
+
if "<think>" in text and "</think>" in text:
|
| 315 |
+
text = text.split("</think>", 1)[1].strip()
|
| 316 |
+
# Strip code fences.
|
| 317 |
+
if text.startswith("```"):
|
| 318 |
+
lines = text.split("\n")
|
| 319 |
+
text = "\n".join(l for l in lines if not l.startswith("```")).strip()
|
| 320 |
+
# Direct parse
|
| 321 |
+
try:
|
| 322 |
+
obj = json.loads(text)
|
| 323 |
+
if isinstance(obj, dict):
|
| 324 |
+
return obj
|
| 325 |
+
except json.JSONDecodeError:
|
| 326 |
+
pass
|
| 327 |
+
# Try to find the first JSON object substring.
|
| 328 |
+
start = text.find("{")
|
| 329 |
+
end = text.rfind("}")
|
| 330 |
+
if start != -1 and end > start:
|
| 331 |
+
try:
|
| 332 |
+
obj = json.loads(text[start : end + 1])
|
| 333 |
+
if isinstance(obj, dict):
|
| 334 |
+
return obj
|
| 335 |
+
except json.JSONDecodeError:
|
| 336 |
+
pass
|
| 337 |
+
return None
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
# ----------------------------------------------------------------------------
|
| 341 |
+
# Public entry point
|
| 342 |
+
# ----------------------------------------------------------------------------
|
| 343 |
+
|
| 344 |
+
async def drive_sales_brain(
|
| 345 |
+
user_text: str,
|
| 346 |
+
profile: Profile,
|
| 347 |
+
chat_history: list[dict],
|
| 348 |
+
session_id: Optional[str] = None,
|
| 349 |
+
) -> SalesBrainResult:
|
| 350 |
+
"""Single LLM call per turn — replaces the scripted slot-walker.
|
| 351 |
+
|
| 352 |
+
Returns a SalesBrainResult. On LLM error or JSON parse failure,
|
| 353 |
+
`brain_used` is `sales_brain::error:<reason>` and `reply_text` is
|
| 354 |
+
empty. Caller (orchestrator) is responsible for any fallback behavior
|
| 355 |
+
— NO scripted reply lives here.
|
| 356 |
+
"""
|
| 357 |
+
t0 = time.time()
|
| 358 |
+
|
| 359 |
+
# Build the system prompt with current profile state
|
| 360 |
+
system_prompt = _build_system_prompt(profile)
|
| 361 |
+
|
| 362 |
+
# Build messages: system + last ~10 chat history turns + user_text
|
| 363 |
+
messages: list[ChatMessage] = [ChatMessage(role="system", content=system_prompt)]
|
| 364 |
+
if chat_history:
|
| 365 |
+
for turn in chat_history[-10:]:
|
| 366 |
+
role = turn.get("role")
|
| 367 |
+
content = turn.get("content")
|
| 368 |
+
if role in ("user", "assistant") and content:
|
| 369 |
+
messages.append(ChatMessage(role=role, content=str(content)))
|
| 370 |
+
messages.append(ChatMessage(role="user", content=user_text or ""))
|
| 371 |
+
|
| 372 |
+
# Call the fast-brain NIM chain with response_format=json_object
|
| 373 |
+
# (singleton-via-factory — see KI-080; the factory builds a fresh
|
| 374 |
+
# NimChainLLM each call but the underlying election state is shared
|
| 375 |
+
# via backend.llm_health module-level dicts).
|
| 376 |
+
llm = get_fast_brain_llm()
|
| 377 |
+
try:
|
| 378 |
+
result = await asyncio.wait_for(
|
| 379 |
+
llm.chat(
|
| 380 |
+
messages=messages,
|
| 381 |
+
temperature=_TEMPERATURE,
|
| 382 |
+
max_tokens=_MAX_TOKENS,
|
| 383 |
+
response_format={"type": "json_object"},
|
| 384 |
+
),
|
| 385 |
+
timeout=_TIMEOUT_S,
|
| 386 |
+
)
|
| 387 |
+
except asyncio.TimeoutError:
|
| 388 |
+
elapsed = time.time() - t0
|
| 389 |
+
logging.warning(
|
| 390 |
+
"sales_brain timeout (session=%s, %.1fs)", session_id, elapsed,
|
| 391 |
+
)
|
| 392 |
+
return SalesBrainResult(
|
| 393 |
+
reply_text="",
|
| 394 |
+
captured_updates={},
|
| 395 |
+
ready_for_recommendations=False,
|
| 396 |
+
brain_used="sales_brain::error:timeout",
|
| 397 |
+
error_reason=f"timeout_after_{elapsed:.1f}s",
|
| 398 |
+
)
|
| 399 |
+
except Exception as e: # noqa: BLE001 — broad catch is intentional here
|
| 400 |
+
logging.warning(
|
| 401 |
+
"sales_brain LLM call failed (session=%s): %s: %s",
|
| 402 |
+
session_id, type(e).__name__, str(e)[:200],
|
| 403 |
+
)
|
| 404 |
+
return SalesBrainResult(
|
| 405 |
+
reply_text="",
|
| 406 |
+
captured_updates={},
|
| 407 |
+
ready_for_recommendations=False,
|
| 408 |
+
brain_used=f"sales_brain::error:llm_error_{type(e).__name__}",
|
| 409 |
+
error_reason=f"{type(e).__name__}: {str(e)[:200]}",
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
# Parse the JSON object from the LLM response.
|
| 413 |
+
raw_text = (result.text or "").strip()
|
| 414 |
+
parsed = _parse_brain_json(raw_text)
|
| 415 |
+
served_model = getattr(result, "model", "unknown") or "unknown"
|
| 416 |
+
|
| 417 |
+
if parsed is None:
|
| 418 |
+
logging.warning(
|
| 419 |
+
"sales_brain JSON parse failed (session=%s, model=%s, raw=%r)",
|
| 420 |
+
session_id, served_model, raw_text[:300],
|
| 421 |
+
)
|
| 422 |
+
return SalesBrainResult(
|
| 423 |
+
reply_text="",
|
| 424 |
+
captured_updates={},
|
| 425 |
+
ready_for_recommendations=False,
|
| 426 |
+
brain_used=f"sales_brain::error:parse_fail",
|
| 427 |
+
error_reason=f"could_not_parse_json from model={served_model}; raw_head={raw_text[:120]!r}",
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
+
# Extract the three contract keys, tolerating mild key drift.
|
| 431 |
+
reply_text = parsed.get("reply") or parsed.get("response") or parsed.get("message") or ""
|
| 432 |
+
if not isinstance(reply_text, str):
|
| 433 |
+
reply_text = str(reply_text)
|
| 434 |
+
reply_text = reply_text.strip()
|
| 435 |
+
|
| 436 |
+
captures_raw = parsed.get("captures") or parsed.get("captured") or {}
|
| 437 |
+
if not isinstance(captures_raw, dict):
|
| 438 |
+
captures_raw = {}
|
| 439 |
+
|
| 440 |
+
ready_raw = parsed.get("ready_for_recommendations")
|
| 441 |
+
if ready_raw is None:
|
| 442 |
+
ready_raw = parsed.get("ready")
|
| 443 |
+
ready_for_recommendations = bool(ready_raw) if ready_raw is not None else False
|
| 444 |
+
|
| 445 |
+
# Normalize + validate captures via the deterministic post-processor.
|
| 446 |
+
captured_updates = normalize_captures(captures_raw, profile)
|
| 447 |
+
|
| 448 |
+
# Empty reply text is a soft-fail — the brain produced JSON but no prose.
|
| 449 |
+
# Surface this to the caller so it can decide what to do.
|
| 450 |
+
if not reply_text:
|
| 451 |
+
return SalesBrainResult(
|
| 452 |
+
reply_text="",
|
| 453 |
+
captured_updates=captured_updates,
|
| 454 |
+
ready_for_recommendations=ready_for_recommendations,
|
| 455 |
+
brain_used="sales_brain::error:empty_reply",
|
| 456 |
+
raw_json=parsed,
|
| 457 |
+
error_reason=f"reply_field_empty from model={served_model}",
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
return SalesBrainResult(
|
| 461 |
+
reply_text=reply_text,
|
| 462 |
+
captured_updates=captured_updates,
|
| 463 |
+
ready_for_recommendations=ready_for_recommendations,
|
| 464 |
+
brain_used=f"sales_brain::nim:{served_model}",
|
| 465 |
+
raw_json=parsed,
|
| 466 |
+
error_reason=None,
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
__all__ = ["SalesBrainResult", "drive_sales_brain"]
|
|
@@ -0,0 +1,687 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""KI-167 WS1 — Deterministic post-processor for the LLM sales brain.
|
| 2 |
+
|
| 3 |
+
The sales brain (`backend/sales_brain.py`) issues ONE NIM call with the slot
|
| 4 |
+
schema in its system prompt + `response_format={"type":"json_object"}`. The
|
| 5 |
+
LLM returns a `captures` dict — but real LLMs:
|
| 6 |
+
|
| 7 |
+
* Use loose field aliases ("location" instead of "location_tier",
|
| 8 |
+
"income" instead of "income_band").
|
| 9 |
+
* Emit free-text values for things that need to be enums
|
| 10 |
+
("Bangalore" instead of "metro", "5 lakhs" instead of "5L-10L").
|
| 11 |
+
* Occasionally emit `null` / empty-string for fields they didn't capture.
|
| 12 |
+
* Sometimes hallucinate a name that's actually a status phrase
|
| 13 |
+
("Currently Not Having Any").
|
| 14 |
+
|
| 15 |
+
This module is the deterministic shim that takes that raw dict and emits a
|
| 16 |
+
clean `{canonical_field: validated_value}` map ready to flow into
|
| 17 |
+
`SessionState.update_profile_field()`. NO LLM calls — pure normalization +
|
| 18 |
+
validation. Cheap, deterministic, easy to audit.
|
| 19 |
+
|
| 20 |
+
Cherry-picked / adapted from `backend/fact_find_normalizer.py`:
|
| 21 |
+
- `_FIELD_SCHEMA` — slot type + bounds + allowed enum values
|
| 22 |
+
- `_validate(value, schema)` — type/enum/bounds check
|
| 23 |
+
- `_keyword_normalize` ideas — free-text → enum mapping
|
| 24 |
+
- `_parse_existing_cover` — INR-amount parser for existing cover
|
| 25 |
+
|
| 26 |
+
Also delegates to `backend/needs_finder._parse_inr_amount`,
|
| 27 |
+
`_parse_budget_band`, `_parse_income_band` so this stays the single source
|
| 28 |
+
of truth on numeric parsing.
|
| 29 |
+
|
| 30 |
+
Public API:
|
| 31 |
+
normalize_captures(raw, current_profile) -> dict
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
import re
|
| 37 |
+
from typing import Any, Optional
|
| 38 |
+
|
| 39 |
+
from backend.needs_finder import (
|
| 40 |
+
Profile,
|
| 41 |
+
_parse_budget_band,
|
| 42 |
+
_parse_income_band,
|
| 43 |
+
_parse_inr_amount,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ----------------------------------------------------------------------------
|
| 48 |
+
# Slot schema — canonical Profile field name → type / bounds / allowed values
|
| 49 |
+
# ----------------------------------------------------------------------------
|
| 50 |
+
#
|
| 51 |
+
# Mirrors `fact_find_normalizer._FIELD_SCHEMA` but keyed by the PROFILE FIELD
|
| 52 |
+
# NAME (not the question id). Includes `self+kids` in dependents (LLM may
|
| 53 |
+
# legitimately emit it; absent from the original schema). Mirrors the WS1
|
| 54 |
+
# spec verbatim where the spec adds new values (`tier3`, `rural`).
|
| 55 |
+
|
| 56 |
+
_FIELD_SCHEMA: dict[str, dict] = {
|
| 57 |
+
"name": {"type": "name"},
|
| 58 |
+
"age": {"type": "int", "min": 16, "max": 99},
|
| 59 |
+
"dependents": {
|
| 60 |
+
"type": "enum",
|
| 61 |
+
"values": [
|
| 62 |
+
"self",
|
| 63 |
+
"self+spouse",
|
| 64 |
+
"self+spouse+kids",
|
| 65 |
+
"self+kids",
|
| 66 |
+
"self+parents",
|
| 67 |
+
"self+spouse+parents",
|
| 68 |
+
"self+spouse+kids+parents",
|
| 69 |
+
],
|
| 70 |
+
},
|
| 71 |
+
"income_band": {
|
| 72 |
+
"type": "enum",
|
| 73 |
+
"values": ["under_5L", "5L-10L", "10L-25L", "25L+"],
|
| 74 |
+
},
|
| 75 |
+
"existing_cover_inr": {"type": "int", "min": 0, "max": 100_000_000},
|
| 76 |
+
"primary_goal": {
|
| 77 |
+
"type": "enum",
|
| 78 |
+
# WS1 spec uses "compare" + "tax_savings"; the existing fact-find
|
| 79 |
+
# codebase uses "compare_specific" + "tax_planning". Accept both
|
| 80 |
+
# via alias-normalization below; canonical-stored value matches the
|
| 81 |
+
# existing Profile (which downstream WS2/WS3 consume).
|
| 82 |
+
"values": ["first_buy", "upgrade", "compare_specific", "tax_planning"],
|
| 83 |
+
},
|
| 84 |
+
"location_tier": {
|
| 85 |
+
"type": "enum",
|
| 86 |
+
# WS1 spec adds "rural"; existing schema uses metro/tier1/tier2/tier3.
|
| 87 |
+
# Accept tier1 as an alias (legacy fact-find emitted it); collapse to
|
| 88 |
+
# tier2 since the WS1 spec drops the tier1 bucket.
|
| 89 |
+
"values": ["metro", "tier2", "tier3", "rural"],
|
| 90 |
+
},
|
| 91 |
+
"parents_to_insure": {"type": "bool"},
|
| 92 |
+
"parents_age_max": {"type": "int", "min": 40, "max": 99},
|
| 93 |
+
"parents_has_ped": {"type": "bool"},
|
| 94 |
+
"budget_band": {
|
| 95 |
+
"type": "enum",
|
| 96 |
+
"values": ["under_15k", "15k_30k", "30k_60k", "60k+"],
|
| 97 |
+
},
|
| 98 |
+
"health_conditions": {
|
| 99 |
+
"type": "list",
|
| 100 |
+
"common_values": [
|
| 101 |
+
"diabetes", "hypertension", "thyroid", "asthma", "heart", "cancer",
|
| 102 |
+
],
|
| 103 |
+
},
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ----------------------------------------------------------------------------
|
| 108 |
+
# Field-name alias map — loose LLM key → canonical Profile field
|
| 109 |
+
# ----------------------------------------------------------------------------
|
| 110 |
+
|
| 111 |
+
_FIELD_ALIASES: dict[str, str] = {
|
| 112 |
+
# location aliases
|
| 113 |
+
"location": "location_tier",
|
| 114 |
+
"city": "location_tier",
|
| 115 |
+
"tier": "location_tier",
|
| 116 |
+
"location_tier": "location_tier",
|
| 117 |
+
# income aliases
|
| 118 |
+
"income": "income_band",
|
| 119 |
+
"income_band": "income_band",
|
| 120 |
+
"annual_income": "income_band",
|
| 121 |
+
"salary": "income_band",
|
| 122 |
+
# cover aliases
|
| 123 |
+
"existing_cover": "existing_cover_inr",
|
| 124 |
+
"existing_cover_inr": "existing_cover_inr",
|
| 125 |
+
"current_cover": "existing_cover_inr",
|
| 126 |
+
"current_sum_insured": "existing_cover_inr",
|
| 127 |
+
"sum_insured": "existing_cover_inr",
|
| 128 |
+
# goal aliases
|
| 129 |
+
"goal": "primary_goal",
|
| 130 |
+
"primary_goal": "primary_goal",
|
| 131 |
+
"intent": "primary_goal",
|
| 132 |
+
# budget aliases
|
| 133 |
+
"budget": "budget_band",
|
| 134 |
+
"budget_band": "budget_band",
|
| 135 |
+
"annual_budget": "budget_band",
|
| 136 |
+
# parents aliases
|
| 137 |
+
"parents_age": "parents_age_max",
|
| 138 |
+
"parents_age_max": "parents_age_max",
|
| 139 |
+
"parents_to_insure": "parents_to_insure",
|
| 140 |
+
"cover_parents": "parents_to_insure",
|
| 141 |
+
"include_parents": "parents_to_insure",
|
| 142 |
+
"parents_has_ped": "parents_has_ped",
|
| 143 |
+
"parents_ped": "parents_has_ped",
|
| 144 |
+
"parents_conditions": "parents_has_ped",
|
| 145 |
+
# health
|
| 146 |
+
"health_conditions": "health_conditions",
|
| 147 |
+
"conditions": "health_conditions",
|
| 148 |
+
"ped": "health_conditions",
|
| 149 |
+
"pre_existing": "health_conditions",
|
| 150 |
+
"pre_existing_conditions": "health_conditions",
|
| 151 |
+
# passthrough
|
| 152 |
+
"name": "name",
|
| 153 |
+
"age": "age",
|
| 154 |
+
"dependents": "dependents",
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ----------------------------------------------------------------------------
|
| 159 |
+
# Public API
|
| 160 |
+
# ----------------------------------------------------------------------------
|
| 161 |
+
|
| 162 |
+
def normalize_captures(raw: dict, current_profile: Profile) -> dict:
|
| 163 |
+
"""Map a raw LLM `captures` dict to clean `{canonical_field: value}`.
|
| 164 |
+
|
| 165 |
+
Pipeline per entry:
|
| 166 |
+
1. Skip if value is None / empty string / empty list (LLM didn't
|
| 167 |
+
actually capture this turn).
|
| 168 |
+
2. Resolve the field alias → canonical Profile attribute name. Drop
|
| 169 |
+
unknown fields silently.
|
| 170 |
+
3. Run the value through the field's normalizer + validator. Drop
|
| 171 |
+
on validation failure (don't raise — sales brain is fail-open).
|
| 172 |
+
4. Compare against `current_profile.<field>`. Skip no-ops.
|
| 173 |
+
|
| 174 |
+
Returns: clean dict ready for `SessionState.update_profile_field()`.
|
| 175 |
+
"""
|
| 176 |
+
if not isinstance(raw, dict):
|
| 177 |
+
return {}
|
| 178 |
+
|
| 179 |
+
clean: dict[str, Any] = {}
|
| 180 |
+
for raw_key, raw_value in raw.items():
|
| 181 |
+
if raw_value is None:
|
| 182 |
+
continue
|
| 183 |
+
if isinstance(raw_value, str) and not raw_value.strip():
|
| 184 |
+
continue
|
| 185 |
+
# Bare empty list / dict from the LLM = no actual signal; drop.
|
| 186 |
+
# The "user affirmed no conditions" path goes through a STRING value
|
| 187 |
+
# like "all good" that the normalizer parses to [].
|
| 188 |
+
if isinstance(raw_value, (list, dict)) and not raw_value:
|
| 189 |
+
continue
|
| 190 |
+
|
| 191 |
+
canonical = _FIELD_ALIASES.get(str(raw_key).strip().lower())
|
| 192 |
+
if canonical is None:
|
| 193 |
+
continue
|
| 194 |
+
schema = _FIELD_SCHEMA.get(canonical)
|
| 195 |
+
if schema is None:
|
| 196 |
+
continue
|
| 197 |
+
|
| 198 |
+
normalized = _normalize_value(canonical, raw_value, schema)
|
| 199 |
+
if normalized is None:
|
| 200 |
+
# Could not coerce — drop silently
|
| 201 |
+
continue
|
| 202 |
+
|
| 203 |
+
# No-op check — skip fields that match the current profile value.
|
| 204 |
+
# Exception: an explicit "no conditions" capture (empty list parsed
|
| 205 |
+
# from "all good" / "none" / etc.) is meaningful even when the profile
|
| 206 |
+
# already holds an empty list — it tells the orchestrator the slot
|
| 207 |
+
# was just addressed this turn so it won't be re-asked.
|
| 208 |
+
current = getattr(current_profile, canonical, None)
|
| 209 |
+
if current == normalized:
|
| 210 |
+
if (
|
| 211 |
+
canonical == "health_conditions"
|
| 212 |
+
and normalized == []
|
| 213 |
+
and current == []
|
| 214 |
+
and isinstance(raw_value, str)
|
| 215 |
+
):
|
| 216 |
+
clean[canonical] = normalized
|
| 217 |
+
continue
|
| 218 |
+
|
| 219 |
+
clean[canonical] = normalized
|
| 220 |
+
|
| 221 |
+
return clean
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ----------------------------------------------------------------------------
|
| 225 |
+
# Value normalization — per-field free-text → canonical value
|
| 226 |
+
# ----------------------------------------------------------------------------
|
| 227 |
+
|
| 228 |
+
def _normalize_value(field: str, value: Any, schema: dict) -> Any:
|
| 229 |
+
"""Field-specific normalization. Returns None on validation failure."""
|
| 230 |
+
if field == "name":
|
| 231 |
+
return _normalize_name(value)
|
| 232 |
+
if field == "age":
|
| 233 |
+
return _normalize_int(value, schema)
|
| 234 |
+
if field == "dependents":
|
| 235 |
+
return _normalize_dependents(value, schema)
|
| 236 |
+
if field == "income_band":
|
| 237 |
+
return _normalize_income_band(value, schema)
|
| 238 |
+
if field == "existing_cover_inr":
|
| 239 |
+
return _normalize_existing_cover(value, schema)
|
| 240 |
+
if field == "primary_goal":
|
| 241 |
+
return _normalize_primary_goal(value, schema)
|
| 242 |
+
if field == "location_tier":
|
| 243 |
+
return _normalize_location_tier(value, schema)
|
| 244 |
+
if field == "parents_to_insure":
|
| 245 |
+
return _normalize_bool(value)
|
| 246 |
+
if field == "parents_age_max":
|
| 247 |
+
return _normalize_int(value, schema)
|
| 248 |
+
if field == "parents_has_ped":
|
| 249 |
+
return _normalize_bool(value)
|
| 250 |
+
if field == "budget_band":
|
| 251 |
+
return _normalize_budget_band(value, schema)
|
| 252 |
+
if field == "health_conditions":
|
| 253 |
+
return _normalize_health_conditions(value)
|
| 254 |
+
return None
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
# ---------- name ----------
|
| 258 |
+
|
| 259 |
+
_NAME_BAD_FIRST_WORDS = {
|
| 260 |
+
"currently", "not", "no", "none", "nothing", "never",
|
| 261 |
+
"without", "looking", "buying", "shopping",
|
| 262 |
+
"yes", "yeah", "nope", "nah",
|
| 263 |
+
"haven", "havent", "haven't",
|
| 264 |
+
"don", "dont", "don't",
|
| 265 |
+
"i", "we", "my", "this", "that", "the",
|
| 266 |
+
"first", "still", "yet",
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _normalize_name(value: Any) -> Optional[str]:
|
| 271 |
+
"""Validate a candidate name string.
|
| 272 |
+
|
| 273 |
+
Reject if:
|
| 274 |
+
- Not a non-empty string.
|
| 275 |
+
- Contains digits.
|
| 276 |
+
- Length > 50 chars.
|
| 277 |
+
- First word is in the status-phrase blocklist.
|
| 278 |
+
- <50% alphabetic chars (e.g. "..." or "@!#").
|
| 279 |
+
"""
|
| 280 |
+
if not isinstance(value, str):
|
| 281 |
+
return None
|
| 282 |
+
s = value.strip()
|
| 283 |
+
if not s or len(s) > 50:
|
| 284 |
+
return None
|
| 285 |
+
if any(c.isdigit() for c in s):
|
| 286 |
+
return None
|
| 287 |
+
# Strip simple polite-prefix scraps the LLM might leave in
|
| 288 |
+
s_lc = s.lower()
|
| 289 |
+
for prefix in (
|
| 290 |
+
"i'm ", "i am ", "this is ", "my name is ", "name is ",
|
| 291 |
+
"name's ", "call me ", "im ", "mr ", "mrs ", "ms ", "dr ",
|
| 292 |
+
):
|
| 293 |
+
if s_lc.startswith(prefix):
|
| 294 |
+
s = s[len(prefix):].strip()
|
| 295 |
+
s_lc = s.lower()
|
| 296 |
+
break
|
| 297 |
+
if not s:
|
| 298 |
+
return None
|
| 299 |
+
first = s.split()[0].lower().strip(".,!?")
|
| 300 |
+
if first in _NAME_BAD_FIRST_WORDS:
|
| 301 |
+
return None
|
| 302 |
+
alpha = sum(1 for c in s if c.isalpha())
|
| 303 |
+
if alpha < 2 or alpha / max(1, len(s)) < 0.5:
|
| 304 |
+
return None
|
| 305 |
+
# Capitalize if all-lower (LLM convention — names should be Title Case).
|
| 306 |
+
if not any(c.isupper() for c in s):
|
| 307 |
+
s = " ".join(w.capitalize() for w in s.split())
|
| 308 |
+
return s
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
# ---------- int / bool ----------
|
| 312 |
+
|
| 313 |
+
def _normalize_int(value: Any, schema: dict) -> Optional[int]:
|
| 314 |
+
if isinstance(value, bool): # bool is subclass of int — exclude
|
| 315 |
+
return None
|
| 316 |
+
if isinstance(value, (int, float)):
|
| 317 |
+
v = int(value)
|
| 318 |
+
elif isinstance(value, str):
|
| 319 |
+
digits = "".join(c for c in value if c.isdigit())
|
| 320 |
+
if not digits:
|
| 321 |
+
return None
|
| 322 |
+
try:
|
| 323 |
+
v = int(digits[:3])
|
| 324 |
+
except ValueError:
|
| 325 |
+
return None
|
| 326 |
+
else:
|
| 327 |
+
return None
|
| 328 |
+
lo = schema.get("min", -1_000_000_000)
|
| 329 |
+
hi = schema.get("max", 1_000_000_000)
|
| 330 |
+
if v < lo or v > hi:
|
| 331 |
+
return None
|
| 332 |
+
return v
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def _normalize_bool(value: Any) -> Optional[bool]:
|
| 336 |
+
if isinstance(value, bool):
|
| 337 |
+
return value
|
| 338 |
+
if isinstance(value, (int, float)):
|
| 339 |
+
return bool(value)
|
| 340 |
+
if isinstance(value, str):
|
| 341 |
+
s = value.strip().lower()
|
| 342 |
+
if s in ("true", "yes", "y", "1", "include", "covered", "with"):
|
| 343 |
+
return True
|
| 344 |
+
if s in ("false", "no", "n", "0", "exclude", "skip", "without"):
|
| 345 |
+
return False
|
| 346 |
+
return None
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
# ---------- dependents ----------
|
| 350 |
+
|
| 351 |
+
_DEPENDENT_VAGUE_TERMS = (
|
| 352 |
+
"family", "everyone", "all of us", "everybody",
|
| 353 |
+
"whole family", "joint family",
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def _normalize_dependents(value: Any, schema: dict) -> Optional[str]:
|
| 358 |
+
if not isinstance(value, str):
|
| 359 |
+
return None
|
| 360 |
+
s = value.strip().lower()
|
| 361 |
+
if not s:
|
| 362 |
+
return None
|
| 363 |
+
# Direct hit on a canonical enum value
|
| 364 |
+
if s in schema["values"]:
|
| 365 |
+
return s
|
| 366 |
+
|
| 367 |
+
has_spouse = any(k in s for k in ("spouse", "wife", "husband", "partner"))
|
| 368 |
+
has_kids = ("kid" in s) or ("child" in s) or ("children" in s) or ("son" in s) or ("daughter" in s)
|
| 369 |
+
has_parents = "parent" in s
|
| 370 |
+
|
| 371 |
+
# Vague terms with no specific signals → cannot coerce
|
| 372 |
+
if any(v in s for v in _DEPENDENT_VAGUE_TERMS) and not (has_spouse or has_kids or has_parents):
|
| 373 |
+
return None
|
| 374 |
+
|
| 375 |
+
if has_spouse and has_kids and has_parents:
|
| 376 |
+
return "self+spouse+kids+parents"
|
| 377 |
+
if has_spouse and has_parents:
|
| 378 |
+
return "self+spouse+parents"
|
| 379 |
+
if has_spouse and has_kids:
|
| 380 |
+
return "self+spouse+kids"
|
| 381 |
+
if has_spouse:
|
| 382 |
+
return "self+spouse"
|
| 383 |
+
if has_kids and has_parents:
|
| 384 |
+
# No canonical bucket — fold parents into the wider bundle
|
| 385 |
+
return "self+spouse+kids+parents"
|
| 386 |
+
if has_kids:
|
| 387 |
+
return "self+kids"
|
| 388 |
+
if has_parents:
|
| 389 |
+
return "self+parents"
|
| 390 |
+
if s in ("self", "me", "just me", "only me", "myself", "only self"):
|
| 391 |
+
return "self"
|
| 392 |
+
return None
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
# ---------- income_band ----------
|
| 396 |
+
|
| 397 |
+
def _normalize_income_band(value: Any, schema: dict) -> Optional[str]:
|
| 398 |
+
if isinstance(value, str):
|
| 399 |
+
s = value.strip()
|
| 400 |
+
if s in schema["values"]:
|
| 401 |
+
return s
|
| 402 |
+
# KI-149 — let the centralised parser handle free text.
|
| 403 |
+
parsed = _parse_income_band(s)
|
| 404 |
+
if parsed in schema["values"]:
|
| 405 |
+
return parsed
|
| 406 |
+
return None
|
| 407 |
+
if isinstance(value, (int, float)):
|
| 408 |
+
# Bare number → rupees → bucket
|
| 409 |
+
amt = int(value)
|
| 410 |
+
if amt < 500_000:
|
| 411 |
+
return "under_5L"
|
| 412 |
+
if amt < 1_000_000:
|
| 413 |
+
return "5L-10L"
|
| 414 |
+
if amt < 2_500_000:
|
| 415 |
+
return "10L-25L"
|
| 416 |
+
return "25L+"
|
| 417 |
+
return None
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
# ---------- existing_cover_inr ----------
|
| 421 |
+
|
| 422 |
+
def _normalize_existing_cover(value: Any, schema: dict) -> Optional[int]:
|
| 423 |
+
if isinstance(value, bool):
|
| 424 |
+
# bool 'no' (False) → 0; 'yes' (True) is ambiguous (don't commit)
|
| 425 |
+
return 0 if value is False else None
|
| 426 |
+
if isinstance(value, (int, float)):
|
| 427 |
+
v = int(value)
|
| 428 |
+
if v < 0 or v > schema.get("max", 100_000_000):
|
| 429 |
+
return None
|
| 430 |
+
return v
|
| 431 |
+
if isinstance(value, str):
|
| 432 |
+
s = value.strip().lower()
|
| 433 |
+
if not s:
|
| 434 |
+
return None
|
| 435 |
+
# Negative answers → 0
|
| 436 |
+
if re.search(
|
| 437 |
+
r"\b(no|none|nothing|zero|nope|nah|haven'?t|don'?t|never|"
|
| 438 |
+
r"not\s+having|not\s+got|not\s+have|without\s+(?:any|a))\b",
|
| 439 |
+
s,
|
| 440 |
+
):
|
| 441 |
+
return 0
|
| 442 |
+
amt = _parse_inr_amount(s)
|
| 443 |
+
if amt is None:
|
| 444 |
+
# Bare digit fallback (very small values OK here — user might
|
| 445 |
+
# have ₹500 unused; still coerce within bounds).
|
| 446 |
+
digits = "".join(c for c in s if c.isdigit())
|
| 447 |
+
if not digits:
|
| 448 |
+
return None
|
| 449 |
+
try:
|
| 450 |
+
amt = int(digits[:8])
|
| 451 |
+
except ValueError:
|
| 452 |
+
return None
|
| 453 |
+
if amt < 0 or amt > schema.get("max", 100_000_000):
|
| 454 |
+
return None
|
| 455 |
+
return amt
|
| 456 |
+
return None
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
# ---------- primary_goal ----------
|
| 460 |
+
|
| 461 |
+
_GOAL_ALIASES: dict[str, str] = {
|
| 462 |
+
# WS1 spec ↔ canonical Profile values
|
| 463 |
+
"compare": "compare_specific",
|
| 464 |
+
"tax_savings": "tax_planning",
|
| 465 |
+
# accept the canonical values themselves
|
| 466 |
+
"first_buy": "first_buy",
|
| 467 |
+
"upgrade": "upgrade",
|
| 468 |
+
"compare_specific": "compare_specific",
|
| 469 |
+
"tax_planning": "tax_planning",
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def _normalize_primary_goal(value: Any, schema: dict) -> Optional[str]:
|
| 474 |
+
if not isinstance(value, str):
|
| 475 |
+
return None
|
| 476 |
+
s = value.strip().lower()
|
| 477 |
+
if not s:
|
| 478 |
+
return None
|
| 479 |
+
# Direct hit on alias or canonical
|
| 480 |
+
if s in _GOAL_ALIASES:
|
| 481 |
+
return _GOAL_ALIASES[s]
|
| 482 |
+
# Keyword fall-through
|
| 483 |
+
if any(k in s for k in ("first policy", "first one", "first time", "first buy", "new policy", "buying my first")):
|
| 484 |
+
return "first_buy"
|
| 485 |
+
if any(k in s for k in ("upgrade", "upgrading", "better cover", "more cover", "increase cover")):
|
| 486 |
+
return "upgrade"
|
| 487 |
+
if any(k in s for k in ("compare", "comparison", " vs ", " vs.", "versus")):
|
| 488 |
+
return "compare_specific"
|
| 489 |
+
if any(k in s for k in (" tax ", "80d", "deduction", "tax planning", "tax saving")):
|
| 490 |
+
return "tax_planning"
|
| 491 |
+
return None
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
# ---------- location_tier ----------
|
| 495 |
+
# WS1 spec city lists — case-insensitive substring match.
|
| 496 |
+
_METRO_CITIES = (
|
| 497 |
+
"mumbai", "delhi", "new delhi", "bangalore", "bengaluru", "chennai",
|
| 498 |
+
"kolkata", "hyderabad", "pune", "ahmedabad",
|
| 499 |
+
)
|
| 500 |
+
_TIER2_CITIES = (
|
| 501 |
+
"jaipur", "lucknow", "indore", "chandigarh", "kochi", "coimbatore",
|
| 502 |
+
"vadodara", "nagpur", "bhubaneshwar", "bhubaneswar", "visakhapatnam",
|
| 503 |
+
"surat",
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
def _normalize_location_tier(value: Any, schema: dict) -> Optional[str]:
|
| 508 |
+
if not isinstance(value, str):
|
| 509 |
+
return None
|
| 510 |
+
s = value.strip().lower()
|
| 511 |
+
if not s:
|
| 512 |
+
return None
|
| 513 |
+
# Direct enum hit
|
| 514 |
+
if s in schema["values"]:
|
| 515 |
+
return s
|
| 516 |
+
# Legacy tier1 → collapse to metro (WS1 spec dropped tier1).
|
| 517 |
+
if s == "tier1" or s == "tier 1":
|
| 518 |
+
return "metro"
|
| 519 |
+
if s == "tier 2":
|
| 520 |
+
return "tier2"
|
| 521 |
+
if s == "tier 3":
|
| 522 |
+
return "tier3"
|
| 523 |
+
# City-name substring match (case-insensitive).
|
| 524 |
+
for c in _METRO_CITIES:
|
| 525 |
+
if c in s:
|
| 526 |
+
return "metro"
|
| 527 |
+
for c in _TIER2_CITIES:
|
| 528 |
+
if c in s:
|
| 529 |
+
return "tier2"
|
| 530 |
+
# Explicit small-town / village wording → rural
|
| 531 |
+
if any(k in s for k in ("village", "rural", "gaon")):
|
| 532 |
+
return "rural"
|
| 533 |
+
if any(k in s for k in ("small town", "tier 3", "tier3", "small city")):
|
| 534 |
+
return "tier3"
|
| 535 |
+
# Otherwise default to tier3 only if the string looks like a real
|
| 536 |
+
# place name (alpha-heavy, short). This avoids stamping junk strings
|
| 537 |
+
# like "n/a" or "?" as tier3.
|
| 538 |
+
alpha = sum(1 for c in s if c.isalpha())
|
| 539 |
+
if alpha >= 3 and alpha / max(1, len(s)) >= 0.7:
|
| 540 |
+
return "tier3"
|
| 541 |
+
return None
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
# ---------- budget_band ----------
|
| 545 |
+
|
| 546 |
+
def _normalize_budget_band(value: Any, schema: dict) -> Optional[str]:
|
| 547 |
+
if isinstance(value, str):
|
| 548 |
+
s = value.strip()
|
| 549 |
+
if s in schema["values"]:
|
| 550 |
+
return s
|
| 551 |
+
parsed = _parse_budget_band(s)
|
| 552 |
+
if parsed in schema["values"]:
|
| 553 |
+
return parsed
|
| 554 |
+
return None
|
| 555 |
+
if isinstance(value, (int, float)):
|
| 556 |
+
amt = int(value)
|
| 557 |
+
if amt < 15_000:
|
| 558 |
+
return "under_15k"
|
| 559 |
+
if amt < 30_000:
|
| 560 |
+
return "15k_30k"
|
| 561 |
+
if amt < 60_000:
|
| 562 |
+
return "30k_60k"
|
| 563 |
+
return "60k+"
|
| 564 |
+
return None
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
# ---------- health_conditions ----------
|
| 568 |
+
|
| 569 |
+
_NO_PED_PATTERNS = (
|
| 570 |
+
r"\b(?:no|none|nothing|nope|nah|negative)\b",
|
| 571 |
+
r"\b(?:not|don'?t|do\s+not|haven'?t|isn'?t)\s+(?:got|have|having|had|got\s+any|have\s+any)\b",
|
| 572 |
+
r"\b(?:zero|nil)\s+(?:pre[-\s]?exist\w*|condition|chronic|health\s+issue|illness)",
|
| 573 |
+
r"\b(?:i\s+am|i'm)\s+(?:healthy|fine|fit|alright|all\s+good|good)\b",
|
| 574 |
+
r"\bnothing\s+(?:chronic|major|serious|to\s+report|like\s+that)\b",
|
| 575 |
+
r"\ball\s+(?:good|fine|clear|healthy)\b",
|
| 576 |
+
r"\bclean\s+bill\s+of\s+health\b",
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
_COND_KEYWORDS: dict[str, tuple] = {
|
| 580 |
+
"diabetes": ("diabetes", "diabetic", "sugar"),
|
| 581 |
+
"hypertension": ("hypertension", "blood pressure", "high bp"),
|
| 582 |
+
"thyroid": ("thyroid", "hypothyroid", "hyperthyroid"),
|
| 583 |
+
"asthma": ("asthma",),
|
| 584 |
+
"heart": ("heart problem", "heart disease", "cardiac"),
|
| 585 |
+
"cancer": ("cancer", "tumor", "tumour"),
|
| 586 |
+
}
|
| 587 |
+
# "bp" as a free-standing token (case-insensitive word-boundary). Kept
|
| 588 |
+
# separate from `_COND_KEYWORDS` so it doesn't collide with substrings
|
| 589 |
+
# like "BPM" or "abp" while still catching "BP" / "high BP" / "diabetes and BP".
|
| 590 |
+
_BP_REGEX = re.compile(r"\bbp\b", re.IGNORECASE)
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
def _normalize_health_conditions(value: Any) -> Optional[list]:
|
| 594 |
+
"""Return a deduped lowercase list of canonical condition names.
|
| 595 |
+
|
| 596 |
+
- [] means "no conditions" — VALID capture.
|
| 597 |
+
- None means "could not parse" — caller drops the field.
|
| 598 |
+
"""
|
| 599 |
+
# Direct list passthrough (LLM did its job)
|
| 600 |
+
if isinstance(value, list):
|
| 601 |
+
cleaned: list[str] = []
|
| 602 |
+
seen: set[str] = set()
|
| 603 |
+
for item in value:
|
| 604 |
+
if not isinstance(item, (str, int)):
|
| 605 |
+
continue
|
| 606 |
+
s = str(item).strip().lower()
|
| 607 |
+
if not s:
|
| 608 |
+
continue
|
| 609 |
+
if s in seen:
|
| 610 |
+
continue
|
| 611 |
+
seen.add(s)
|
| 612 |
+
# Map keyword hits to canonical names
|
| 613 |
+
mapped = None
|
| 614 |
+
for canon, kws in _COND_KEYWORDS.items():
|
| 615 |
+
if s == canon or any(k.strip() == s for k in kws):
|
| 616 |
+
mapped = canon
|
| 617 |
+
break
|
| 618 |
+
cleaned.append(mapped if mapped else s)
|
| 619 |
+
# Dedup again after canonicalisation
|
| 620 |
+
return list(dict.fromkeys(cleaned))
|
| 621 |
+
# String → parse out
|
| 622 |
+
if isinstance(value, str):
|
| 623 |
+
s = value.strip().lower()
|
| 624 |
+
if not s:
|
| 625 |
+
return None
|
| 626 |
+
# Explicit "no PED" phrasings → empty list
|
| 627 |
+
if any(re.search(p, s) for p in _NO_PED_PATTERNS):
|
| 628 |
+
# belt-and-braces — if a real condition keyword is ALSO present,
|
| 629 |
+
# fall through to capture it.
|
| 630 |
+
has_condition = any(
|
| 631 |
+
any(k in s for k in kws) for kws in _COND_KEYWORDS.values()
|
| 632 |
+
)
|
| 633 |
+
if not has_condition:
|
| 634 |
+
return []
|
| 635 |
+
canonical: list[str] = []
|
| 636 |
+
for canon, kws in _COND_KEYWORDS.items():
|
| 637 |
+
if any(k in s for k in kws):
|
| 638 |
+
canonical.append(canon)
|
| 639 |
+
# Word-boundary BP catch (independent of substring keywords above).
|
| 640 |
+
if "hypertension" not in canonical and _BP_REGEX.search(s):
|
| 641 |
+
canonical.append("hypertension")
|
| 642 |
+
if canonical:
|
| 643 |
+
return canonical
|
| 644 |
+
return None
|
| 645 |
+
return None
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
# ----------------------------------------------------------------------------
|
| 649 |
+
# Generic validator — kept for callers that want a single dispatch.
|
| 650 |
+
# ----------------------------------------------------------------------------
|
| 651 |
+
|
| 652 |
+
def _validate(value: Any, schema: dict) -> Any:
|
| 653 |
+
"""Type + enum + bounds check. Returns None on failure.
|
| 654 |
+
|
| 655 |
+
Adapted from `fact_find_normalizer._validate` (line 420).
|
| 656 |
+
"""
|
| 657 |
+
t = schema.get("type")
|
| 658 |
+
if t == "enum":
|
| 659 |
+
if isinstance(value, str) and value in schema["values"]:
|
| 660 |
+
return value
|
| 661 |
+
return None
|
| 662 |
+
if t == "int":
|
| 663 |
+
if isinstance(value, bool):
|
| 664 |
+
return None
|
| 665 |
+
try:
|
| 666 |
+
v = int(value)
|
| 667 |
+
except (TypeError, ValueError):
|
| 668 |
+
return None
|
| 669 |
+
if v < schema.get("min", -1_000_000_000) or v > schema.get("max", 1_000_000_000):
|
| 670 |
+
return None
|
| 671 |
+
return v
|
| 672 |
+
if t == "list":
|
| 673 |
+
if not isinstance(value, list):
|
| 674 |
+
return None
|
| 675 |
+
cleaned = [str(x).strip().lower() for x in value if x and isinstance(x, (str, int))]
|
| 676 |
+
cleaned = [c for c in cleaned if c]
|
| 677 |
+
return cleaned
|
| 678 |
+
if t == "bool":
|
| 679 |
+
if isinstance(value, bool):
|
| 680 |
+
return value
|
| 681 |
+
return None
|
| 682 |
+
if t == "name":
|
| 683 |
+
return _normalize_name(value)
|
| 684 |
+
return value
|
| 685 |
+
|
| 686 |
+
|
| 687 |
+
__all__ = ["normalize_captures"]
|
|
@@ -31,6 +31,10 @@ python tests/live_verify.py
|
|
| 31 |
TARGET_URL=http://localhost:8000 python tests/live_verify.py
|
| 32 |
```
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
## Related
|
| 35 |
|
| 36 |
- Root `CLAUDE.md` § Routing invariants — the four lines this folder protects
|
|
|
|
| 31 |
TARGET_URL=http://localhost:8000 python tests/live_verify.py
|
| 32 |
```
|
| 33 |
|
| 34 |
+
## Retired tests (KI-167, 2026-05-15)
|
| 35 |
+
|
| 36 |
+
- `test_fact_find_loop_break.py` — retired because the loop-break + `_canonical_fallback` architecture was replaced by the LLM-driven `backend/sales_brain.py`. The test pinned internals (`_canonical_fallback`, `_MAX_FAILED_ATTEMPTS`, `FIELD_TO_QUESTION_ID`) that no longer exist after the `fact_find_brain.py` rip-out. The equivalent behavioural invariant (bot doesn't re-ask answered slots) is now covered end-to-end by the gold-QA accuracy eval and the multi-persona stress audit.
|
| 37 |
+
|
| 38 |
## Related
|
| 39 |
|
| 40 |
- Root `CLAUDE.md` § Routing invariants — the four lines this folder protects
|
|
@@ -1,191 +0,0 @@
|
|
| 1 |
-
"""Regression tests for the fact-find canonical-fallback loop bug (KI-103).
|
| 2 |
-
|
| 3 |
-
Pre-fix bug
|
| 4 |
-
-----------
|
| 5 |
-
Live 15-persona smoke test on 2026-05-15 caught the brain repeating the
|
| 6 |
-
SAME slot question for 4-8 consecutive turns even though the user explicitly
|
| 7 |
-
answered on the first turn. Concrete evidence:
|
| 8 |
-
A3 (Priya): age 42 stated T2, bot asked "First, your age?" 7 times
|
| 9 |
-
A5 (Sanjay): employer-cover answered T4, bot asked the same Q 4 times
|
| 10 |
-
B3 (Mehul): age 45 stated T2, bot asked age 8 times despite the answer
|
| 11 |
-
|
| 12 |
-
Root cause
|
| 13 |
-
----------
|
| 14 |
-
When the LLM brain returned a reply WITHOUT the `<FF>{...}</FF>` JSON tail
|
| 15 |
-
(reason=`no_trailer`), `_canonical_fallback` ran. Its greedy multi-slot
|
| 16 |
-
capture only appends to `profile.asked` when a value is successfully
|
| 17 |
-
captured. When greedy fails (user_text doesn't match any slot's strict
|
| 18 |
-
regex), `profile.asked` stays unchanged, so `next_question(profile)`
|
| 19 |
-
returns the SAME slot every turn. Loop.
|
| 20 |
-
|
| 21 |
-
Fix
|
| 22 |
-
---
|
| 23 |
-
`_canonical_fallback` now tracks per-slot consecutive failed-fallback
|
| 24 |
-
surfaces on the SessionState (`_ff_failed_attempts`). After
|
| 25 |
-
`_MAX_FAILED_ATTEMPTS` (=2) consecutive surfaces of slot S with no
|
| 26 |
-
capture for S, the slot is marked asked + appended to
|
| 27 |
-
`_ff_skipped_slots` and the fallback advances to the next unfilled slot.
|
| 28 |
-
|
| 29 |
-
Run:
|
| 30 |
-
cd /Users/rohitsar/Developer/Insurance\\ Sales\\ Bot
|
| 31 |
-
PYTHONPATH=$PWD .venv/bin/python -m pytest tests/test_fact_find_loop_break.py -v
|
| 32 |
-
"""
|
| 33 |
-
|
| 34 |
-
from __future__ import annotations
|
| 35 |
-
|
| 36 |
-
import unittest
|
| 37 |
-
import uuid
|
| 38 |
-
from dataclasses import dataclass, field
|
| 39 |
-
from typing import Optional
|
| 40 |
-
|
| 41 |
-
# Bootstrap import path so this file runs from either pytest or unittest.
|
| 42 |
-
import sys
|
| 43 |
-
from pathlib import Path
|
| 44 |
-
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 45 |
-
if str(_REPO_ROOT) not in sys.path:
|
| 46 |
-
sys.path.insert(0, str(_REPO_ROOT))
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def _fresh_session_id() -> str:
|
| 50 |
-
return f"test_ff_loop_break_{uuid.uuid4().hex[:10]}"
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
def _cleanup_session_file(session_id: str) -> None:
|
| 54 |
-
target = _REPO_ROOT / "40-data" / "sessions" / f"{session_id}.json"
|
| 55 |
-
if target.exists():
|
| 56 |
-
try:
|
| 57 |
-
target.unlink()
|
| 58 |
-
except OSError:
|
| 59 |
-
pass
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
class TestCanonicalFallbackBreaksLoop(unittest.TestCase):
|
| 63 |
-
"""KI-103 — three consecutive `_canonical_fallback` calls with user
|
| 64 |
-
text that captures nothing must NOT return the same slot on call 3
|
| 65 |
-
as on call 1. The session-level failure counter must advance past
|
| 66 |
-
the stuck slot after two failed surfaces.
|
| 67 |
-
"""
|
| 68 |
-
|
| 69 |
-
def setUp(self) -> None:
|
| 70 |
-
from backend.session_state import _sessions, get_session
|
| 71 |
-
_sessions.clear()
|
| 72 |
-
self.session_id = _fresh_session_id()
|
| 73 |
-
# Build a fresh session with name + age unset (everything unset
|
| 74 |
-
# actually). No awaiting_question_id, no asked slots.
|
| 75 |
-
self.session = get_session(self.session_id)
|
| 76 |
-
|
| 77 |
-
def tearDown(self) -> None:
|
| 78 |
-
_cleanup_session_file(self.session_id)
|
| 79 |
-
|
| 80 |
-
def test_three_failed_attempts_advance_slot(self) -> None:
|
| 81 |
-
"""Drive `_canonical_fallback` 3 times with non-extracting input.
|
| 82 |
-
Assert the slot driven on call 3 differs from call 1 — the
|
| 83 |
-
loop-breaker has skipped the stuck slot and advanced.
|
| 84 |
-
"""
|
| 85 |
-
from backend.fact_find_brain import _canonical_fallback
|
| 86 |
-
|
| 87 |
-
# user_text that doesn't match ANY slot's strict capture regex:
|
| 88 |
-
# - "name" strict mode needs intro phrase ("I'm X", "my name is X")
|
| 89 |
-
# - "age" needs age trigger or bare number
|
| 90 |
-
# - dependents / income_band / etc. need family / amount / etc. keywords
|
| 91 |
-
non_extracting_text = "hello there how are you"
|
| 92 |
-
|
| 93 |
-
# Call 1
|
| 94 |
-
out1 = _canonical_fallback(
|
| 95 |
-
self.session, non_extracting_text, reason="no_trailer",
|
| 96 |
-
)
|
| 97 |
-
# Call 2
|
| 98 |
-
out2 = _canonical_fallback(
|
| 99 |
-
self.session, non_extracting_text, reason="no_trailer",
|
| 100 |
-
)
|
| 101 |
-
# Call 3
|
| 102 |
-
out3 = _canonical_fallback(
|
| 103 |
-
self.session, non_extracting_text, reason="no_trailer",
|
| 104 |
-
)
|
| 105 |
-
|
| 106 |
-
# Sanity: all three are ambiguous canonical fallbacks.
|
| 107 |
-
self.assertTrue(out1.ambiguous, "Call 1 must be a canonical fallback")
|
| 108 |
-
self.assertTrue(out2.ambiguous, "Call 2 must be a canonical fallback")
|
| 109 |
-
self.assertTrue(out3.ambiguous, "Call 3 must be a canonical fallback")
|
| 110 |
-
|
| 111 |
-
# Headline assertion: by call 3 the loop-breaker has advanced past
|
| 112 |
-
# the stuck slot — the slot returned on call 3 MUST differ from call 1.
|
| 113 |
-
self.assertIsNotNone(out1.slot_driving, "Call 1 must surface a slot")
|
| 114 |
-
self.assertIsNotNone(out3.slot_driving, "Call 3 must surface a slot")
|
| 115 |
-
self.assertNotEqual(
|
| 116 |
-
out3.slot_driving, out1.slot_driving,
|
| 117 |
-
"REGRESSION (KI-103): canonical fallback re-asked the same slot "
|
| 118 |
-
f"on call 3 ({out3.slot_driving!r}) as on call 1 "
|
| 119 |
-
f"({out1.slot_driving!r}) — loop-breaker is broken. After "
|
| 120 |
-
"2 failed attempts the stuck slot must be marked asked and the "
|
| 121 |
-
"fallback must advance to the next unfilled slot.",
|
| 122 |
-
)
|
| 123 |
-
|
| 124 |
-
# The stuck slot from call 1 must now be in _ff_skipped_slots
|
| 125 |
-
# so the orchestrator/scorecard knows it's intentionally unanswered.
|
| 126 |
-
# `slot_driving` on the outcome is the FIELD name, not the question
|
| 127 |
-
# id; map back to the question id for the assertion.
|
| 128 |
-
from backend.fact_find_brain import FIELD_TO_QUESTION_ID
|
| 129 |
-
stuck_field = out1.slot_driving
|
| 130 |
-
stuck_qid = FIELD_TO_QUESTION_ID.get(stuck_field, stuck_field)
|
| 131 |
-
skipped = getattr(self.session, "_ff_skipped_slots", [])
|
| 132 |
-
self.assertIn(
|
| 133 |
-
stuck_qid, skipped,
|
| 134 |
-
f"REGRESSION (KI-103): stuck slot {stuck_qid!r} (field "
|
| 135 |
-
f"{stuck_field!r}) must be recorded on session._ff_skipped_slots "
|
| 136 |
-
f"so the scorecard knows it was intentionally skipped after "
|
| 137 |
-
f"hitting the failed-attempt threshold. Got skipped={skipped!r}.",
|
| 138 |
-
)
|
| 139 |
-
|
| 140 |
-
def test_successful_capture_resets_counter(self) -> None:
|
| 141 |
-
"""A successful greedy capture for a slot must reset its failure
|
| 142 |
-
counter — so a temporary LLM outage that recovers doesn't
|
| 143 |
-
permanently ghost that slot for the session.
|
| 144 |
-
"""
|
| 145 |
-
from backend.fact_find_brain import _canonical_fallback
|
| 146 |
-
|
| 147 |
-
# Call 1: non-extracting input → name surfaces, counter[name]=1.
|
| 148 |
-
_canonical_fallback(self.session, "hello there", reason="no_trailer")
|
| 149 |
-
attempts = getattr(self.session, "_ff_failed_attempts", {})
|
| 150 |
-
# Note: the surfaced slot id is determined by GREEDY_ORDER + GRAPH
|
| 151 |
-
# iteration; we don't hard-code it — just confirm a counter advanced.
|
| 152 |
-
self.assertTrue(
|
| 153 |
-
any(v >= 1 for v in attempts.values()),
|
| 154 |
-
"Failure counter must record the first surface as a failed attempt.",
|
| 155 |
-
)
|
| 156 |
-
|
| 157 |
-
# Call 2: user supplies a clear name in lenient form. The strict
|
| 158 |
-
# name parser requires an intro phrase, so use that explicitly.
|
| 159 |
-
out2 = _canonical_fallback(
|
| 160 |
-
self.session, "my name is Rohit", reason="no_trailer",
|
| 161 |
-
)
|
| 162 |
-
# Name should now be captured.
|
| 163 |
-
self.assertEqual(
|
| 164 |
-
self.session.profile.name, "Rohit",
|
| 165 |
-
"Greedy capture in canonical fallback should have extracted "
|
| 166 |
-
"the name from 'my name is Rohit'.",
|
| 167 |
-
)
|
| 168 |
-
# The name slot's counter must be reset (popped) after capture.
|
| 169 |
-
attempts_after = getattr(self.session, "_ff_failed_attempts", {})
|
| 170 |
-
self.assertNotIn(
|
| 171 |
-
"name", attempts_after,
|
| 172 |
-
"REGRESSION (KI-103): successful name capture must reset/pop "
|
| 173 |
-
"the failed-attempt counter for 'name' so the slot can be "
|
| 174 |
-
"re-introduced cleanly if the brain recovers.",
|
| 175 |
-
)
|
| 176 |
-
|
| 177 |
-
def test_failed_attempt_threshold_constant(self) -> None:
|
| 178 |
-
"""Pin the threshold constant. If anyone tunes it back to 5+ they
|
| 179 |
-
have to update this test (and re-prove the loop is still broken).
|
| 180 |
-
"""
|
| 181 |
-
from backend.fact_find_brain import _MAX_FAILED_ATTEMPTS
|
| 182 |
-
self.assertEqual(
|
| 183 |
-
_MAX_FAILED_ATTEMPTS, 2,
|
| 184 |
-
"KI-103 threshold is 2 (skip on the 3rd surface). Bumping this "
|
| 185 |
-
"above 2 re-enables the live-smoke-test loop bug where users "
|
| 186 |
-
"saw the same slot question 4-8 times in a row.",
|
| 187 |
-
)
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
if __name__ == "__main__":
|
| 191 |
-
unittest.main(verbosity=2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -182,19 +182,25 @@ class TestCaseA_ExtractorNullCannotWipeName(unittest.TestCase):
|
|
| 182 |
|
| 183 |
|
| 184 |
# ---------------------------------------------------------------------------
|
| 185 |
-
# CASE B —
|
| 186 |
# ---------------------------------------------------------------------------
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
"""
|
| 199 |
|
| 200 |
def setUp(self) -> None:
|
|
@@ -205,58 +211,61 @@ class TestCaseB_FactFindBranchDoesNotInvokeExtractor(unittest.TestCase):
|
|
| 205 |
def tearDown(self) -> None:
|
| 206 |
_cleanup_session_file(self.session_id)
|
| 207 |
|
| 208 |
-
def
|
| 209 |
from backend.session_state import get_session
|
| 210 |
from backend import orchestrator as orch
|
| 211 |
-
from backend.
|
| 212 |
|
| 213 |
-
# Seed a session that's in
|
| 214 |
session = get_session(self.session_id)
|
| 215 |
session.profile.name = "Rohit"
|
| 216 |
session.profile.age = 30
|
| 217 |
session.profile.asked = ["name", "age"]
|
| 218 |
session.free_form_session = False
|
| 219 |
-
session.awaiting_question_id = "dependents" # mid-
|
| 220 |
session._flush()
|
| 221 |
|
| 222 |
# Mock extractor: if called, fail loudly.
|
| 223 |
async def _explode(user_text, current_profile):
|
| 224 |
raise AssertionError(
|
| 225 |
-
"extract_profile_updates was called on a
|
| 226 |
-
"
|
| 227 |
-
"
|
| 228 |
-
"is both wasteful AND the source of the name-wipe bug."
|
| 229 |
)
|
| 230 |
|
| 231 |
-
# Mock the
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
# The orchestrator wraps the extractor call in try/except, swallows
|
| 242 |
-
# AssertionError, and just logs a warning — meaning a broken
|
| 243 |
-
#
|
| 244 |
# this properly, we monitor the patched extractor with a Mock and
|
| 245 |
# assert call_count == 0 instead of relying on the raise reaching us.
|
| 246 |
extractor_spy = mock.AsyncMock(side_effect=_explode)
|
| 247 |
|
| 248 |
with mock.patch("backend.profile_extractor.extract_profile_updates",
|
| 249 |
new=extractor_spy), \
|
| 250 |
-
mock.patch("backend.
|
| 251 |
-
new=
|
| 252 |
mock.patch("backend.profile_rag.upsert_profile_chunk",
|
| 253 |
new=_stub_upsert), \
|
| 254 |
mock.patch("backend.profile_store.save_profile", new=mock.Mock()):
|
| 255 |
|
| 256 |
-
# Drive a turn — the user's message is a
|
| 257 |
# in_fact_find_continuation is True (awaiting_question_id set,
|
| 258 |
# not free-form), so the orchestrator should route to the
|
| 259 |
-
#
|
| 260 |
result = asyncio.run(orch.handle_turn(
|
| 261 |
user_text="self + spouse",
|
| 262 |
chat_history=[],
|
|
@@ -267,18 +276,17 @@ class TestCaseB_FactFindBranchDoesNotInvokeExtractor(unittest.TestCase):
|
|
| 267 |
# The headline assertion: extractor was never invoked.
|
| 268 |
self.assertEqual(
|
| 269 |
extractor_spy.call_count, 0,
|
| 270 |
-
"REGRESSION (KI-091): extract_profile_updates was called
|
| 271 |
-
"
|
| 272 |
-
"
|
| 273 |
-
"
|
| 274 |
% extractor_spy.call_count,
|
| 275 |
)
|
| 276 |
|
| 277 |
-
# Sanity: the
|
| 278 |
-
self.assertEqual(result.intent, "fact_find")
|
| 279 |
self.assertEqual(
|
| 280 |
result.reply_text, "And who else do you want covered?",
|
| 281 |
-
"
|
| 282 |
)
|
| 283 |
|
| 284 |
# And the captured name is still there.
|
|
|
|
| 182 |
|
| 183 |
|
| 184 |
# ---------------------------------------------------------------------------
|
| 185 |
+
# CASE B — sales-brain branch does not invoke extract_profile_updates.
|
| 186 |
# ---------------------------------------------------------------------------
|
| 187 |
+
# KI-167 (2026-05-15) — rewired to mock `backend.sales_brain.drive_sales_brain`
|
| 188 |
+
# returning a `SalesBrainResult` instead of the retired
|
| 189 |
+
# `backend.fact_find_brain.drive_fact_find` / `FactFindOutcome`. Same
|
| 190 |
+
# architectural invariant: a sales-brain (fact-find) turn must NOT trigger
|
| 191 |
+
# the heavy extractor LLM, because the sales brain captures slots natively
|
| 192 |
+
# from its structured JSON output.
|
| 193 |
+
|
| 194 |
+
class TestCaseB_SalesBrainBranchDoesNotInvokeExtractor(unittest.TestCase):
|
| 195 |
+
"""KI-091 regression (rewired under KI-167). On a sales-brain turn the
|
| 196 |
+
orchestrator MUST NOT call extract_profile_updates — the sales brain
|
| 197 |
+
captures slots natively in its JSON reply (`captures` field), and the
|
| 198 |
+
heavy extractor LLM both (a) duplicates work and (b) periodically returns
|
| 199 |
+
nulls that wipe filled slots.
|
| 200 |
+
|
| 201 |
+
We pin this by patching `extract_profile_updates` with a spy. If the
|
| 202 |
+
orchestrator wrongly calls it on a sales-brain turn, call_count > 0 and
|
| 203 |
+
the test fails. If the gate is in place, the call never happens.
|
| 204 |
"""
|
| 205 |
|
| 206 |
def setUp(self) -> None:
|
|
|
|
| 211 |
def tearDown(self) -> None:
|
| 212 |
_cleanup_session_file(self.session_id)
|
| 213 |
|
| 214 |
+
def test_sales_brain_turn_does_not_call_extractor(self) -> None:
|
| 215 |
from backend.session_state import get_session
|
| 216 |
from backend import orchestrator as orch
|
| 217 |
+
from backend.sales_brain import SalesBrainResult
|
| 218 |
|
| 219 |
+
# Seed a session that's in sales-brain continuation (awaiting an answer).
|
| 220 |
session = get_session(self.session_id)
|
| 221 |
session.profile.name = "Rohit"
|
| 222 |
session.profile.age = 30
|
| 223 |
session.profile.asked = ["name", "age"]
|
| 224 |
session.free_form_session = False
|
| 225 |
+
session.awaiting_question_id = "dependents" # mid-sales-brain
|
| 226 |
session._flush()
|
| 227 |
|
| 228 |
# Mock extractor: if called, fail loudly.
|
| 229 |
async def _explode(user_text, current_profile):
|
| 230 |
raise AssertionError(
|
| 231 |
+
"extract_profile_updates was called on a sales-brain turn — "
|
| 232 |
+
"the gate is broken. The sales brain already captures slots "
|
| 233 |
+
"natively from its structured JSON reply; the heavy extractor "
|
| 234 |
+
"LLM here is both wasteful AND the source of the name-wipe bug."
|
| 235 |
)
|
| 236 |
|
| 237 |
+
# Mock the sales brain to return a benign SalesBrainResult.
|
| 238 |
+
mock_result = SalesBrainResult(
|
| 239 |
+
reply_text="And who else do you want covered?",
|
| 240 |
+
captured_updates={},
|
| 241 |
+
ready_for_recommendations=False,
|
| 242 |
+
brain_used="sales_brain::nim:test-model",
|
| 243 |
+
raw_json={
|
| 244 |
+
"reply": "And who else do you want covered?",
|
| 245 |
+
"captures": {},
|
| 246 |
+
"ready_for_recommendations": False,
|
| 247 |
+
},
|
| 248 |
+
)
|
| 249 |
|
| 250 |
# The orchestrator wraps the extractor call in try/except, swallows
|
| 251 |
+
# AssertionError, and just logs a warning — meaning a broken gate
|
| 252 |
+
# would silently regress without our raise propagating. To pin
|
| 253 |
# this properly, we monitor the patched extractor with a Mock and
|
| 254 |
# assert call_count == 0 instead of relying on the raise reaching us.
|
| 255 |
extractor_spy = mock.AsyncMock(side_effect=_explode)
|
| 256 |
|
| 257 |
with mock.patch("backend.profile_extractor.extract_profile_updates",
|
| 258 |
new=extractor_spy), \
|
| 259 |
+
mock.patch("backend.sales_brain.drive_sales_brain",
|
| 260 |
+
new=mock.AsyncMock(return_value=mock_result)), \
|
| 261 |
mock.patch("backend.profile_rag.upsert_profile_chunk",
|
| 262 |
new=_stub_upsert), \
|
| 263 |
mock.patch("backend.profile_store.save_profile", new=mock.Mock()):
|
| 264 |
|
| 265 |
+
# Drive a turn — the user's message is a sales-brain answer.
|
| 266 |
# in_fact_find_continuation is True (awaiting_question_id set,
|
| 267 |
# not free-form), so the orchestrator should route to the
|
| 268 |
+
# sales brain and return BEFORE reaching the extractor.
|
| 269 |
result = asyncio.run(orch.handle_turn(
|
| 270 |
user_text="self + spouse",
|
| 271 |
chat_history=[],
|
|
|
|
| 276 |
# The headline assertion: extractor was never invoked.
|
| 277 |
self.assertEqual(
|
| 278 |
extractor_spy.call_count, 0,
|
| 279 |
+
"REGRESSION (KI-091/KI-167): extract_profile_updates was called "
|
| 280 |
+
"on a sales-brain turn (call_count=%d). The gate in "
|
| 281 |
+
"backend/orchestrator.py is broken — sales-brain turns must skip "
|
| 282 |
+
"the heavy extractor LLM."
|
| 283 |
% extractor_spy.call_count,
|
| 284 |
)
|
| 285 |
|
| 286 |
+
# Sanity: the sales-brain branch did run (we got its reply back).
|
|
|
|
| 287 |
self.assertEqual(
|
| 288 |
result.reply_text, "And who else do you want covered?",
|
| 289 |
+
"Sales-brain branch did not run — test setup is wrong.",
|
| 290 |
)
|
| 291 |
|
| 292 |
# And the captured name is still there.
|