rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
769576f
Β·
1 Parent(s): 06bab12

feat: conversational profile updates in free-form chat (D-021)

Browse files

After fact-find onboarding completes, users often share new profile facts
in ordinary conversation β€” "I just turned 40", "we had a baby", "I was
diagnosed with diabetes". Vanilla retrieval didn't know to update the
session profile from these utterances.

Implementation:
- backend/profile_extractor.py β€” lightweight NIM extractor (Llama 3.3 70B)
runs on each free-form user message. Returns validated dict of
{field_name: new_value} for high-confidence updates only. Conservative
validation: bad types / enums / bounds get dropped silently.
- handle_turn() in orchestrator.py β€” after fact-find branch exits and
free_form_session is set, extract β†’ apply to session.profile β†’ re-upsert
the profile chunk in Chroma so THIS turn's retrieval reflects new state.
- Health conditions are MERGED (additive, deduped) β€” existing conditions
are preserved; only new ones get appended.
- TurnResult.profile_updates / ChatResponse.profile_updates surface the
field changes to the frontend so it can refresh the completeness panel.

Failure isolation: extractor exceptions NEVER block the chat β€” fall back
to no-update silently.

Unit-tested validator: out-of-bounds age dropped, invalid enums dropped,
unknown fields dropped, valid inputs preserved.

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

backend/main.py CHANGED
@@ -111,6 +111,14 @@ class ChatResponse(BaseModel):
111
  faithfulness_passed: bool = True
112
  faithfulness_reasons: list[str] = Field(default_factory=list)
113
  blocked: bool = False
 
 
 
 
 
 
 
 
114
 
115
 
116
  class TTSRequest(BaseModel):
@@ -307,6 +315,7 @@ async def chat(req: ChatRequest):
307
  faithfulness_passed=turn.faithfulness_passed,
308
  faithfulness_reasons=turn.faithfulness_reasons,
309
  blocked=turn.blocked,
 
310
  )
311
 
312
 
 
111
  faithfulness_passed: bool = True
112
  faithfulness_reasons: list[str] = Field(default_factory=list)
113
  blocked: bool = False
114
+ profile_updates: dict = Field(
115
+ default_factory=dict,
116
+ description=(
117
+ "Any profile fields auto-extracted from the user's free-form message "
118
+ "this turn (age, dependents, health_conditions, etc.). Frontend can "
119
+ "flash an acknowledgment + refresh the completeness panel."
120
+ ),
121
+ )
122
 
123
 
124
  class TTSRequest(BaseModel):
 
315
  faithfulness_passed=turn.faithfulness_passed,
316
  faithfulness_reasons=turn.faithfulness_reasons,
317
  blocked=turn.blocked,
318
+ profile_updates=turn.profile_updates,
319
  )
320
 
321
 
backend/orchestrator.py CHANGED
@@ -117,6 +117,7 @@ class TurnResult:
117
  faithfulness_passed: bool = True
118
  faithfulness_reasons: list[str] = field(default_factory=list)
119
  blocked: bool = False
 
120
 
121
 
122
  async def handle_turn(
@@ -204,6 +205,52 @@ async def handle_turn(
204
  session.set_awaiting(None)
205
  session.free_form_session = True
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  # 2. Retrieve β€” pass session_id so the user's profile chunk (stored in
208
  # Chroma at POST /api/profile time) gets boosted to the top of the
209
  # context. Without session_id this path is dormant and the brain never
@@ -353,4 +400,5 @@ async def handle_turn(
353
  faithfulness_passed=verdict.passed,
354
  faithfulness_reasons=verdict.reasons,
355
  blocked=blocked,
 
356
  )
 
117
  faithfulness_passed: bool = True
118
  faithfulness_reasons: list[str] = field(default_factory=list)
119
  blocked: bool = False
120
+ profile_updates: dict = field(default_factory=dict)
121
 
122
 
123
  async def handle_turn(
 
205
  session.set_awaiting(None)
206
  session.free_form_session = True
207
 
208
+ # 1c. CONVERSATIONAL PROFILE UPDATES (free-form mode)
209
+ # In free-form chat the user often shares new profile facts ("I just turned 40",
210
+ # "we had a baby", "I was diagnosed with diabetes"). Run a lightweight LLM
211
+ # extractor, apply high-confidence updates to session.profile, and re-upsert
212
+ # the profile chunk so THIS turn's retrieval reflects the new state.
213
+ profile_updates_applied: dict = {}
214
+ try:
215
+ from backend.profile_extractor import extract_profile_updates
216
+ extracted = await extract_profile_updates(user_text, session.profile)
217
+ if extracted:
218
+ for field_name, new_value in extracted.items():
219
+ if field_name == "health_conditions":
220
+ existing = list(session.profile.health_conditions or [])
221
+ existing_lower = {c.lower() for c in existing if c}
222
+ merged = list(existing)
223
+ for cond in new_value:
224
+ if cond.lower() not in existing_lower:
225
+ merged.append(cond)
226
+ existing_lower.add(cond.lower())
227
+ session.update_profile_field("health_conditions", merged)
228
+ profile_updates_applied["health_conditions"] = merged
229
+ else:
230
+ session.update_profile_field(field_name, new_value)
231
+ profile_updates_applied[field_name] = new_value
232
+ # Re-upsert profile chunk so retrieval sees fresh profile THIS turn
233
+ try:
234
+ from backend.profile_rag import upsert_profile_chunk
235
+ profile_dict_for_chunk = {
236
+ "age": session.profile.age,
237
+ "dependents": session.profile.dependents,
238
+ "income_band": session.profile.income_band,
239
+ "existing_cover_inr": session.profile.existing_cover_inr,
240
+ "primary_goal": session.profile.primary_goal,
241
+ "location_tier": session.profile.location_tier,
242
+ "parents_to_insure": session.profile.parents_to_insure,
243
+ "parents_age_max": session.profile.parents_age_max,
244
+ "parents_has_ped": session.profile.parents_has_ped,
245
+ "budget_band": session.profile.budget_band,
246
+ "health_conditions": session.profile.health_conditions,
247
+ }
248
+ await upsert_profile_chunk(session_id or "anonymous", profile_dict_for_chunk)
249
+ except Exception:
250
+ pass # chunk upsert failure must not block the chat
251
+ except Exception:
252
+ pass # extraction failure must never block the chat
253
+
254
  # 2. Retrieve β€” pass session_id so the user's profile chunk (stored in
255
  # Chroma at POST /api/profile time) gets boosted to the top of the
256
  # context. Without session_id this path is dormant and the brain never
 
400
  faithfulness_passed=verdict.passed,
401
  faithfulness_reasons=verdict.reasons,
402
  blocked=blocked,
403
+ profile_updates=profile_updates_applied,
404
  )
backend/profile_extractor.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conversational profile updates in free-form chat.
2
+
3
+ After fact-find onboarding completes, users often share new profile facts
4
+ in ordinary conversation β€” "I just turned 40", "we had a baby last month",
5
+ "I was diagnosed with diabetes". Vanilla retrieval doesn't know to update
6
+ the session profile from these utterances.
7
+
8
+ This module runs a lightweight LLM extractor on each free-form user message
9
+ to pull out any concrete profile updates the user just revealed. High-
10
+ confidence updates get applied to session.profile + re-upserted as the
11
+ profile chunk in Chroma, so subsequent retrieval (and the brain's reply
12
+ to THIS same turn) reflect the new state.
13
+
14
+ Design choices:
15
+ - Cheap-tier NIM model (Llama 3.3 70B) β€” extraction doesn't need the frontier.
16
+ - Conservative validation: drop any field that fails type/enum/bounds checks.
17
+ - Health conditions are MERGED with existing list (additive, deduped).
18
+ - Extraction failure NEVER blocks the chat β€” falls back to no-update silently.
19
+ - Enum values match backend/needs_finder.py::Profile exactly (under_5L / first_buy / ...).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ from typing import Any
26
+
27
+ from backend.needs_finder import Profile
28
+
29
+ _EXTRACTOR_SYSTEM = """You extract profile updates from a single user message.
30
+
31
+ Output a JSON object containing ONLY fields the user EXPLICITLY revealed in this single message. Use null/omit for unmentioned fields.
32
+
33
+ Fields and allowed values:
34
+ - age: integer (years, 1-120)
35
+ - dependents: one of "self", "self+spouse", "self+spouse+kids", "self+parents", "self+spouse+kids+parents"
36
+ - income_band: one of "under_5L", "5L-10L", "10L-25L", "25L+"
37
+ - existing_cover_inr: integer (current sum insured in rupees, e.g. 500000 for 5 lakh)
38
+ - primary_goal: one of "first_buy", "upgrade", "compare_specific", "tax_planning"
39
+ - location_tier: one of "metro", "tier1", "tier2", "tier3"
40
+ - parents_to_insure: boolean
41
+ - parents_age_max: integer (30-120, oldest parent's age in years)
42
+ - parents_has_ped: boolean (true if any parent has pre-existing disease)
43
+ - budget_band: one of "under_15k", "15k_30k", "30k_60k", "60k+"
44
+ - health_conditions: list of NEW condition strings the user just mentioned (additive β€” do not echo old ones)
45
+
46
+ Rules:
47
+ 1. Only extract what the user EXPLICITLY stated in this message. Never infer.
48
+ 2. Be conservative β€” when ambiguous, omit the field. Wrong updates are worse than missed ones.
49
+ 3. If the user said nothing new about their profile, return an empty object: {}
50
+ 4. Output the JSON object only. No prose. No code fences. No <think> blocks."""
51
+
52
+
53
+ _EXTRACTOR_USER_TEMPLATE = """User just said:
54
+ \"\"\"
55
+ {user_text}
56
+ \"\"\"
57
+
58
+ Current known profile (for context, do NOT echo unchanged fields back):
59
+ {profile_summary}
60
+
61
+ Return JSON of any NEW profile facts revealed in the user's message above."""
62
+
63
+
64
+ async def extract_profile_updates(
65
+ user_text: str,
66
+ current_profile: Profile,
67
+ ) -> dict[str, Any]:
68
+ """Return validated dict of {field_name: new_value}.
69
+
70
+ Empty dict means nothing extractable. Never raises.
71
+ """
72
+ if not user_text or not user_text.strip():
73
+ return {}
74
+
75
+ from backend.providers.nvidia_nim_llm import NvidiaNimLLM
76
+ from backend.providers.base import ChatMessage
77
+
78
+ summary_parts = []
79
+ for k, v in current_profile.__dict__.items():
80
+ if v in (None, "", []) or k in ("asked", "free_form_session"):
81
+ continue
82
+ summary_parts.append(f"{k}={v}")
83
+ profile_summary = ", ".join(summary_parts) or "(empty)"
84
+
85
+ messages = [
86
+ ChatMessage(role="system", content=_EXTRACTOR_SYSTEM),
87
+ ChatMessage(
88
+ role="user",
89
+ content=_EXTRACTOR_USER_TEMPLATE.format(
90
+ user_text=user_text[:1500],
91
+ profile_summary=profile_summary[:500],
92
+ ),
93
+ ),
94
+ ]
95
+
96
+ try:
97
+ llm = NvidiaNimLLM(model="meta/llama-3.3-70b-instruct")
98
+ result = await llm.chat(messages=messages, temperature=0.0, max_tokens=300)
99
+ raw = (result.text or "").strip()
100
+ except Exception as e:
101
+ logging.warning("profile_extractor LLM call failed: %s: %s", type(e).__name__, e)
102
+ return {}
103
+
104
+ # Strip code fences / think blocks if model added them despite instructions
105
+ if "<think>" in raw and "</think>" in raw:
106
+ raw = raw.split("</think>", 1)[1].strip()
107
+ if raw.startswith("```"):
108
+ lines = [l for l in raw.split("\n") if not l.startswith("```")]
109
+ raw = "\n".join(lines).strip()
110
+ if not raw.startswith("{"):
111
+ return {}
112
+
113
+ try:
114
+ parsed = json.loads(raw)
115
+ except json.JSONDecodeError:
116
+ return {}
117
+
118
+ if not isinstance(parsed, dict):
119
+ return {}
120
+
121
+ return _validate(parsed)
122
+
123
+
124
+ _ALLOWED_FIELDS: dict[str, type] = {
125
+ "age": int,
126
+ "dependents": str,
127
+ "income_band": str,
128
+ "existing_cover_inr": int,
129
+ "primary_goal": str,
130
+ "location_tier": str,
131
+ "parents_to_insure": bool,
132
+ "parents_age_max": int,
133
+ "parents_has_ped": bool,
134
+ "budget_band": str,
135
+ "health_conditions": list,
136
+ }
137
+
138
+
139
+ _ENUM_VALUES: dict[str, set[str]] = {
140
+ "dependents": {"self", "self+spouse", "self+spouse+kids", "self+parents", "self+spouse+kids+parents"},
141
+ "income_band": {"under_5L", "5L-10L", "10L-25L", "25L+"},
142
+ "primary_goal": {"first_buy", "upgrade", "compare_specific", "tax_planning"},
143
+ "location_tier": {"metro", "tier1", "tier2", "tier3"},
144
+ "budget_band": {"under_15k", "15k_30k", "30k_60k", "60k+"},
145
+ }
146
+
147
+
148
+ def _validate(updates: dict) -> dict:
149
+ """Coerce types, enforce enums, drop anything that fails."""
150
+ clean: dict[str, Any] = {}
151
+ for k, v in updates.items():
152
+ if k not in _ALLOWED_FIELDS or v is None:
153
+ continue
154
+
155
+ expected = _ALLOWED_FIELDS[k]
156
+ try:
157
+ if expected is int:
158
+ if isinstance(v, bool):
159
+ continue
160
+ v = int(v)
161
+ elif expected is bool:
162
+ if not isinstance(v, bool):
163
+ continue
164
+ elif expected is str:
165
+ if not isinstance(v, str):
166
+ continue
167
+ v = v.strip()
168
+ if not v:
169
+ continue
170
+ elif expected is list:
171
+ if not isinstance(v, list):
172
+ continue
173
+ v = [str(x).strip().lower() for x in v if x and isinstance(x, (str, int))]
174
+ v = [c for c in v if c]
175
+ if not v:
176
+ continue
177
+ except (TypeError, ValueError):
178
+ continue
179
+
180
+ if k in _ENUM_VALUES and v not in _ENUM_VALUES[k]:
181
+ continue
182
+ if k == "age" and not (1 <= v <= 120):
183
+ continue
184
+ if k == "parents_age_max" and not (30 <= v <= 120):
185
+ continue
186
+ if k == "existing_cover_inr" and v < 0:
187
+ continue
188
+
189
+ clean[k] = v
190
+
191
+ return clean