rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
6f495c1
·
1 Parent(s): 3d06a80

feat(voice): KI-032 — LLM-paraphrase fact-find questions (Option B + verifier)

Browse files

User report: the bot's 9 fact-find questions are asked in the same fixed
order with the same fixed wording every session — "mechanical and robotic".
Root cause: backend/needs_finder.py::GRAPH is hardcoded strings; no LLM
runs in the question-asking loop, only in the answer-normalizing loop.

New backend/question_paraphraser.py:
• paraphrase_question(canonical, slot_id, session_id, recent_user_text)
→ LLM-rewrite the canonical with warmer, more conversational tone
while preserving the educational/parenthetical "why we ask" context.
• Uses NimChainLLM(FAST_BRAIN_CHAIN, timeout=4s, total_budget=6s) so the
50/50 NIM/Groq rotation applies — average latency ~1-2s, capped at 6s.
• Verifier: parses JSON {paraphrase, asks_about_slot}, rejects if
asks_about_slot drifts from the requested slot_id, if no question
mark, or if length is out of [30, 500] chars. On any rejection,
returns None so the caller falls back to the canonical text — no
UX regression possible.
• Module-level cache keyed by (session_id, slot_id) so a given slot
is paraphrased AT MOST ONCE per session. Failed attempts cache None
so we don't retry mid-session on a flaky model.
• clear_session_cache(session_id) — called from session_state.reset_session()
so a "Start fresh" click produces fresh paraphrase wording too.

Orchestrator wire-in (handle_turn fact-find branch):
• Before emitting the question, attempt paraphrase for English language
only (Hindi keeps canonical for now). Re-asks ("Sorry, I didn't catch
that...") also skip paraphrase — re-ask wording should stay a stable
anchor the user can recognize.

Smoke test (3 trials, same slot, same session_id varied):
canonical: "First, your age? (Premium + eligibility + how long you can renew all hinge on this.)"
trial 1: "To get you the best plan, could you share your age? It helps us figure out your premium and how long you can keep renewing it."
trial 2: "To get you the best plan, could you tell me your age? It affects your premium, eligibility, and how long you can keep renewing your cover."
trial 3: "To get you the best plan, could you tell me your age? It helps us figure out your premium and how long you can keep renewing it."

All 3 passed the verifier (asks_about_slot == "age", ends with "?", in
length bounds). Variety is moderate — temperature=0.7. Can tune later.

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

backend/orchestrator.py CHANGED
@@ -316,7 +316,31 @@ async def handle_turn(
316
  else:
317
  opener_en = "Happy to help. " if not user_text.lower().strip().startswith(("hi", "hello")) else "Hi! "
318
  opener_hi = "मदद के लिए तैयार हूँ। "
319
- reply = (opener_hi + q.prompt_hi) if language == "indic" else (opener_en + q.prompt_en)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  if ambiguous_or_failed:
321
  brain_tag = "needs_finder::reask_clarify"
322
  else:
 
316
  else:
317
  opener_en = "Happy to help. " if not user_text.lower().strip().startswith(("hi", "hello")) else "Hi! "
318
  opener_hi = "मदद के लिए तैयार हूँ। "
319
+
320
+ # KI-032 — Per-turn LLM paraphrase + verifier so the bot stops
321
+ # asking the same 9 hardcoded questions with the same wording in
322
+ # every session. English only for now; the indic branch keeps
323
+ # the canonical Hindi text. Re-ask flows ALSO skip paraphrase —
324
+ # we want the literal "let me ask again" to read the canonical
325
+ # so the user has a stable anchor.
326
+ question_text_en = q.prompt_en
327
+ if language != "indic" and not ambiguous_or_failed:
328
+ try:
329
+ from backend.question_paraphraser import paraphrase_question
330
+ paraphrased = await paraphrase_question(
331
+ canonical=q.prompt_en,
332
+ slot_id=q.id,
333
+ session_id=session_id,
334
+ recent_user_text=user_text,
335
+ )
336
+ if paraphrased:
337
+ question_text_en = paraphrased
338
+ except Exception:
339
+ # Paraphraser must never block fact-find; on any failure
340
+ # we silently keep the canonical wording.
341
+ pass
342
+
343
+ reply = (opener_hi + q.prompt_hi) if language == "indic" else (opener_en + question_text_en)
344
  if ambiguous_or_failed:
345
  brain_tag = "needs_finder::reask_clarify"
346
  else:
backend/question_paraphraser.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM-driven per-turn paraphrasing for the fact-find question graph.
2
+
3
+ Real-user testing on 2026-05-14 flagged the bot as "mechanical and robotic":
4
+ the 9 fact-find questions (backend/needs_finder.py::GRAPH) are hardcoded
5
+ strings asked in fixed order with fixed wording. This module wraps each
6
+ canonical question with a fast NIM/Groq paraphrase pass + a verifier that
7
+ rejects any paraphrase which drifts off-slot, so the worst case degrades
8
+ gracefully to the canonical text (no UX regression).
9
+
10
+ Caching: a module-level dict keyed by (session_id, slot_id) holds the
11
+ paraphrase across the lifetime of a session. Each slot is therefore
12
+ LLM-paraphrased AT MOST ONCE per session → max 9 paraphrase calls per
13
+ 30-turn audit persona, even though the orchestrator can call this
14
+ function on every fact-find turn.
15
+
16
+ KI-032 (2026-05-14).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import re
24
+ from typing import Optional
25
+
26
+ from backend.providers.base import ChatMessage
27
+ from backend.providers.nvidia_nim_llm import FAST_BRAIN_CHAIN, NimChainLLM
28
+
29
+ # Slot IDs match Question.id in needs_finder.GRAPH — keep this in lock-step.
30
+ ALLOWED_SLOTS = frozenset({
31
+ "age", "dependents", "income_band", "existing_cover", "primary_goal",
32
+ "location", "parents_age", "health_conditions", "budget",
33
+ })
34
+
35
+
36
+ _PARAPHRASER_SYSTEM = """You are rewriting fact-find questions for a health insurance bot in India.
37
+
38
+ The bot asks 9 fixed questions about the customer's age, dependents, income,
39
+ existing insurance, goals, city, parents' health, own pre-existing conditions,
40
+ and budget. Your job is to rephrase the next question so it sounds warm and
41
+ conversational instead of robotic — without changing what's being asked.
42
+
43
+ NON-NEGOTIABLE CONSTRAINTS:
44
+ 1. The paraphrase must ask about the SAME piece of information (same slot).
45
+ 2. Preserve any educational context the original explains ("this matters
46
+ because…"). Rewrite the explanation in your own words, but keep its
47
+ meaning.
48
+ 3. Warm and conversational, not verbose. 1-3 sentences max.
49
+ 4. Must end with a question mark.
50
+ 5. Indian English is fine — "₹", "lakh", "metro/tier-2 city" are OK.
51
+ 6. Do NOT add new questions or ask for additional information beyond the slot.
52
+
53
+ OUTPUT — strict JSON with exactly two fields, NO code fences, NO commentary:
54
+ {"paraphrase": "...", "asks_about_slot": "<slot_id>"}
55
+
56
+ Valid slot IDs: age | dependents | income_band | existing_cover | primary_goal | location | parents_age | health_conditions | budget
57
+ """
58
+
59
+
60
+ _USER_TEMPLATE = (
61
+ "ORIGINAL CANONICAL: {original}\n"
62
+ "SLOT: {slot_id}\n"
63
+ "USER'S MOST RECENT MESSAGE (for tone calibration): {recent_user_text}\n\n"
64
+ "Rewrite the question now. Return strict JSON only."
65
+ )
66
+
67
+
68
+ # (session_id, slot_id) → cached paraphrase string. None means "tried + failed,
69
+ # fall back to canonical for this session" so we don't retry the LLM each turn.
70
+ _PARAPHRASE_CACHE: dict[tuple[str, str], Optional[str]] = {}
71
+
72
+
73
+ def clear_session_cache(session_id: str) -> int:
74
+ """Drop all cached paraphrases for one session. Called from
75
+ session_state.reset_session() so a "Start fresh" click produces fresh
76
+ paraphrase wording. Returns count of dropped keys."""
77
+ keys = [k for k in _PARAPHRASE_CACHE.keys() if k[0] == session_id]
78
+ for k in keys:
79
+ _PARAPHRASE_CACHE.pop(k, None)
80
+ return len(keys)
81
+
82
+
83
+ def _parse_json_lenient(raw: str) -> Optional[dict]:
84
+ raw = raw.strip()
85
+ if not raw:
86
+ return None
87
+ # Strip code fences some models add despite instructions
88
+ if raw.startswith("```"):
89
+ raw = re.sub(r"^```(?:json)?\s*", "", raw)
90
+ raw = re.sub(r"\s*```\s*$", "", raw)
91
+ try:
92
+ return json.loads(raw)
93
+ except Exception:
94
+ pass
95
+ # Last-resort: pull the first balanced {...}
96
+ m = re.search(r"\{.*\}", raw, flags=re.DOTALL)
97
+ if not m:
98
+ return None
99
+ try:
100
+ return json.loads(m.group(0))
101
+ except Exception:
102
+ return None
103
+
104
+
105
+ async def paraphrase_question(
106
+ canonical: str,
107
+ slot_id: str,
108
+ session_id: Optional[str] = None,
109
+ recent_user_text: str = "",
110
+ *,
111
+ total_budget_s: float = 6.0,
112
+ ) -> Optional[str]:
113
+ """LLM-rewrite the canonical fact-find question + verify it still targets
114
+ `slot_id`. Returns the paraphrase if it passes the verifier, None otherwise.
115
+
116
+ Callers should fall back to the canonical text on None.
117
+
118
+ Result is cached per (session_id, slot_id) so the LLM is only called once
119
+ per slot per session. A None result is also cached so we don't keep
120
+ retrying a flaky model mid-session.
121
+ """
122
+ if slot_id not in ALLOWED_SLOTS:
123
+ return None
124
+
125
+ cache_key = (session_id or "_anon", slot_id)
126
+ if cache_key in _PARAPHRASE_CACHE:
127
+ return _PARAPHRASE_CACHE[cache_key]
128
+
129
+ user_msg = _USER_TEMPLATE.format(
130
+ original=canonical,
131
+ slot_id=slot_id,
132
+ recent_user_text=(recent_user_text or "(no prior message yet)")[:300],
133
+ )
134
+
135
+ llm = NimChainLLM(
136
+ chain=FAST_BRAIN_CHAIN,
137
+ timeout=4.0,
138
+ role="paraphraser",
139
+ total_budget_s=total_budget_s,
140
+ )
141
+
142
+ try:
143
+ res = await llm.chat(
144
+ messages=[
145
+ ChatMessage(role="system", content=_PARAPHRASER_SYSTEM),
146
+ ChatMessage(role="user", content=user_msg),
147
+ ],
148
+ temperature=0.7,
149
+ max_tokens=200,
150
+ response_format={"type": "json_object"},
151
+ )
152
+ except Exception as e:
153
+ logging.info("paraphraser LLM call failed slot=%s err=%s — falling back",
154
+ slot_id, type(e).__name__)
155
+ _PARAPHRASE_CACHE[cache_key] = None
156
+ return None
157
+
158
+ data = _parse_json_lenient(res.text or "")
159
+ if not data:
160
+ logging.info("paraphraser JSON unparseable slot=%s — falling back", slot_id)
161
+ _PARAPHRASE_CACHE[cache_key] = None
162
+ return None
163
+
164
+ paraphrase = (data.get("paraphrase") or "").strip()
165
+ claimed_slot = (data.get("asks_about_slot") or "").strip()
166
+
167
+ # ---- Verifier ---------------------------------------------------------
168
+ if claimed_slot != slot_id:
169
+ logging.info("paraphraser slot drift slot=%s claimed=%s — falling back",
170
+ slot_id, claimed_slot)
171
+ _PARAPHRASE_CACHE[cache_key] = None
172
+ return None
173
+ if "?" not in paraphrase:
174
+ logging.info("paraphraser missing '?' slot=%s — falling back", slot_id)
175
+ _PARAPHRASE_CACHE[cache_key] = None
176
+ return None
177
+ if not (30 <= len(paraphrase) <= 500):
178
+ logging.info("paraphraser length oob slot=%s len=%d — falling back",
179
+ slot_id, len(paraphrase))
180
+ _PARAPHRASE_CACHE[cache_key] = None
181
+ return None
182
+
183
+ _PARAPHRASE_CACHE[cache_key] = paraphrase
184
+ return paraphrase