Spaces:
Sleeping
fix(fact-find): KI-092 — lenient name parser when slot explicitly awaiting name
Browse filesUser screenshot: bot asked "First — what should I call you?", user
replied "rohit sar" (plain lowercase name, no intro phrase). Bot
RE-ASKED the same question because `_normalize_for_slot("name", "rohit sar")`
returned None — KI-074's strict regex required an explicit intro phrase
("I'm X" / "my name is X" / "this is X") which a direct reply doesn't have.
Root cause: KI-074 conflated TWO different name-extraction modes:
Mode A (lenient) — bot explicitly asked for name; the reply IS the
name by construction. Should accept any plain alphabetic input.
Mode B (strict) — greedy multi-slot capture; the user's message could
be about anything. Need intro phrase to avoid false positives like
"this is correct" (KI-069) or "29 years old".
Fix: split into two paths via a synthetic slot_id="name__awaiting" tag.
`_canonical_fallback` checks `session.awaiting_question_id == "name"` and
calls the lenient path first. Greedy multi-slot capture continues to use
the strict path. Both paths share validation: 1-50 chars, ≥50% alpha,
no digits, 1-4 word tokens.
Verified 9 cases including the user's exact failing input:
✓ 'rohit sar' → 'Rohit Sar' (lenient mode)
✓ 'Anjali' → 'Anjali'
✓ 'my name is Rohit' → 'Rohit'
✓ '29 years old' → None (digit-reject still works)
✓ 'this is correct' → None (strict-mode KI-069 guard intact)
Tests: routing_regression 15/15, credits_election 12/12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/fact_find_brain.py +63 -7
|
@@ -527,17 +527,55 @@ def _normalize_for_slot(slot_id: str, raw_text: str) -> Any:
|
|
| 527 |
return None
|
| 528 |
text = raw_text.strip()
|
| 529 |
|
| 530 |
-
# NAME slot — KI-074 (2026-05-15) —
|
| 531 |
-
#
|
| 532 |
-
#
|
| 533 |
-
# "
|
| 534 |
-
#
|
| 535 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
import re as _re
|
| 537 |
# Strip leading greeting first
|
| 538 |
s = text.strip().strip(".,!?")
|
| 539 |
s = _re.sub(r"^(hi|hello|hey|namaste|yo)[,!.\s]+", "", s, flags=_re.IGNORECASE)
|
| 540 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
m = _re.search(
|
| 542 |
r"\b(?:i'?m|i\s+am|this\s+is|my\s+name\s+is|name\s+is|call\s+me|name'?s)\s+"
|
| 543 |
r"([a-zA-Z][a-zA-Z'\-]{1,30}(?:\s+[a-zA-Z][a-zA-Z'\-]{1,30}){0,3})\b",
|
|
@@ -667,6 +705,24 @@ def _canonical_fallback(session, user_text: str, *, reason: str) -> FactFindOutc
|
|
| 667 |
try:
|
| 668 |
from backend.needs_finder import GRAPH
|
| 669 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
# Build the prioritised slot order — try high-signal slots first
|
| 671 |
# (numbers, enums) before name (which has explicit-intro guard).
|
| 672 |
_GREEDY_ORDER = [
|
|
|
|
| 527 |
return None
|
| 528 |
text = raw_text.strip()
|
| 529 |
|
| 530 |
+
# NAME slot — KI-074 + KI-092 (2026-05-15) — two-mode parsing.
|
| 531 |
+
# MODE A (lenient, when caller explicitly tagged awaiting=name): accept
|
| 532 |
+
# any plain alphabetic name. Used when fallback fires after the bot
|
| 533 |
+
# asked "what should I call you?" — the reply IS the name by
|
| 534 |
+
# construction; no intro phrase needed. Avoids the KI-074 false-
|
| 535 |
+
# reject on "rohit sar" (plain reply to the name prompt).
|
| 536 |
+
# MODE B (strict, default — used by greedy multi-slot capture): requires
|
| 537 |
+
# explicit "I'm X" / "my name is X" / "this is X" / "call me X" intro
|
| 538 |
+
# to avoid false positives on "this is correct" / "29 years old" etc.
|
| 539 |
+
# The caller signals MODE A by passing slot_id="name__awaiting" (a
|
| 540 |
+
# synthetic tag); the canonical fallback maps awaiting_question_id=name
|
| 541 |
+
# to this tag.
|
| 542 |
+
if slot_id in ("name", "name__awaiting"):
|
| 543 |
+
lenient_mode = (slot_id == "name__awaiting")
|
| 544 |
+
slot_id = "name" # normalise so downstream logic still sees "name"
|
| 545 |
import re as _re
|
| 546 |
# Strip leading greeting first
|
| 547 |
s = text.strip().strip(".,!?")
|
| 548 |
s = _re.sub(r"^(hi|hello|hey|namaste|yo)[,!.\s]+", "", s, flags=_re.IGNORECASE)
|
| 549 |
+
|
| 550 |
+
# KI-092 — LENIENT MODE: the bot just asked "what should I call you?"
|
| 551 |
+
# so any plain alphabetic reply should be accepted as the name.
|
| 552 |
+
# Examples to accept: "rohit sar" / "Anjali" / "Dr. Priya" / "Sam"
|
| 553 |
+
# Examples to reject: empty, all-digit, mostly-symbol, way too long.
|
| 554 |
+
if lenient_mode:
|
| 555 |
+
# Drop common polite-prefix scraps if present.
|
| 556 |
+
s_lc = s.lower()
|
| 557 |
+
for prefix in ("i'm ", "i am ", "this is ", "my name is ", "name is ",
|
| 558 |
+
"name's ", "call me ", "im ", "mr ", "mrs ", "ms ", "dr "):
|
| 559 |
+
if s_lc.startswith(prefix):
|
| 560 |
+
s = s[len(prefix):].strip()
|
| 561 |
+
break
|
| 562 |
+
# Validation: 1-50 chars, ≥50% alphabetic, no embedded digits
|
| 563 |
+
if not s or len(s) > 50 or any(c.isdigit() for c in s):
|
| 564 |
+
return None
|
| 565 |
+
alpha = sum(1 for c in s if c.isalpha())
|
| 566 |
+
if alpha < 2 or alpha / max(1, len(s)) < 0.5:
|
| 567 |
+
return None
|
| 568 |
+
# Limit to 1-4 word tokens
|
| 569 |
+
tokens = s.split()
|
| 570 |
+
if not (1 <= len(tokens) <= 4):
|
| 571 |
+
return None
|
| 572 |
+
# Capitalise if all-lower
|
| 573 |
+
if not any(c.isupper() for c in s):
|
| 574 |
+
s = " ".join(w.capitalize() for w in tokens)
|
| 575 |
+
return s
|
| 576 |
+
|
| 577 |
+
# STRICT MODE (greedy multi-slot capture) — original KI-074 behaviour:
|
| 578 |
+
# require an explicit intro phrase to avoid false positives.
|
| 579 |
m = _re.search(
|
| 580 |
r"\b(?:i'?m|i\s+am|this\s+is|my\s+name\s+is|name\s+is|call\s+me|name'?s)\s+"
|
| 581 |
r"([a-zA-Z][a-zA-Z'\-]{1,30}(?:\s+[a-zA-Z][a-zA-Z'\-]{1,30}){0,3})\b",
|
|
|
|
| 705 |
try:
|
| 706 |
from backend.needs_finder import GRAPH
|
| 707 |
|
| 708 |
+
# KI-092 — if the bot was explicitly awaiting the name slot,
|
| 709 |
+
# try the LENIENT name parser FIRST so a plain "rohit sar"
|
| 710 |
+
# reply gets captured instead of falling through to the strict
|
| 711 |
+
# intro-phrase requirement and never matching.
|
| 712 |
+
awaiting = getattr(session, "awaiting_question_id", None)
|
| 713 |
+
if awaiting == "name" and not getattr(profile, "name", None):
|
| 714 |
+
try:
|
| 715 |
+
lenient_name = _normalize_for_slot("name__awaiting", user_text)
|
| 716 |
+
except Exception:
|
| 717 |
+
lenient_name = None
|
| 718 |
+
if lenient_name:
|
| 719 |
+
q_obj = next((q for q in GRAPH if q.id == "name"), None)
|
| 720 |
+
if q_obj is not None:
|
| 721 |
+
captured[q_obj.field] = lenient_name
|
| 722 |
+
setattr(profile, q_obj.field, lenient_name)
|
| 723 |
+
if "name" not in profile.asked:
|
| 724 |
+
profile.asked.append("name")
|
| 725 |
+
|
| 726 |
# Build the prioritised slot order — try high-signal slots first
|
| 727 |
# (numbers, enums) before name (which has explicit-intro guard).
|
| 728 |
_GREEDY_ORDER = [
|