rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
2fbd062
·
1 Parent(s): 8ff05ba

feat(ux): KI-056 — natural-language acknowledgers + spouse capture + Q3 paren consistency

Browse files

Three fixes for robotic-feeling fact-find UX surfaced by 2026-05-15 user testing.

1. Dynamic acknowledger opener (orchestrator.py)
_pick_opener() rotates 8 natural openers ("Thanks for that. ", "Noted. ",
"Helpful — ", "Right, ", "OK. ", "Got it. ", "Makes sense. ", "")
deterministically keyed by (session_id, turn_idx, slot). Applied to both
the continuation opener and the readback summary.

2. Family-aware opener (orchestrator.py)
_family_aware_opener() detects spouse/kids/parents mentions and swaps in
an explicit acknowledgement ("Understood — for you and your spouse, then. ")
so the user feels heard when volunteering family info mid-flow.

3. Opportunistic dependents capture (needs_finder.py + orchestrator.py)
infer_dependents_from_text() pre-fills profile.dependents from any slot's
answer so the dedicated dependents slot gets skipped. Does not conflict
with INSURANCE_BOT_SKIP_PROFILE_EXTRACTOR (KI-053) — regex-only, no LLM.

4. Q3 paren consistency (needs_finder.py)
primary_goal prompt now matches Q1 + Q2's "(why we ask)" tail.

Verification:
- python -m py_compile both files → SYNTAX OK
- 7/7 inline tests on infer_dependents_from_text
- 4/4 inline tests on _pick_opener
- tests/test_routing_regression.py → 15 passed, 13 subtests passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. backend/needs_finder.py +60 -2
  2. backend/orchestrator.py +156 -2
backend/needs_finder.py CHANGED
@@ -23,6 +23,7 @@ free-form questions — the graph supports both.
23
 
24
  from __future__ import annotations
25
 
 
26
  from dataclasses import dataclass, field
27
  from typing import Any, Optional
28
 
@@ -114,8 +115,8 @@ GRAPH: list[Question] = [
114
  ),
115
  Question(
116
  id="primary_goal",
117
- prompt_en="What's brought you here — first health policy, upgrading existing cover, comparing specific policies, or tax planning?",
118
- prompt_hi="आप यहाँ क्यों हैं — पहली policy, upgrade, specific compare, या tax planning?",
119
  field="primary_goal",
120
  is_core=True,
121
  ),
@@ -199,6 +200,63 @@ def record_answer(profile: Profile, question_id: str, raw_answer: str) -> Profil
199
  return profile
200
 
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  def readback_summary(profile: Profile) -> str:
203
  """One-paragraph human-readable summary of the gathered profile."""
204
  bits = []
 
23
 
24
  from __future__ import annotations
25
 
26
+ import re
27
  from dataclasses import dataclass, field
28
  from typing import Any, Optional
29
 
 
115
  ),
116
  Question(
117
  id="primary_goal",
118
+ 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.)",
119
+ prompt_hi="आप यहाँ क्यों हैं — पहली policy, upgrade, specific compare, या tax planning? (इससे हम तय करते हैं कि आपको price, coverage, claim experience या tax savings पर grade करें।)",
120
  field="primary_goal",
121
  is_core=True,
122
  ),
 
200
  return profile
201
 
202
 
203
+ # ----------------------------------------------------------------------------
204
+ # Opportunistic family/dependents extractor — KI-056 (2026-05-15)
205
+ # ----------------------------------------------------------------------------
206
+ # Real-user testing surfaced: when the user mentions a spouse / kids / parents
207
+ # while answering an UNRELATED slot ("my wife also doesn't have anything" in
208
+ # response to existing_cover), the bot just acknowledges and moves on without
209
+ # capturing the family signal. By the time we reach the dependents slot the
210
+ # information has been thrown away. This helper detects family mentions in any
211
+ # free-text turn so the orchestrator can pre-fill `profile.dependents`.
212
+ #
213
+ # Returns one of the canonical `dependents` enum values, or None if no clear
214
+ # family signal is present. Conservative on purpose — only the explicit
215
+ # combinations are recognised.
216
+
217
+ _FAMILY_TERM_RE = re.compile(
218
+ r"\b(wife|husband|spouse|partner|kids?|children|child|parents?)\b",
219
+ re.IGNORECASE,
220
+ )
221
+ _SPOUSE_RE = re.compile(r"\b(wife|husband|spouse|partner)\b", re.IGNORECASE)
222
+ _KIDS_RE = re.compile(r"\b(kids?|children|child)\b", re.IGNORECASE)
223
+ _PARENTS_RE = re.compile(r"\bparents?\b", re.IGNORECASE)
224
+
225
+
226
+ def infer_dependents_from_text(text: str) -> Optional[str]:
227
+ """Detect spouse/kids/parents mentions in a free-text user message and
228
+ return the matching canonical `dependents` enum value, or None.
229
+
230
+ KI-056 (2026-05-15). Used by the orchestrator to pre-fill the dependents
231
+ slot opportunistically when the user volunteers family information while
232
+ answering a different slot.
233
+
234
+ Decision tree (in order of specificity):
235
+ - spouse + kids → "self+spouse+kids"
236
+ - spouse + parents → "self+spouse+parents"
237
+ - spouse only → "self+spouse"
238
+ - kids only → "self+kids"
239
+ - parents only → "self+parents"
240
+ - nothing recognised → None
241
+ """
242
+ if not text or not _FAMILY_TERM_RE.search(text):
243
+ return None
244
+ has_spouse = bool(_SPOUSE_RE.search(text))
245
+ has_kids = bool(_KIDS_RE.search(text))
246
+ has_parents = bool(_PARENTS_RE.search(text))
247
+ if has_spouse and has_kids:
248
+ return "self+spouse+kids"
249
+ if has_spouse and has_parents:
250
+ return "self+spouse+parents"
251
+ if has_spouse:
252
+ return "self+spouse"
253
+ if has_kids:
254
+ return "self+kids"
255
+ if has_parents:
256
+ return "self+parents"
257
+ return None
258
+
259
+
260
  def readback_summary(profile: Profile) -> str:
261
  """One-paragraph human-readable summary of the gathered profile."""
262
  bits = []
backend/orchestrator.py CHANGED
@@ -12,6 +12,7 @@ For each user turn:
12
 
13
  from __future__ import annotations
14
 
 
15
  import re
16
  import time
17
  from dataclasses import dataclass, field
@@ -149,6 +150,120 @@ def pick_brain(intent: str, language: str) -> BrainPick:
149
  return BrainPick(get_fast_brain_llm(), f"v4-flash::{intent}")
150
 
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  # ---------- main entrypoint ----------
153
 
154
  @dataclass
@@ -490,6 +605,22 @@ async def handle_turn(
490
  session.set_awaiting(None)
491
  ambiguous_or_failed = False # no longer a reask situation
492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  # KI-040 — returning-visitor short-circuit. If we recognised the user's
494
  # name and loaded their stored profile, skip directly to the greeting
495
  # without picking another fact-find question.
@@ -522,7 +653,17 @@ async def handle_turn(
522
  opener_en = "Sorry, I didn't catch that. Let me ask again — "
523
  opener_hi = "माफ़ कीजिए, समझ नहीं आया। दोबारा पूछता हूँ — "
524
  elif in_fact_find_continuation:
525
- opener_en = "Got it. "
 
 
 
 
 
 
 
 
 
 
526
  opener_hi = "ठीक है। "
527
  else:
528
  opener_en = "Happy to help. " if not user_text.lower().strip().startswith(("hi", "hello")) else "Hi! "
@@ -574,8 +715,21 @@ async def handle_turn(
574
  session.free_form_session = True
575
  session._flush()
576
  summary = readback_summary(session.profile)
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  reply = (
578
- f"Got it — here's what I've understood: {summary}. "
579
  f"**If anything's wrong, just tell me** (e.g., \"actually I'm 31\", or "
580
  f"\"I want to cover my parents too\"). "
581
  f"Otherwise — want me to suggest 2-3 policies that fit your profile, "
 
12
 
13
  from __future__ import annotations
14
 
15
+ import hashlib
16
  import re
17
  import time
18
  from dataclasses import dataclass, field
 
150
  return BrainPick(get_fast_brain_llm(), f"v4-flash::{intent}")
151
 
152
 
153
+ # ---------- conversational acknowledgers (KI-056, 2026-05-15) ----------
154
+ #
155
+ # Previous behaviour: every fact-find continuation turn started with the
156
+ # literal "Got it. " — three turns in a row with the same opener felt robotic
157
+ # and triggered user feedback. We now rotate through a small set of natural
158
+ # acknowledgers (deterministic per session+turn so the wording is stable on
159
+ # replay) AND, when the user's message mentions family, swap in a
160
+ # family-aware opener that explicitly acknowledges the disclosure.
161
+
162
+ _FAMILY_DISCLOSURE_RE = re.compile(
163
+ r"\b(wife|husband|spouse|partner|kids?|children|child|parents?|family)\b",
164
+ re.IGNORECASE,
165
+ )
166
+
167
+ # Plain rotation — when nothing special is going on. Trailing space included so
168
+ # callers can concatenate directly; `""` lets some turns skip the opener
169
+ # entirely and go straight into the next question.
170
+ _NEUTRAL_OPENERS_EN: tuple[str, ...] = (
171
+ "Thanks for that. ",
172
+ "Noted. ",
173
+ "Helpful — ",
174
+ "Right, ",
175
+ "OK. ",
176
+ "Got it. ",
177
+ "Makes sense. ",
178
+ "",
179
+ )
180
+
181
+ # Family-aware variants — picked when the user's message references a spouse,
182
+ # kids, or parents. The bot should signal that it actually heard the family
183
+ # mention rather than mechanically advancing.
184
+ _FAMILY_OPENERS_EN: tuple[str, ...] = (
185
+ "Understood — for you and your family, then. ",
186
+ "Noted — covering your family. ",
187
+ "OK, family coverage to think about. ",
188
+ )
189
+ _SPOUSE_OPENERS_EN: tuple[str, ...] = (
190
+ "Understood — for you and your spouse, then. ",
191
+ "Noted — covering you and your spouse. ",
192
+ "OK, that means two people on the policy. ",
193
+ )
194
+ _KIDS_OPENERS_EN: tuple[str, ...] = (
195
+ "Noted — covering your kids too. ",
196
+ "OK, family-floater territory then. ",
197
+ )
198
+ _PARENTS_OPENERS_EN: tuple[str, ...] = (
199
+ "Noted — your parents in the mix as well. ",
200
+ "OK, parent coverage to factor in. ",
201
+ )
202
+
203
+
204
+ def _family_aware_opener(user_text: str, fallback: str) -> Optional[str]:
205
+ """Pick a family-aware acknowledger if `user_text` mentions a spouse,
206
+ kids, or parents; otherwise return None so the caller uses `fallback`.
207
+
208
+ KI-056 (2026-05-15). The opener picked here is intentionally more
209
+ specific than the neutral rotation so the user feels heard when they
210
+ volunteer family information mid-flow.
211
+ """
212
+ if not user_text:
213
+ return None
214
+ t = user_text.lower()
215
+ has_spouse = bool(re.search(r"\b(wife|husband|spouse|partner)\b", t))
216
+ has_kids = bool(re.search(r"\b(kids?|children|child)\b", t))
217
+ has_parents = bool(re.search(r"\bparents?\b", t))
218
+ has_family_word = "family" in t
219
+ if not (has_spouse or has_kids or has_parents or has_family_word):
220
+ return None
221
+ # Pick the most specific variant available — order matters.
222
+ if has_spouse and (has_kids or has_parents):
223
+ pool = _FAMILY_OPENERS_EN
224
+ elif has_spouse:
225
+ pool = _SPOUSE_OPENERS_EN
226
+ elif has_kids:
227
+ pool = _KIDS_OPENERS_EN
228
+ elif has_parents:
229
+ pool = _PARENTS_OPENERS_EN
230
+ else: # has_family_word only
231
+ pool = _FAMILY_OPENERS_EN
232
+ # Deterministic pick based on the fallback string so two consecutive calls
233
+ # with similar context don't collide on the same variant.
234
+ idx = (sum(ord(c) for c in (fallback or "x")) % len(pool))
235
+ return pool[idx]
236
+
237
+
238
+ def _pick_opener(
239
+ user_text: str,
240
+ session_id: Optional[str],
241
+ turn_idx: int,
242
+ slot_just_filled: Optional[str],
243
+ ) -> str:
244
+ """Choose a natural-language acknowledger for the bot's next reply.
245
+
246
+ KI-056 (2026-05-15). Replaces the hardcoded literal "Got it. " opener
247
+ that appeared on every fact-find continuation turn. The opener varies
248
+ deterministically by (session_id, turn_idx) so the same user gets a
249
+ different acknowledger each turn — but two replays of the same session
250
+ produce the same wording (testable).
251
+
252
+ If the user's message contains a spouse/family/parents disclosure, the
253
+ opener swaps to a family-aware variant that explicitly acknowledges it,
254
+ so the user feels heard rather than ignored.
255
+ """
256
+ # Family-aware override takes precedence over the neutral rotation.
257
+ fam = _family_aware_opener(user_text, fallback=f"{session_id}:{turn_idx}:{slot_just_filled or ''}")
258
+ if fam is not None:
259
+ return fam
260
+ # Deterministic neutral rotation. Hash (session_id, turn_idx, slot)
261
+ # so each turn rotates and different sessions decorrelate.
262
+ seed = f"{session_id or 'anon'}|{turn_idx}|{slot_just_filled or ''}"
263
+ h = int(hashlib.sha1(seed.encode("utf-8")).hexdigest()[:8], 16)
264
+ return _NEUTRAL_OPENERS_EN[h % len(_NEUTRAL_OPENERS_EN)]
265
+
266
+
267
  # ---------- main entrypoint ----------
268
 
269
  @dataclass
 
605
  session.set_awaiting(None)
606
  ambiguous_or_failed = False # no longer a reask situation
607
 
608
+ # KI-056 (2026-05-15) — opportunistic dependents capture from any
609
+ # free-text fact-find turn. If the user mentioned spouse / kids /
610
+ # parents while answering an UNRELATED slot ("my wife also doesn't
611
+ # have anything" in response to existing_cover), pre-fill the
612
+ # dependents slot so we don't waste a turn asking again later.
613
+ # Only fires when the slot is still empty — never overwrites an
614
+ # explicit user-provided answer to the dependents question.
615
+ if session.profile.dependents in (None, ""):
616
+ from backend.needs_finder import infer_dependents_from_text
617
+ inferred = infer_dependents_from_text(user_text)
618
+ if inferred:
619
+ session.update_profile_field("dependents", inferred)
620
+ fact_find_profile_updates["dependents"] = inferred
621
+ if "dependents" not in session.profile.asked:
622
+ session.profile.asked.append("dependents")
623
+
624
  # KI-040 — returning-visitor short-circuit. If we recognised the user's
625
  # name and loaded their stored profile, skip directly to the greeting
626
  # without picking another fact-find question.
 
653
  opener_en = "Sorry, I didn't catch that. Let me ask again — "
654
  opener_hi = "माफ़ कीजिए, समझ नहीं आया। दोबारा पूछता हूँ — "
655
  elif in_fact_find_continuation:
656
+ # KI-056 (2026-05-15) — dynamic acknowledger. Replaces the
657
+ # literal "Got it. " that previously appeared at the start of
658
+ # every continuation turn. Family disclosures get an explicit
659
+ # acknowledgement; neutral turns rotate through 8 variants
660
+ # deterministic on (session_id, turn_idx, slot).
661
+ opener_en = _pick_opener(
662
+ user_text=user_text,
663
+ session_id=session_id,
664
+ turn_idx=len(session.profile.asked),
665
+ slot_just_filled=session.awaiting_question_id or None,
666
+ )
667
  opener_hi = "ठीक है। "
668
  else:
669
  opener_en = "Happy to help. " if not user_text.lower().strip().startswith(("hi", "hello")) else "Hi! "
 
715
  session.free_form_session = True
716
  session._flush()
717
  summary = readback_summary(session.profile)
718
+ # KI-056 (2026-05-15) — dynamic readback opener. Picks a varied
719
+ # acknowledger so the completion turn doesn't always start with
720
+ # "Got it — here's what I've understood:".
721
+ readback_opener = _pick_opener(
722
+ user_text=user_text,
723
+ session_id=session_id,
724
+ turn_idx=len(session.profile.asked),
725
+ slot_just_filled="__readback__",
726
+ ).rstrip()
727
+ if not readback_opener:
728
+ readback_opener = "Here's what I've understood:"
729
+ else:
730
+ readback_opener = f"{readback_opener} Here's what I've understood:"
731
  reply = (
732
+ f"{readback_opener} {summary}. "
733
  f"**If anything's wrong, just tell me** (e.g., \"actually I'm 31\", or "
734
  f"\"I want to cover my parents too\"). "
735
  f"Otherwise — want me to suggest 2-3 policies that fit your profile, "