rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
9a1b321
·
1 Parent(s): a777198

fix(fact-find): LLM-normalize answers + reject non-answers + safe readback

Browse files

Three live-product bugs (D-021/22/23 follow-up):

1. dependents/income_band/primary_goal/location_tier/budget_band/
health_conditions had NO parsers in needs_finder.py → raw text was
stored verbatim. Frontend's enum-button comparison never matched,
so the Profile sidebar showed nothing selected even when chat had
captured the answer.

2. STT-failure templates ("Sorry, I couldn't hear that clearly. Please
try again.") were recorded as the user's answer to the in-flight
question, then the bot silently moved on with garbage.

3. health_conditions stored as a string (not list) caused
readback_summary to character-split via ", ".join(str) → output
"conditions: d, i, f, f, e, r, e, n, c, e, , i, n, t, o, ..."

Fix in three layers:

[A] backend/fact_find_normalizer.py — new module.
is_valid_answer(text)
Rejects empty, < 2-char, or known failure-template strings.
normalize_answer(question_id, raw_text) -> async
Fast-path regex for age/parents_age/existing_cover (with crore/
lakh/k/digits handling). LLM-mapped (NIM Llama-3.3-70B @ temp 0)
for enum + list fields. Returns None on ambiguous input → caller
re-asks the same question.

[B] backend/orchestrator.py — fact-find branch rewired.
On receiving an awaited answer:
- Skip recording if is_valid_answer() fails (STT garbage).
- Call normalize_answer() to map free-text → schema enum.
- Apply normalized value via session.update_profile_field() so
the Profile sidebar's enum-button comparison matches.
- On any failure, KEEP awaiting_question_id and re-ask with
opener "Sorry, I didn't catch that. Let me ask again — ".
Brain tag: needs_finder::reask_clarify.

[C] backend/needs_finder.py — readback_summary defensive list check.
isinstance(hc, str) → wrap as [hc] before join. Prevents the
character-split symptom even if a stale session still has a
string in profile.health_conditions.

Unit tests passed for the pure-Python paths (12/12 _parse_existing_cover
variants incl. "5L", "30k", "1.5 crore", "₹500000"). LLM enum mapping
will be verified live after the HF Space rebuild lands.

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

backend/fact_find_normalizer.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Unknown question id — defensive pass-through
116
+ return raw_text.strip() or None
117
+
118
+ # Fast paths — no LLM needed for plain integers / cover-amount parsing.
119
+ if question_id in ("age", "parents_age"):
120
+ return _parse_int(raw_text, schema)
121
+ if question_id == "existing_cover":
122
+ return _parse_existing_cover(raw_text)
123
+
124
+ # Enum + list fields — let the LLM map natural language to canonical value.
125
+ return await _llm_normalize(question_id, raw_text, schema)
126
+
127
+
128
+ # ----------------------------------------------------------------------------
129
+ # Fast-path parsers (no LLM)
130
+ # ----------------------------------------------------------------------------
131
+
132
+ def _parse_int(text: str, schema: dict) -> int | None:
133
+ digits = "".join(c for c in str(text) if c.isdigit())
134
+ if not digits:
135
+ return None
136
+ try:
137
+ v = int(digits[:3])
138
+ except ValueError:
139
+ return None
140
+ if v < schema.get("min", 0) or v > schema.get("max", 9_999):
141
+ return None
142
+ return v
143
+
144
+
145
+ def _parse_existing_cover(text: str) -> int | None:
146
+ """Handle "no" / "none" / "5 lakh" / "₹500000" / "5L" / "haven't got any" / "30k"."""
147
+ s = text.lower().strip()
148
+ # Negative answers map to 0 (no existing cover).
149
+ if re.search(r"\b(no|none|nothing|zero|nope|nah|haven'?t|don'?t)\b", s):
150
+ return 0
151
+
152
+ # Look for a number followed by a unit suffix (digit-attached OR separated).
153
+ # crore > lakh > thousand priority so longer units win the alternation.
154
+ cr_match = re.search(r"(\d+(?:\.\d+)?)\s*(?:cr|crore|crores)\b", s)
155
+ if cr_match:
156
+ try:
157
+ return int(float(cr_match.group(1)) * 10_000_000)
158
+ except ValueError:
159
+ return None
160
+ lakh_match = re.search(r"(\d+(?:\.\d+)?)\s*(?:l(?:akh|ac)?s?)\b", s)
161
+ if lakh_match:
162
+ try:
163
+ return int(float(lakh_match.group(1)) * 100_000)
164
+ except ValueError:
165
+ return None
166
+ k_match = re.search(r"(\d+(?:\.\d+)?)\s*k\b", s)
167
+ if k_match:
168
+ try:
169
+ return int(float(k_match.group(1)) * 1_000)
170
+ except ValueError:
171
+ return None
172
+
173
+ # Plain digit-only amount (e.g., "500000").
174
+ digits = "".join(c for c in text if c.isdigit())
175
+ if not digits:
176
+ return None
177
+ try:
178
+ amount = int(digits[:7])
179
+ except ValueError:
180
+ return None
181
+ if amount < 0 or amount > 100_000_000:
182
+ return None
183
+ return amount
184
+
185
+
186
+ # ----------------------------------------------------------------------------
187
+ # LLM-backed normalizer for enum + list fields
188
+ # ----------------------------------------------------------------------------
189
+
190
+ _LLM_SYSTEM_TEMPLATE = """You map a user's natural-language answer to a structured value.
191
+
192
+ Question ID: {qid}
193
+ Expected schema: {schema}
194
+
195
+ Rules:
196
+ 1. If type=enum, return EXACTLY one of the allowed values (a JSON string), or null if no clear match.
197
+ 2. If type=list, return a JSON array of canonical lowercase condition strings. For "no", "none", "nothing" → [].
198
+ 3. If the user clearly didn't answer the question (off-topic, asking back, gibberish), return null.
199
+ 4. Output ONLY the JSON value — no prose, no code fences, no <think> blocks.
200
+
201
+ Examples for guidance:
202
+ - dependents enum, user "just me" → "self"
203
+ - dependents enum, user "me and my wife" → "self+spouse"
204
+ - dependents enum, user "I want coverage for my parents too" → "self+parents"
205
+ - income_band enum, user "around 18 lakhs" → "10L-25L"
206
+ - income_band enum, user "more than 25 lakhs" → "25L+"
207
+ - primary_goal enum, user "I'm buying my first one" → "first_buy"
208
+ - primary_goal enum, user "want to compare HDFC and ICICI" → "compare_specific"
209
+ - location enum, user "Bangalore" → "metro"
210
+ - location enum, user "Patna" → "tier2"
211
+ - budget enum, user "around 20k a year" → "15k_30k"
212
+ - health_conditions list, user "none" → []
213
+ - health_conditions list, user "diabetes and BP" → ["diabetes", "hypertension"]
214
+ - health_conditions list, user "I have asthma" → ["asthma"]
215
+ """
216
+
217
+
218
+ async def _llm_normalize(question_id: str, raw_text: str, schema: dict) -> Any:
219
+ from backend.providers.base import ChatMessage
220
+ from backend.providers.nvidia_nim_llm import NvidiaNimLLM
221
+
222
+ sys_msg = _LLM_SYSTEM_TEMPLATE.format(qid=question_id, schema=json.dumps(schema))
223
+ user_msg = f'User said: "{raw_text[:600]}"\n\nReturn the JSON value.'
224
+
225
+ try:
226
+ llm = NvidiaNimLLM(model="meta/llama-3.3-70b-instruct")
227
+ result = await llm.chat(
228
+ messages=[
229
+ ChatMessage(role="system", content=sys_msg),
230
+ ChatMessage(role="user", content=user_msg),
231
+ ],
232
+ temperature=0.0,
233
+ max_tokens=120,
234
+ )
235
+ raw = (result.text or "").strip()
236
+ except Exception as e:
237
+ logging.warning(
238
+ "fact_find_normalizer LLM call failed (qid=%s, raw=%r): %s",
239
+ question_id, raw_text[:80], e,
240
+ )
241
+ return None
242
+
243
+ # Strip <think> blocks and code fences that some models add despite instructions.
244
+ if "<think>" in raw and "</think>" in raw:
245
+ raw = raw.split("</think>", 1)[1].strip()
246
+ if raw.startswith("```"):
247
+ raw = "\n".join(l for l in raw.split("\n") if not l.startswith("```")).strip()
248
+ if not raw or raw.lower() == "null":
249
+ return None
250
+
251
+ try:
252
+ parsed = json.loads(raw)
253
+ except json.JSONDecodeError:
254
+ # Some models return bare strings without JSON quoting; tolerate.
255
+ if schema["type"] == "enum" and raw.strip('"') in schema["values"]:
256
+ return raw.strip('"')
257
+ return None
258
+
259
+ return _validate(parsed, schema)
260
+
261
+
262
+ def _validate(value: Any, schema: dict) -> Any:
263
+ """Type + enum + bounds check. Returns None on failure."""
264
+ t = schema.get("type")
265
+
266
+ if t == "enum":
267
+ if isinstance(value, str) and value in schema["values"]:
268
+ return value
269
+ return None
270
+
271
+ if t == "int":
272
+ if isinstance(value, bool):
273
+ return None
274
+ try:
275
+ v = int(value)
276
+ except (TypeError, ValueError):
277
+ return None
278
+ if v < schema.get("min", -1_000_000_000) or v > schema.get("max", 1_000_000_000):
279
+ return None
280
+ return v
281
+
282
+ if t == "list":
283
+ if not isinstance(value, list):
284
+ return None
285
+ cleaned = [str(x).strip().lower() for x in value if x and isinstance(x, (str, int))]
286
+ cleaned = [c for c in cleaned if c]
287
+ return cleaned # [] is a valid answer (= "no conditions")
288
+
289
+ if t == "bool":
290
+ if isinstance(value, bool):
291
+ return value
292
+ return None
293
+
294
+ return value
backend/needs_finder.py CHANGED
@@ -218,7 +218,15 @@ def readback_summary(profile: Profile) -> str:
218
  if profile.parents_age_max:
219
  bits.append(f"parents up to age {profile.parents_age_max}")
220
  if profile.health_conditions:
221
- bits.append(f"conditions: {', '.join(profile.health_conditions)}")
 
 
 
 
 
 
 
 
222
  if profile.budget_band:
223
  bits.append(f"budget {profile.budget_band}")
224
  return "; ".join(bits) if bits else "(no profile yet)"
 
218
  if profile.parents_age_max:
219
  bits.append(f"parents up to age {profile.parents_age_max}")
220
  if profile.health_conditions:
221
+ hc = profile.health_conditions
222
+ # Defensive: if a string accidentally landed here, wrap it so we don't
223
+ # split it character-by-character in the join. Production hit this on
224
+ # 2026-05-14 — a verbatim STT transcript was stored as a string, then
225
+ # ', '.join(str) emitted "d, i, f, f, e, r, e, n, c, e, ...".
226
+ if isinstance(hc, str):
227
+ hc = [hc] if hc.strip() else []
228
+ if hc:
229
+ bits.append(f"conditions: {', '.join(str(c) for c in hc)}")
230
  if profile.budget_band:
231
  bits.append(f"budget {profile.budget_band}")
232
  return "; ".join(bits) if bits else "(no profile yet)"
backend/orchestrator.py CHANGED
@@ -161,21 +161,58 @@ async def handle_turn(
161
  treat_as_fact_find = (intent == "fact_find" and not session.free_form_session) or in_fact_find_continuation
162
 
163
  if treat_as_fact_find:
164
- # If we were awaiting an answer, parse + record it before picking next Q.
 
 
 
 
 
165
  if session.awaiting_question_id:
166
- session.record_user_answer(user_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
- q = next_question(session.profile, language=language)
169
  if q is not None:
170
  session.set_awaiting(q.id)
171
- if in_fact_find_continuation:
 
 
 
172
  opener_en = "Got it. "
173
  opener_hi = "ठीक है। "
174
  else:
175
  opener_en = "Happy to help. " if not user_text.lower().strip().startswith(("hi", "hello")) else "Hi! "
176
  opener_hi = "मदद के लिए तैयार हूँ। "
177
  reply = (opener_hi + q.prompt_hi) if language == "indic" else (opener_en + q.prompt_en)
178
- brain_tag = "needs_finder::fact_find_continue" if in_fact_find_continuation else "needs_finder::fact_find_start"
 
 
 
179
  else:
180
  # Fact-find complete — produce a profile readback + invite next step
181
  from backend.needs_finder import readback_summary
 
161
  treat_as_fact_find = (intent == "fact_find" and not session.free_form_session) or in_fact_find_continuation
162
 
163
  if treat_as_fact_find:
164
+ # If we were awaiting an answer, normalize + record it before picking next Q.
165
+ # Uses backend/fact_find_normalizer.py to map free-text → schema enums.
166
+ # If the input is a non-answer (STT failure / empty / gibberish), or the
167
+ # LLM can't confidently map it, we DON'T clear awaiting_question_id so
168
+ # the bot re-asks the same question rather than silently moving on.
169
+ ambiguous_or_failed = False
170
  if session.awaiting_question_id:
171
+ from backend.fact_find_normalizer import is_valid_answer, normalize_answer
172
+ qid = session.awaiting_question_id
173
+ if not is_valid_answer(user_text):
174
+ ambiguous_or_failed = True
175
+ else:
176
+ try:
177
+ normalized = await normalize_answer(qid, user_text)
178
+ except Exception:
179
+ normalized = None
180
+ if normalized is None:
181
+ ambiguous_or_failed = True
182
+ else:
183
+ # Apply the normalized value to the right Profile field.
184
+ q_obj = next((q for q in __import__('backend.needs_finder', fromlist=['GRAPH']).GRAPH if q.id == qid), None)
185
+ if q_obj is not None:
186
+ session.update_profile_field(q_obj.field, normalized)
187
+ if qid not in session.profile.asked:
188
+ session.profile.asked.append(qid)
189
+ session.set_awaiting(None)
190
+ else:
191
+ ambiguous_or_failed = True
192
+
193
+ # If the answer didn't normalize, pick the SAME question again (re-ask
194
+ # with a gentle clarifier) instead of moving on with garbage.
195
+ if ambiguous_or_failed and session.awaiting_question_id:
196
+ q = next((qq for qq in __import__('backend.needs_finder', fromlist=['GRAPH']).GRAPH if qq.id == session.awaiting_question_id), None)
197
+ else:
198
+ q = next_question(session.profile, language=language)
199
 
 
200
  if q is not None:
201
  session.set_awaiting(q.id)
202
+ if ambiguous_or_failed:
203
+ opener_en = "Sorry, I didn't catch that. Let me ask again — "
204
+ opener_hi = "माफ़ कीजिए, समझ नहीं आया। दोबारा पूछता हूँ — "
205
+ elif in_fact_find_continuation:
206
  opener_en = "Got it. "
207
  opener_hi = "ठीक है। "
208
  else:
209
  opener_en = "Happy to help. " if not user_text.lower().strip().startswith(("hi", "hello")) else "Hi! "
210
  opener_hi = "मदद के लिए तैयार हूँ। "
211
  reply = (opener_hi + q.prompt_hi) if language == "indic" else (opener_en + q.prompt_en)
212
+ if ambiguous_or_failed:
213
+ brain_tag = "needs_finder::reask_clarify"
214
+ else:
215
+ brain_tag = "needs_finder::fact_find_continue" if in_fact_find_continuation else "needs_finder::fact_find_start"
216
  else:
217
  # Fact-find complete — produce a profile readback + invite next step
218
  from backend.needs_finder import readback_summary