rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
4449d44
·
1 Parent(s): a774b91

feat: KI-039 + KI-040 — single Clear-chat (keep profile) + named profiles

Browse files

KI-039 — Single Clear-chat button. The previous two-button setup
("Clear chat" / "Start fresh") confused users. Now one button labeled
"Clear chat" wipes the visible conversation but KEEPS the server-side
profile, so the bot doesn't lose context — next turn picks up with the
existing profile loaded. The user-stated rationale: "the LLM still knows
what conversations, what questions have already been answered, and it
can speak in a natural manner."

KI-040 — Named profiles for cross-session identity.

Backend changes:
• backend/profile_store.py (NEW) — JSON-keyed by normalised name.
save_profile / load_profile / list_profiles + is_valid_name.
Files at data/profiles/<slug>.json. Display name preserved inside
each file. Atomic write via .tmp + replace.
• backend/needs_finder.py — Profile dataclass gains `name: Optional[str]`.
GRAPH gets a new slot 0 ("name") that asks "First — what should I
call you?" before age. Free-text, validated 1-50 chars 50%+ alpha.
• backend/orchestrator.py — name-slot special handling:
(a) tolerates conversational prefixes ("I'm Rohit", "My name is",
"call me", "this is") and strips them before storing,
(b) auto-capitalises a sensible display form when the user typed
all-lowercase,
(c) on successful name capture, calls profile_store.load_profile.
If it returns a stored profile with material content, MERGES
every populated field into session.profile, marks every slot
as already-asked, flips free_form_session=True, and short-
circuits with a "Welcome back, <name>!" reply (brain_tag
`needs_finder::welcome_back`) — skipping the full 9-question
walk for returning visitors,
(d) on any other slot capture, persists the updated profile back
to the JSON store so the next visit sees the latest state.

Architecture note (per the user's "embed or JSON?" question): JSON is
the canonical store for name lookup (O(1) deterministic); Chroma's
profile chunk (existing upsert_profile_chunk in profile_rag.py) stays
the retrieval-time context layer (re-embedded on profile change, not
per query). Both layers stay in sync.

Frontend change:
• Two-button Clear chat / Start fresh consolidated to one "Clear chat"
button. Calls handleClearChat(false) — drop_profile stays false.

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

backend/needs_finder.py CHANGED
@@ -30,6 +30,7 @@ from typing import Any, Optional
30
  @dataclass
31
  class Profile:
32
  """User profile accumulated during fact-find."""
 
33
  age: Optional[int] = None
34
  dependents: Optional[str] = None # "self", "self+spouse", "self+spouse+kids", "self+parents", etc.
35
  income_band: Optional[str] = None # "under_5L", "5L-10L", "10L-25L", "25L+"
@@ -66,6 +67,14 @@ def _always(p: Profile) -> bool:
66
 
67
 
68
  GRAPH: list[Question] = [
 
 
 
 
 
 
 
 
69
  Question(
70
  id="age",
71
  prompt_en="First, your age? (Premium + eligibility + how long you can renew all hinge on this.)",
 
30
  @dataclass
31
  class Profile:
32
  """User profile accumulated during fact-find."""
33
+ name: Optional[str] = None # KI-040 — humanise + key for cross-session lookup
34
  age: Optional[int] = None
35
  dependents: Optional[str] = None # "self", "self+spouse", "self+spouse+kids", "self+parents", etc.
36
  income_band: Optional[str] = None # "under_5L", "5L-10L", "10L-25L", "25L+"
 
67
 
68
 
69
  GRAPH: list[Question] = [
70
+ Question(
71
+ id="name",
72
+ prompt_en="First — what should I call you? I'll save your profile so the next time you visit, you won't have to answer all this again.",
73
+ prompt_hi="सबसे पहले — आपको क्या कहूँ? आपकी profile save हो जाएगी, तो अगली बार दोबारा सवाल नहीं पूछूँगा।",
74
+ field="name",
75
+ is_core=True,
76
+ # Free-text — no parser; the orchestrator validates 1-50 chars
77
+ ),
78
  Question(
79
  id="age",
80
  prompt_en="First, your age? (Premium + eligibility + how long you can renew all hinge on this.)",
backend/orchestrator.py CHANGED
@@ -320,10 +320,73 @@ async def handle_turn(
320
  # the slot-filler was actually working — see readback summaries in
321
  # `needs_finder::fact_find_complete` turns).
322
  fact_find_profile_updates: dict = {}
 
 
 
 
 
323
  if session.awaiting_question_id:
324
  from backend.fact_find_normalizer import is_valid_answer, normalize_answer
325
  qid = session.awaiting_question_id
326
- if not is_valid_answer(user_text):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  ambiguous_or_failed = True
328
  else:
329
  try:
@@ -344,6 +407,14 @@ async def handle_turn(
344
  # Reset re-ask counter on success
345
  if hasattr(session, "_reask_counts"):
346
  session._reask_counts.pop(qid, None)
 
 
 
 
 
 
 
 
347
  else:
348
  ambiguous_or_failed = True
349
 
@@ -359,6 +430,24 @@ async def handle_turn(
359
  session.set_awaiting(None)
360
  ambiguous_or_failed = False # no longer a reask situation
361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  # If the answer didn't normalize, pick the SAME question again (re-ask
363
  # with a gentle clarifier) instead of moving on with garbage — UNLESS
364
  # the cap above just kicked in, in which case we move on.
 
320
  # the slot-filler was actually working — see readback summaries in
321
  # `needs_finder::fact_find_complete` turns).
322
  fact_find_profile_updates: dict = {}
323
+ # KI-040 — named-profile return-visit detection. Set when we resolve
324
+ # a name and find a stored profile for that name; used at the bottom
325
+ # of this branch to emit a "Welcome back" message instead of the
326
+ # next slot's question.
327
+ returning_visitor_greeting: Optional[str] = None
328
  if session.awaiting_question_id:
329
  from backend.fact_find_normalizer import is_valid_answer, normalize_answer
330
  qid = session.awaiting_question_id
331
+ # KI-040 — special handling for the name slot: free-text, no enum.
332
+ if qid == "name":
333
+ from backend.profile_store import is_valid_name, load_profile, save_profile
334
+ raw_name = user_text.strip().strip(".,!?")
335
+ # Tolerate "I'm Rohit" / "My name is Rohit" / "call me Rohit"
336
+ for prefix in ("i'm ", "i am ", "my name is ", "name is ", "call me ", "this is "):
337
+ if raw_name.lower().startswith(prefix):
338
+ raw_name = raw_name[len(prefix):].strip()
339
+ break
340
+ # Capitalize a sensible display form
341
+ if raw_name and not any(c.isupper() for c in raw_name):
342
+ raw_name = " ".join(w.capitalize() for w in raw_name.split())
343
+ if is_valid_name(raw_name):
344
+ session.update_profile_field("name", raw_name)
345
+ fact_find_profile_updates["name"] = raw_name
346
+ if qid not in session.profile.asked:
347
+ session.profile.asked.append(qid)
348
+ session.set_awaiting(None)
349
+ if hasattr(session, "_reask_counts"):
350
+ session._reask_counts.pop(qid, None)
351
+ # Look up an existing profile for this name. If found,
352
+ # MERGE its fields into the current empty session so the
353
+ # user doesn't repeat the 9-question walk.
354
+ stored = load_profile(raw_name)
355
+ if stored is not None and any(
356
+ getattr(stored, f, None) not in (None, [], "")
357
+ for f in ("age", "dependents", "income_band", "primary_goal")
358
+ ):
359
+ for field_name in (
360
+ "age", "dependents", "income_band", "existing_cover_inr",
361
+ "primary_goal", "location_tier", "parents_to_insure",
362
+ "parents_age_max", "parents_has_ped", "health_conditions",
363
+ "budget_band",
364
+ ):
365
+ val = getattr(stored, field_name, None)
366
+ if val not in (None, [], ""):
367
+ session.update_profile_field(field_name, val)
368
+ fact_find_profile_updates[field_name] = val
369
+ # Mark all stored slots as already-asked so next_question
370
+ # skips them.
371
+ for slot_id in ("age", "dependents", "income_band",
372
+ "existing_cover", "primary_goal", "location",
373
+ "parents_age", "health_conditions", "budget"):
374
+ if slot_id not in session.profile.asked:
375
+ session.profile.asked.append(slot_id)
376
+ session.free_form_session = True
377
+ session._flush()
378
+ returning_visitor_greeting = (
379
+ f"Welcome back, {raw_name}! I've loaded your profile from last time. "
380
+ "Want me to suggest some policies that fit your situation, or do "
381
+ "you have a specific question in mind?"
382
+ )
383
+ else:
384
+ # New visitor — record an initial profile so subsequent
385
+ # turns persist; orchestrator will continue to next slot.
386
+ save_profile(raw_name, session.profile, session_id=session_id)
387
+ else:
388
+ ambiguous_or_failed = True
389
+ elif not is_valid_answer(user_text):
390
  ambiguous_or_failed = True
391
  else:
392
  try:
 
407
  # Reset re-ask counter on success
408
  if hasattr(session, "_reask_counts"):
409
  session._reask_counts.pop(qid, None)
410
+ # KI-040 — persist the updated profile to JSON store
411
+ # so next-visit lookup-by-name has the latest data.
412
+ if session.profile.name:
413
+ try:
414
+ from backend.profile_store import save_profile
415
+ save_profile(session.profile.name, session.profile, session_id=session_id)
416
+ except Exception:
417
+ pass
418
  else:
419
  ambiguous_or_failed = True
420
 
 
430
  session.set_awaiting(None)
431
  ambiguous_or_failed = False # no longer a reask situation
432
 
433
+ # KI-040 — returning-visitor short-circuit. If we recognised the user's
434
+ # name and loaded their stored profile, skip directly to the greeting
435
+ # without picking another fact-find question.
436
+ if returning_visitor_greeting:
437
+ return TurnResult(
438
+ reply_text=returning_visitor_greeting,
439
+ citations=[],
440
+ retrieved_chunk_ids=[],
441
+ brain_used="needs_finder::welcome_back",
442
+ intent="fact_find",
443
+ language=language,
444
+ latency_ms=int((time.time() - t0) * 1000),
445
+ raw_reply=returning_visitor_greeting,
446
+ faithfulness_passed=True,
447
+ blocked=False,
448
+ profile_updates=fact_find_profile_updates,
449
+ )
450
+
451
  # If the answer didn't normalize, pick the SAME question again (re-ask
452
  # with a gentle clarifier) instead of moving on with garbage — UNLESS
453
  # the cap above just kicked in, in which case we move on.
backend/profile_store.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistent name-keyed profile store.
2
+
3
+ KI-040 (2026-05-14). Lets returning visitors say their name and have the
4
+ bot recognise them + auto-load their stored profile, so they don't have
5
+ to walk the 9-slot fact-find again.
6
+
7
+ Architectural answer to the "embed or JSON?" question:
8
+
9
+ • JSON (this module) — the canonical store, keyed by normalised name.
10
+ O(1) lookup, deterministic, human-readable, manually editable.
11
+ • Chroma vector chunk (existing backend/profile_rag.py) — re-embedded
12
+ when the profile changes, so retrieval-time the brain sees the
13
+ user's profile alongside policy chunks for the "what's best for me?"
14
+ style questions. Embedding cost = once per update, not per query.
15
+
16
+ Both layers stay in sync: when `save_profile()` is called here, the
17
+ orchestrator also fires `profile_rag.upsert_profile_chunk()` so the
18
+ Chroma side reflects the new state.
19
+
20
+ Files live under `data/profiles/<normalised-name>.json`. Names are
21
+ normalised to lowercase + alpha-only for the filename so "Rohit" and
22
+ "rohit." both resolve to the same profile. The original (capitalised)
23
+ display name is preserved inside the JSON.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import logging
30
+ import re
31
+ import time
32
+ from dataclasses import asdict
33
+ from pathlib import Path
34
+ from typing import Optional
35
+
36
+ from backend.config import settings
37
+ from backend.needs_finder import Profile
38
+
39
+ _PROFILES_DIR = settings.CORPUS_DIR.parent.parent / "data" / "profiles"
40
+
41
+
42
+ def _normalise_name(name: str) -> str:
43
+ """Lowercase + strip to alphanumerics. 'Rohit Sharma' → 'rohit-sharma'."""
44
+ if not name:
45
+ return ""
46
+ cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", name.strip().lower()).strip("-")
47
+ return cleaned[:60] # cap filename length
48
+
49
+
50
+ def _path_for(name: str) -> Optional[Path]:
51
+ slug = _normalise_name(name)
52
+ if not slug:
53
+ return None
54
+ return _PROFILES_DIR / f"{slug}.json"
55
+
56
+
57
+ def is_valid_name(text: str) -> bool:
58
+ """Heuristic name validation. Rejects empty, too long, mostly-non-alpha."""
59
+ if not text:
60
+ return False
61
+ s = text.strip()
62
+ if not (1 <= len(s) <= 50):
63
+ return False
64
+ # At least 60% alphabetic
65
+ alpha = sum(1 for c in s if c.isalpha())
66
+ return alpha / max(1, len(s)) >= 0.5
67
+
68
+
69
+ def load_profile(name: str) -> Optional[Profile]:
70
+ """Return the stored Profile for `name`, or None if no record exists.
71
+
72
+ Future-proofs against Profile schema drift — any persisted fields that no
73
+ longer exist on the Profile dataclass are silently dropped.
74
+ """
75
+ p = _path_for(name)
76
+ if not p or not p.exists():
77
+ return None
78
+ try:
79
+ raw = json.loads(p.read_text())
80
+ except Exception as e:
81
+ logging.warning("profile_store load failed name=%s: %s", name, e)
82
+ return None
83
+ prof_dict = raw.get("profile") or {}
84
+ valid_fields = set(Profile.__dataclass_fields__.keys())
85
+ prof_dict = {k: v for k, v in prof_dict.items() if k in valid_fields}
86
+ try:
87
+ return Profile(**prof_dict)
88
+ except Exception as e:
89
+ logging.warning("profile_store reconstruct failed name=%s: %s", name, e)
90
+ return None
91
+
92
+
93
+ def save_profile(name: str, profile: Profile, *, session_id: Optional[str] = None) -> bool:
94
+ """Persist `profile` keyed by `name`. Returns True on success."""
95
+ p = _path_for(name)
96
+ if not p:
97
+ return False
98
+ try:
99
+ _PROFILES_DIR.mkdir(parents=True, exist_ok=True)
100
+ existing: dict = {}
101
+ if p.exists():
102
+ try:
103
+ existing = json.loads(p.read_text())
104
+ except Exception:
105
+ existing = {}
106
+ now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
107
+ sessions = list(existing.get("sessions") or [])
108
+ if session_id and session_id not in sessions:
109
+ sessions.append(session_id)
110
+ sessions = sessions[-20:] # keep last 20 only
111
+ payload = {
112
+ "name_display": (profile.name or name).strip(),
113
+ "name_slug": _normalise_name(name),
114
+ "profile": asdict(profile),
115
+ "first_seen": existing.get("first_seen") or now_iso,
116
+ "last_seen": now_iso,
117
+ "sessions": sessions,
118
+ }
119
+ tmp = p.with_suffix(".json.tmp")
120
+ tmp.write_text(json.dumps(payload, indent=2, default=str))
121
+ tmp.replace(p)
122
+ return True
123
+ except Exception as e:
124
+ logging.warning("profile_store save failed name=%s: %s", name, e)
125
+ return False
126
+
127
+
128
+ def list_profiles() -> list[dict]:
129
+ """Return summary of all stored profiles — used by the admin Profile +
130
+ Visitor Log view. One entry per file."""
131
+ if not _PROFILES_DIR.exists():
132
+ return []
133
+ out: list[dict] = []
134
+ for p in sorted(_PROFILES_DIR.glob("*.json")):
135
+ try:
136
+ raw = json.loads(p.read_text())
137
+ out.append({
138
+ "name_display": raw.get("name_display"),
139
+ "name_slug": raw.get("name_slug"),
140
+ "first_seen": raw.get("first_seen"),
141
+ "last_seen": raw.get("last_seen"),
142
+ "session_count": len(raw.get("sessions") or []),
143
+ "profile_complete_fields": sum(
144
+ 1 for v in (raw.get("profile") or {}).values()
145
+ if v not in (None, "", [], 0)
146
+ ),
147
+ })
148
+ except Exception:
149
+ continue
150
+ return out
frontend/src/app/page.tsx CHANGED
@@ -691,16 +691,18 @@ export default function Page() {
691
  <EmptyState onSuggest={(q) => send(q)} coverage={coverage} t={t} />
692
  ) : (
693
  <>
694
- {/* KI-020 / KI-039 — single Clear-chat control. One click wipes
695
- the visible chat AND clears server-side profile AND issues a
696
- fresh session id. Two-button variant was confusing per user
697
- feedback. */}
 
 
698
  <div className="flex items-center justify-end gap-2 mb-2 text-[11px]">
699
  <button
700
- onClick={() => handleClearChat(true)}
701
  disabled={busy}
702
- className="px-2 py-1 rounded-md border border-[var(--border)] text-[var(--muted-foreground)] hover:text-rose-600 hover:border-rose-400 disabled:opacity-40 transition"
703
- title="Wipe this conversation — clears chat history, profile, and starts a fresh session"
704
  >
705
  Clear chat
706
  </button>
 
691
  <EmptyState onSuggest={(q) => send(q)} coverage={coverage} t={t} />
692
  ) : (
693
  <>
694
+ {/* KI-020 / KI-039 / KI-040 — single Clear-chat control. One
695
+ click wipes the visible chat history but KEEPS the server-side
696
+ profile so the bot doesn't have to re-fact-find the user. The
697
+ bot will pick up with the existing profile on the next turn.
698
+ (A named-profile feature for fully-different identities is on
699
+ the roadmap — see backend/profile_store.py.) */}
700
  <div className="flex items-center justify-end gap-2 mb-2 text-[11px]">
701
  <button
702
+ onClick={() => handleClearChat(false)}
703
  disabled={busy}
704
+ className="px-2 py-1 rounded-md border border-[var(--border)] text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:border-[var(--primary)] disabled:opacity-40 transition"
705
+ title="Clear the visible chat. Your profile is preserved so the bot picks up where it left off."
706
  >
707
  Clear chat
708
  </button>