rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
f7df227
·
1 Parent(s): 0a02616

refactor(brain): KI-167 — rip out fact_find_brain, replace with sales_brain

Browse files

Replaces 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 DELETED
@@ -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
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/fact_find_normalizer.py DELETED
@@ -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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/main.py CHANGED
@@ -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
- nq = next_question(p)
1164
- if nq:
1165
- hint = nq.prompt_en
 
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(
backend/needs_finder.py CHANGED
@@ -1,24 +1,22 @@
1
- """Adaptive fact-find question graph.
2
 
3
- Inspired by what a good Independent Financial Advisor does in the first 10
4
- minutes of a consultation: ask a stable core of questions, then deep-dive
5
- conditionally based on signal. The depth is adaptive — stop when we have
6
- enough to recommend.
7
 
8
- The graph is explicit (not LLM-improvised) so:
9
- - A reviewer can see and audit it
10
- - Behavior is testable
11
- - Failure modes are tractable
12
- - It works without an LLM (fallback when the brain is degraded)
 
 
13
 
14
  Public API:
15
- - Profile dataclass — accumulated user state
16
- - next_question(profile) -> str | None (None = ready to recommend)
17
- - record_answer(profile, question_id, 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, language: str = "en") -> Optional[Question]:
323
- """Return the next question to ask, or None if we have enough to recommend.
 
 
 
 
 
 
324
 
325
- KI-070 (2026-05-15) the orchestrator no longer drives fact-find via this
326
- function; the new `backend/fact_find_brain.py` single-LLM-call brain
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 q in GRAPH:
336
- if q.id in profile.asked:
337
- continue
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
- def record_answer(profile: Profile, question_id: str, raw_answer: str) -> Profile:
349
- """Mutate profile in place with a parsed answer."""
350
- q = next((x for x in GRAPH if x.id == question_id), None)
351
- if q is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  return profile
353
  value: Any = raw_answer
354
- if q.parser:
 
355
  try:
356
- value = q.parser(raw_answer)
357
  except Exception:
358
  value = None
359
  if value is not None and value != "":
360
- setattr(profile, q.field, value)
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.append(question_id)
 
364
  return profile
365
 
366
 
367
- # ----------------------------------------------------------------------------
368
- # Opportunistic family/dependents extractor KI-056 (2026-05-15)
369
- # ----------------------------------------------------------------------------
370
- # Real-user testing surfaced: when the user mentions a spouse / kids / parents
371
- # while answering an UNRELATED slot ("my wife also doesn't have anything" in
372
- # response to existing_cover), the bot just acknowledges and moves on without
373
- # capturing the family signal. By the time we reach the dependents slot the
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
- KI-068 (2026-05-15) converts schema-level enum values
466
- ("first_buy", "30k_60k", "metro") into spoken labels
467
- ("first health policy", "₹30,000–60,000/year", "metro city") so the
468
- readback reads like a sentence, not a JSON dump.
469
- """
470
- bits = []
471
- if profile.age:
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
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/orchestrator.py CHANGED
@@ -553,72 +553,57 @@ async def handle_turn(
553
  )
554
 
555
  if treat_as_fact_find:
556
- # KI-070 (2026-05-15) — single-LLM-call fact-find brain replaces the
557
- # 3-layer stitching of (hardcoded canonical prompts + per-turn
558
- # paraphraser + acknowledger rotation). One model call now produces
559
- # the natural reply AND a machine-readable trailer describing what
560
- # was captured, which slot it's driving toward, and whether
561
- # fact-find is complete.
562
- from backend.fact_find_brain import (
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
- outcome = await asyncio.wait_for(
575
- drive_fact_find(
576
  user_text=user_text,
577
- session=session,
578
- chat_history=chat_history,
579
  session_id=session_id,
580
  ),
581
  timeout=25.0,
582
  )
583
  except asyncio.TimeoutError:
584
- logging.warning(
585
- "fact_find_brain outer wait_for(25s) tripped — falling back to canonical (session=%s)",
586
- session_id,
587
- )
588
- from backend.fact_find_brain import _canonical_fallback
589
- outcome = _canonical_fallback(
590
- session=session,
591
- user_text=user_text,
592
- reason="outer_timeout_25s",
593
  )
594
 
595
- # Apply captured updates to profile + mark matching slot ids as asked
596
- # so the orchestrator's downstream `next_question` (used in the
597
- # canonical fallback + completeness API) doesn't double-ask.
 
598
  fact_find_profile_updates: dict = {}
599
- for field_name, value in outcome.captured_updates.items():
600
  if value in (None, "", []):
601
  continue
602
  session.update_profile_field(field_name, value)
603
  fact_find_profile_updates[field_name] = value
604
- matching_slot = FIELD_TO_QUESTION_ID.get(field_name)
605
- if matching_slot and matching_slot not in session.profile.asked:
606
- session.profile.asked.append(matching_slot)
607
-
608
- # Track slot_driving so subsequent turns know where we are. The new
609
- # brain produces FIELD names (e.g., "income_band"); translate to the
610
- # canonical question id (e.g., "income_band") via FIELD_TO_QUESTION_ID
611
- # so the rest of the orchestrator (which keys on question ids) stays
612
- # consistent.
613
- if outcome.slot_driving:
614
- slot_qid = FIELD_TO_QUESTION_ID.get(outcome.slot_driving, outcome.slot_driving)
615
- session.set_awaiting(slot_qid)
616
- else:
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
- if fact_find_profile_updates and session.profile.name:
 
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
- "fact_find_brain profile-chunk upsert failed (session=%s): %s: %s",
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 fact-find brain reply BEFORE returning to ChatResponse.
688
- # Live re-smoke caught Scenario S4 T3+T4 leaking raw model reasoning
689
- # ("We need to respond naturally...", "We need to process user
690
- # message...") into reply_text because the fact_find brain path was
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=ff_reply_clean,
700
  citations=[],
701
  retrieved_chunk_ids=[],
702
- brain_used=brain_tag,
703
  intent="fact_find",
704
  language=language,
705
  latency_ms=int((time.time() - t0) * 1000),
706
- raw_reply=outcome.reply_text,
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,
backend/sales_brain.py ADDED
@@ -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"]
backend/sales_brain_normalizer.py ADDED
@@ -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"]
tests/README.md CHANGED
@@ -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
tests/test_fact_find_loop_break.py DELETED
@@ -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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_name_persistence.py CHANGED
@@ -182,19 +182,25 @@ class TestCaseA_ExtractorNullCannotWipeName(unittest.TestCase):
182
 
183
 
184
  # ---------------------------------------------------------------------------
185
- # CASE B — fact-find branch does not invoke extract_profile_updates.
186
  # ---------------------------------------------------------------------------
187
-
188
- class TestCaseB_FactFindBranchDoesNotInvokeExtractor(unittest.TestCase):
189
- """KI-091 regression. On a fact-find turn the orchestrator MUST NOT call
190
- extract_profile_updates the fact_find brain extracts slots natively
191
- from its <FF> JSON tail, and the heavy extractor LLM both (a) duplicates
192
- work and (b) periodically returns nulls that wipe filled slots.
193
-
194
- We pin this by patching `extract_profile_updates` to raise. If the
195
- orchestrator wrongly calls it on a fact-find turn, the call raises and
196
- the test fails. If KI-091's gate is in place, the call never happens
197
- and the turn completes cleanly.
 
 
 
 
 
 
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 test_fact_find_turn_does_not_call_extractor(self) -> None:
209
  from backend.session_state import get_session
210
  from backend import orchestrator as orch
211
- from backend.fact_find_brain import FactFindOutcome
212
 
213
- # Seed a session that's in fact-find continuation (awaiting an answer).
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-fact-find
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 fact-find turn — "
226
- "KI-091 gate is broken. The fact_find brain already extracts "
227
- "slots from its <FF> trailer; the heavy extractor LLM here "
228
- "is both wasteful AND the source of the name-wipe bug."
229
  )
230
 
231
- # Mock the fact_find brain to return a benign outcome.
232
- async def _stub_drive(user_text, session, chat_history, session_id):
233
- return FactFindOutcome(
234
- reply_text="And who else do you want covered?",
235
- captured_updates={},
236
- slot_driving="dependents",
237
- fact_find_complete=False,
238
- ambiguous=False,
239
- )
 
 
 
240
 
241
  # The orchestrator wraps the extractor call in try/except, swallows
242
- # AssertionError, and just logs a warning — meaning a broken KI-091
243
- # gate would silently regress without our raise propagating. To pin
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.fact_find_brain.drive_fact_find",
251
- new=_stub_drive), \
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 fact-find answer.
257
  # in_fact_find_continuation is True (awaiting_question_id set,
258
  # not free-form), so the orchestrator should route to the
259
- # fact-find brain and return BEFORE reaching the extractor.
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 on a "
271
- "fact-find turn (call_count=%d). The intent='fact_find' / "
272
- "in_fact_find_continuation gate in backend/orchestrator.py is "
273
- "broken — fact-find turns must skip the heavy extractor LLM."
274
  % extractor_spy.call_count,
275
  )
276
 
277
- # Sanity: the fact-find branch did run (we got its reply back).
278
- self.assertEqual(result.intent, "fact_find")
279
  self.assertEqual(
280
  result.reply_text, "And who else do you want covered?",
281
- "Fact-find branch did not run — test setup is wrong.",
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.