rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
4516e87
·
1 Parent(s): 2e60476

fix(fact-find): KI-074 — greedy multi-slot capture in fallback + stricter name parse + strip ** markdown leak

Browse files

User 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>

Files changed (2) hide show
  1. backend/fact_find_brain.py +140 -44
  2. backend/needs_finder.py +2 -2
backend/fact_find_brain.py CHANGED
@@ -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 — strip greeting + intro prefix; require alphabetic content.
 
 
 
 
423
  if slot_id == "name":
424
- s = text.strip(".,!?")
425
- for greet in ("hi there ", "hello there ", "hey there ",
426
- "hi, ", "hello, ", "hey, ",
427
- "hi ", "hello ", "hey ", "namaste ", "yo "):
428
- if s.lower().startswith(greet):
429
- s = s[len(greet):].strip()
430
- break
431
- for prefix in ("i'm ", "i am ", "my name is ", "name is ",
432
- "call me ", "this is ", "name's "):
433
- if s.lower().startswith(prefix):
434
- s = s[len(prefix):].strip()
435
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  # Capitalise if all-lower (STT often outputs lowercase)
437
- if s and not any(c.isupper() for c in s):
438
- s = " ".join(w.capitalize() for w in s.split())
439
- if 1 <= len(s) <= 50 and sum(1 for c in s if c.isalpha()) / max(1, len(s)) >= 0.5:
440
- return s
441
- return None
442
-
443
- # Numeric / cover slots use existing sync parsers.
 
 
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
- if slot_id in ("age", "parents_age"):
451
- return _parse_int(text, schema or {"min": 1, "max": 120})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) — CRITICAL: applies the user's current message to
470
- the previously-asked slot via the legacy normalizer BEFORE picking the
471
- next slot. Without this, repeated brain failures wedge the user in an
472
- infinite re-ask loop (live bug: "I am Don" / "Don" / "Don Jon" all
473
- re-asked "what should I call you?").
 
 
 
 
474
  """
475
  profile = session.profile
476
  captured: dict[str, Any] = {}
477
 
478
- # KI-072 try to capture the user's answer to whichever slot we were
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
- captured_value = _normalize_for_slot(last_slot, user_text)
485
- if captured_value is not None:
486
- # Map slot_id Profile field name via GRAPH.
487
- from backend.needs_finder import GRAPH
488
- q_obj = next((q for q in GRAPH if q.id == last_slot), None)
489
- if q_obj is not None:
490
- captured[q_obj.field] = captured_value
491
- # Apply to profile so next_question picks the FOLLOWING slot.
492
- setattr(profile, q_obj.field, captured_value)
493
- if last_slot not in profile.asked:
494
- profile.asked.append(last_slot)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
  except Exception as e:
496
- logging.info("canonical_fallback capture skipped (slot=%s): %s", last_slot, e)
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
backend/needs_finder.py CHANGED
@@ -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? **Be straight with me.** 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
  ),
 
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
  ),