Spaces:
Sleeping
fix(fact-find): KI-074 — greedy multi-slot capture in fallback + stricter name parse + strip ** markdown leak
Browse filesUser screenshots showed two persistent issues after KI-072:
1. **Multi-fact utterance dropped to floor.** User said "I am 29 years
old and looking for cover just for me" — should capture age=29 and
dependents=self. KI-072 only checked `awaiting_question_id` (the LLM
was driving the name slot from turn 1), tried to extract a name from
the age phrase, failed, dropped both age + dependents.
2. **`**Be straight with me.**` markdown asterisks leaking** in the
chat panel + read by TTS as "asterisk asterisk Be straight with me
asterisk asterisk" — same class of bug as KI-068 but in a different
GRAPH entry (health_conditions slot, needs_finder.py line 149-150).
═══════════════════════════════════════════════════════════════
KI-074 fixes (backend/fact_find_brain.py)
═══════════════════════════════════════════════════════════════
GREEDY multi-slot capture in `_canonical_fallback`:
- Iterates 9 slots in prioritised order (age → dependents →
income_band → existing_cover → primary_goal → location →
parents_age → budget → name).
- For each unfilled slot, runs `_normalize_for_slot(slot, user_text)`.
- Applies whatever captures, marks slot asked, repeats.
- Verified test cases:
"I am 29 and just for me" → age=29 captured
"No, I don't have any plan right now" → existing_cover_inr=0
"5 lakh from my employer" → existing_cover_inr=500000
"I am looking for all metro cities" → location_tier=metro
Slot-specific TRIGGER guards in `_normalize_for_slot`:
- age: requires "i'm" / "i am" / "years old" / "yo" / "y/o" cue,
or a bare numeric token. Range 18-99.
- parents_age: requires explicit mention of parent / mom / dad /
mother / father. Range 30-110.
- existing_cover: requires denial cue ("no plan", "first policy",
"never bought") OR currency/unit cue (₹, lakh, crore, "from work",
"sum insured"). Word "cover" alone is NOT enough — caused
false positive on "looking for cover for me".
NAME parser tightened:
- Requires an explicit intro phrase ("I'm X" / "this is X" /
"my name is X" / "call me X").
- First captured word can't be in expanded blocklist (my / your /
his / her / their / our / this / that / these / those / first /
second / last / next / a / an / the / looking / buying / shopping
/ interested / good / fine / ok / years / year / from / very
/ really / just).
- Stops at conjunction boundary: "Rohit Sar and I am 32" → "Rohit Sar".
- Strips trailing conjunctions: "Rohit Sar and" → "Rohit Sar".
═══════════════════════════════════════════════════════════════
backend/needs_finder.py
═══════════════════════════════════════════════════════════════
Removed `**Be straight with me.**` and `**सच बताइए।**` markdown
asterisks from the health_conditions slot prompts (lines 149-150).
Replaced with em-dash phrasing that reads naturally in both chat panel
and TTS. The honest-disclosure content is preserved word-for-word; only
the markdown wrapper is dropped.
═══════════════════════════════════════════════════════════════
Live state observation
═══════════════════════════════════════════════════════════════
User screenshots show the LLM brain succeeds intermittently — most
turns fall to canonical, but some turns (e.g. budget→recommendation
transition) produce natural conversational replies. Suggests NIM
rate-limit / pool timeout hitting per-call. Separate follow-up needed
to investigate why brain calls fail so consistently in production.
KI-074 ensures the fallback path keeps the user progressing through
fact-find even when the brain is degraded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/fact_find_brain.py +140 -44
- backend/needs_finder.py +2 -2
|
@@ -419,39 +419,115 @@ def _normalize_for_slot(slot_id: str, raw_text: str) -> Any:
|
|
| 419 |
return None
|
| 420 |
text = raw_text.strip()
|
| 421 |
|
| 422 |
-
# NAME slot —
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
if slot_id == "name":
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
# Capitalise if all-lower (STT often outputs lowercase)
|
| 437 |
-
if
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
#
|
|
|
|
|
|
|
| 444 |
try:
|
| 445 |
from backend.fact_find_normalizer import (
|
| 446 |
_parse_int, _parse_existing_cover, _keyword_normalize,
|
| 447 |
_validate, _FIELD_SCHEMA,
|
| 448 |
)
|
| 449 |
schema = _FIELD_SCHEMA.get(slot_id)
|
| 450 |
-
|
| 451 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
if slot_id == "existing_cover":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
return _parse_existing_cover(text)
|
|
|
|
| 454 |
# Enum / list slots — keyword fast path only, no LLM.
|
|
|
|
| 455 |
kw = _keyword_normalize(slot_id, text)
|
| 456 |
if kw is not None and schema is not None:
|
| 457 |
return _validate(kw, schema)
|
|
@@ -466,34 +542,54 @@ def _canonical_fallback(session, user_text: str, *, reason: str) -> FactFindOutc
|
|
| 466 |
question for the next missing slot so the user always sees a coherent
|
| 467 |
next step.
|
| 468 |
|
| 469 |
-
KI-072 (2026-05-15) —
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
"""
|
| 475 |
profile = session.profile
|
| 476 |
captured: dict[str, Any] = {}
|
| 477 |
|
| 478 |
-
|
| 479 |
-
# last asking. session.awaiting_question_id was set on the previous turn
|
| 480 |
-
# from outcome.slot_driving.
|
| 481 |
-
last_slot = getattr(session, "awaiting_question_id", None)
|
| 482 |
-
if last_slot and (user_text or "").strip():
|
| 483 |
try:
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
except Exception as e:
|
| 496 |
-
logging.info("canonical_fallback capture
|
| 497 |
|
| 498 |
try:
|
| 499 |
from backend.needs_finder import next_question
|
|
|
|
| 419 |
return None
|
| 420 |
text = raw_text.strip()
|
| 421 |
|
| 422 |
+
# NAME slot — KI-074 (2026-05-15) — explicit intro patterns ONLY.
|
| 423 |
+
# Previous version accepted "29 years old" as a name because it passed
|
| 424 |
+
# the alphabetic-ratio check. Now we require an explicit "I am X" /
|
| 425 |
+
# "my name is X" / "this is X" / "call me X" pattern, AND the captured
|
| 426 |
+
# span must look like a name (no digits, 1-4 short tokens).
|
| 427 |
if slot_id == "name":
|
| 428 |
+
import re as _re
|
| 429 |
+
# Strip leading greeting first
|
| 430 |
+
s = text.strip().strip(".,!?")
|
| 431 |
+
s = _re.sub(r"^(hi|hello|hey|namaste|yo)[,!.\s]+", "", s, flags=_re.IGNORECASE)
|
| 432 |
+
# Require an explicit intro phrase
|
| 433 |
+
m = _re.search(
|
| 434 |
+
r"\b(?:i'?m|i\s+am|this\s+is|my\s+name\s+is|name\s+is|call\s+me|name'?s)\s+"
|
| 435 |
+
r"([a-zA-Z][a-zA-Z'\-]{1,30}(?:\s+[a-zA-Z][a-zA-Z'\-]{1,30}){0,3})\b",
|
| 436 |
+
s,
|
| 437 |
+
flags=_re.IGNORECASE,
|
| 438 |
+
)
|
| 439 |
+
if not m:
|
| 440 |
+
return None
|
| 441 |
+
candidate = m.group(1).strip()
|
| 442 |
+
# KI-074 — chop at conjunctions / boundary words so "Rohit Sar and"
|
| 443 |
+
# in "My name is Rohit Sar and I am 32" becomes "Rohit Sar".
|
| 444 |
+
candidate = _re.split(
|
| 445 |
+
r"\s+(?:and|but|with|plus|also|or|&|,)\s+",
|
| 446 |
+
candidate, maxsplit=1, flags=_re.IGNORECASE,
|
| 447 |
+
)[0].strip()
|
| 448 |
+
# Also strip a TRAILING conjunction (when the connector is the
|
| 449 |
+
# last token in the captured span — e.g. "Rohit Sar and").
|
| 450 |
+
candidate = _re.sub(
|
| 451 |
+
r"\s+(?:and|but|with|plus|also|or|&)\s*$",
|
| 452 |
+
"", candidate, flags=_re.IGNORECASE,
|
| 453 |
+
).strip()
|
| 454 |
+
# Reject if any digit or trailing words look like an age phrase
|
| 455 |
+
if any(c.isdigit() for c in candidate):
|
| 456 |
+
return None
|
| 457 |
+
first_word = candidate.split()[0].lower()
|
| 458 |
+
if first_word in {
|
| 459 |
+
# Articles + possessives + demonstratives
|
| 460 |
+
"a", "an", "the", "my", "your", "his", "her", "their", "our",
|
| 461 |
+
"this", "that", "these", "those",
|
| 462 |
+
# Ordinals (commonly follow "this is my first ...")
|
| 463 |
+
"first", "second", "third", "fourth", "fifth", "last", "next",
|
| 464 |
+
# Common adjectives mistaken for names
|
| 465 |
+
"looking", "buying", "shopping", "interested", "trying", "thinking",
|
| 466 |
+
"happy", "sad", "tired", "busy", "ready", "done", "new", "old",
|
| 467 |
+
"good", "fine", "ok", "okay", "sure", "right",
|
| 468 |
+
"correct", "wrong", "alone", "single", "married",
|
| 469 |
+
"years", "year", "from", "very", "really", "just",
|
| 470 |
+
}:
|
| 471 |
+
return None
|
| 472 |
# Capitalise if all-lower (STT often outputs lowercase)
|
| 473 |
+
if not any(c.isupper() for c in candidate):
|
| 474 |
+
candidate = " ".join(w.capitalize() for w in candidate.split())
|
| 475 |
+
return candidate
|
| 476 |
+
|
| 477 |
+
# KI-074 — slot-specific triggers prevent cross-contamination during
|
| 478 |
+
# greedy multi-slot capture. Without these guards, "29 years old" was
|
| 479 |
+
# getting written into existing_cover_inr AND parents_age_max AND age.
|
| 480 |
+
import re as _re2
|
| 481 |
+
lc = text.lower()
|
| 482 |
try:
|
| 483 |
from backend.fact_find_normalizer import (
|
| 484 |
_parse_int, _parse_existing_cover, _keyword_normalize,
|
| 485 |
_validate, _FIELD_SCHEMA,
|
| 486 |
)
|
| 487 |
schema = _FIELD_SCHEMA.get(slot_id)
|
| 488 |
+
|
| 489 |
+
if slot_id == "age":
|
| 490 |
+
# Require an age trigger so a bare "29" in "₹29L cover" doesn't fire.
|
| 491 |
+
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):
|
| 492 |
+
# Bare number on its own line is also OK (typed "29")
|
| 493 |
+
if not _re2.match(r"^\s*\d{1,3}\s*\.?\s*$", text):
|
| 494 |
+
return None
|
| 495 |
+
val = _parse_int(text, schema or {"min": 1, "max": 120})
|
| 496 |
+
# Age sanity: 18-99
|
| 497 |
+
if val is not None and 18 <= val <= 99:
|
| 498 |
+
return val
|
| 499 |
+
return None
|
| 500 |
+
|
| 501 |
+
if slot_id == "parents_age":
|
| 502 |
+
# MUST explicitly mention parent context.
|
| 503 |
+
if not _re2.search(r"\b(parent|mom|mum|mother|dad|father|mama|papa)", lc):
|
| 504 |
+
return None
|
| 505 |
+
val = _parse_int(text, schema or {"min": 30, "max": 110})
|
| 506 |
+
if val is not None and 30 <= val <= 110:
|
| 507 |
+
return val
|
| 508 |
+
return None
|
| 509 |
+
|
| 510 |
if slot_id == "existing_cover":
|
| 511 |
+
# KI-074 — require a cover-context trigger. Word "cover" alone
|
| 512 |
+
# is too weak (matches "looking for cover" with no amount).
|
| 513 |
+
# Need either an explicit denial OR a currency/unit token.
|
| 514 |
+
denial = _re2.search(
|
| 515 |
+
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",
|
| 516 |
+
lc,
|
| 517 |
+
)
|
| 518 |
+
# Currency or unit cue. Plain "cover" word doesn't count.
|
| 519 |
+
unit_cue = _re2.search(
|
| 520 |
+
r"(₹|\brs\.?\s*\d|\d+\s*(?:lakh|lac|crore|cr)\b|"
|
| 521 |
+
r"\bsum\s+insured\b|\bcovered\s+for\b|\bemployer.*\bcover|"
|
| 522 |
+
r"\bfrom\s+work\b)",
|
| 523 |
+
lc,
|
| 524 |
+
)
|
| 525 |
+
if not denial and not unit_cue:
|
| 526 |
+
return None
|
| 527 |
return _parse_existing_cover(text)
|
| 528 |
+
|
| 529 |
# Enum / list slots — keyword fast path only, no LLM.
|
| 530 |
+
# Each slot has its own trigger keywords inside _keyword_normalize.
|
| 531 |
kw = _keyword_normalize(slot_id, text)
|
| 532 |
if kw is not None and schema is not None:
|
| 533 |
return _validate(kw, schema)
|
|
|
|
| 542 |
question for the next missing slot so the user always sees a coherent
|
| 543 |
next step.
|
| 544 |
|
| 545 |
+
KI-072 (2026-05-15) — applies the user's message to the previously-asked
|
| 546 |
+
slot via the legacy normalizer BEFORE picking the next slot.
|
| 547 |
+
|
| 548 |
+
KI-074 (2026-05-15) — GREEDY multi-slot capture. Previous KI-072 logic
|
| 549 |
+
only checked `awaiting_question_id` → dropped facts when brain failed on
|
| 550 |
+
a multi-fact message ("I am 29 and just me" lost age + dependents
|
| 551 |
+
because the LLM happened to be driving the `name` slot). Now we try
|
| 552 |
+
every unfilled slot against the user_text and capture whatever
|
| 553 |
+
matches — same multi-fact spirit as KI-070, just without the LLM call.
|
| 554 |
"""
|
| 555 |
profile = session.profile
|
| 556 |
captured: dict[str, Any] = {}
|
| 557 |
|
| 558 |
+
if (user_text or "").strip():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
try:
|
| 560 |
+
from backend.needs_finder import GRAPH
|
| 561 |
+
|
| 562 |
+
# Build the prioritised slot order — try high-signal slots first
|
| 563 |
+
# (numbers, enums) before name (which has explicit-intro guard).
|
| 564 |
+
_GREEDY_ORDER = [
|
| 565 |
+
"age", "dependents", "income_band", "existing_cover",
|
| 566 |
+
"primary_goal", "location", "parents_age", "budget", "name",
|
| 567 |
+
]
|
| 568 |
+
ordered_slots: list[str] = [
|
| 569 |
+
sid for sid in _GREEDY_ORDER
|
| 570 |
+
if any(q.id == sid for q in GRAPH)
|
| 571 |
+
]
|
| 572 |
+
|
| 573 |
+
for slot_id in ordered_slots:
|
| 574 |
+
q_obj = next((q for q in GRAPH if q.id == slot_id), None)
|
| 575 |
+
if q_obj is None:
|
| 576 |
+
continue
|
| 577 |
+
# Skip already-filled slots
|
| 578 |
+
current = getattr(profile, q_obj.field, None)
|
| 579 |
+
if current not in (None, "", []):
|
| 580 |
+
continue
|
| 581 |
+
try:
|
| 582 |
+
val = _normalize_for_slot(slot_id, user_text)
|
| 583 |
+
except Exception:
|
| 584 |
+
val = None
|
| 585 |
+
if val is None:
|
| 586 |
+
continue
|
| 587 |
+
captured[q_obj.field] = val
|
| 588 |
+
setattr(profile, q_obj.field, val)
|
| 589 |
+
if slot_id not in profile.asked:
|
| 590 |
+
profile.asked.append(slot_id)
|
| 591 |
except Exception as e:
|
| 592 |
+
logging.info("canonical_fallback greedy capture failed: %s", e)
|
| 593 |
|
| 594 |
try:
|
| 595 |
from backend.needs_finder import next_question
|
|
@@ -146,8 +146,8 @@ GRAPH: list[Question] = [
|
|
| 146 |
),
|
| 147 |
Question(
|
| 148 |
id="health_conditions",
|
| 149 |
-
prompt_en="Any pre-existing conditions on your side — diabetes, BP, thyroid, asthma, anything chronic?
|
| 150 |
-
prompt_hi="आपकी side से कोई pre-existing condition — diabetes, BP, thyroid?
|
| 151 |
field="health_conditions",
|
| 152 |
condition=_always,
|
| 153 |
),
|
|
|
|
| 146 |
),
|
| 147 |
Question(
|
| 148 |
id="health_conditions",
|
| 149 |
+
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.",
|
| 150 |
+
prompt_hi="आपकी side से कोई pre-existing condition — diabetes, BP, thyroid? सच बताइए — hide करने से premium तो कम होगा, but claim time पर ₹8 lakh denied हो सकते हैं। आपकी ईमानदारी आपकी claim बचाती है।",
|
| 151 |
field="health_conditions",
|
| 152 |
condition=_always,
|
| 153 |
),
|