rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
2368504
Β·
1 Parent(s): deefe3a

#25/#26: returning-user recall any-turn + mid-conversation state recovery

Browse files

#25 β€” returning user never recognised. Recall probe was gated to
_current_turn==1, but fact-find asks the name in the bot's FIRST reply
so the name lands on turn >=2; compounded by extract_potential_name
needing an "I'm X" preamble and a two-token name slugging past the
stored first-name file. Fix: probe whenever the LLM-captured
session.profile.name is first known (any turn) + first-name slug
fallback in rehydrate_by_name; one-shot guarded (recall_probe_done),
declined recall never re-offered. Privacy design (stage + explicit
confirm, no auto-merge) preserved.

#26 β€” profile lost mid-conversation. In-memory sessions (1h TTL, KI-118
removed disk persistence) evict on HF container restart/idle ->
get_session returns blank -> bot says "I seem to have lost ... What's
your name?". Fix (chosen design): when the live profile is blank but
the client still carries chat_history, inject STATE-RECOVERY MODE so
the model silently re-captures facts from history and continues β€”
never re-asking the name / never admitting a loss.

Regression tests replay both exact live flows; red-green verified;
full suite green.

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

backend/session_state.py CHANGED
@@ -88,6 +88,13 @@ class SessionState:
88
  # explicitly declines the pricing inputs; bypasses the re-ask.
89
  pricing_bundle_reasked: bool = False
90
  pricing_bundle_skipped: bool = False
 
 
 
 
 
 
 
91
 
92
  def _flush(self) -> None:
93
  """No-op. Session state lives only in the in-memory dict; the
@@ -180,7 +187,17 @@ def rehydrate_by_name(session: SessionState, name: str) -> bool:
180
  from backend.profile_store import load_profile
181
  stored = load_profile(name)
182
  if stored is None:
183
- return False
 
 
 
 
 
 
 
 
 
 
184
 
185
  # Build a non-PII-leaking identity summary so the brain can ask
186
  # "are you <name>?" without putting anything on the live profile.
@@ -238,6 +255,9 @@ def apply_pending_recall(session: SessionState, *, confirmed: bool) -> bool:
238
  return False
239
  # Resolve the staging regardless of outcome.
240
  session.pending_profile_recall = None
 
 
 
241
  if not confirmed:
242
  return False
243
  stored_fields = pending.get("stored_fields") or {}
 
88
  # explicitly declines the pricing inputs; bypasses the re-ask.
89
  pricing_bundle_reasked: bool = False
90
  pricing_bundle_skipped: bool = False
91
+ # Bug #25 (2026-05-19) β€” one-shot guard for returning-user recall.
92
+ # The old wiring only probed on turn 1, but the fact-find asks the
93
+ # name in the bot's FIRST reply, so the name lands on turn >=2 and
94
+ # recall never fired. The probe now runs whenever the name is first
95
+ # known (any turn); this flag stops it re-staging every subsequent
96
+ # turn and stops a declined recall from being re-offered.
97
+ recall_probe_done: bool = False
98
 
99
  def _flush(self) -> None:
100
  """No-op. Session state lives only in the in-memory dict; the
 
187
  from backend.profile_store import load_profile
188
  stored = load_profile(name)
189
  if stored is None:
190
+ # Bug #25 (2026-05-19): a multi-token capture ("Rohit Sar")
191
+ # slugs to "rohit-sar" and misses the stored first-name file
192
+ # ("rohit.json"). Fall back to the first name token. Still
193
+ # privacy-safe β€” this only STAGES a match; the user must
194
+ # explicitly confirm the identity summary before any merge.
195
+ _stripped = (name or "").strip()
196
+ _first = _stripped.split()[0] if _stripped else ""
197
+ if _first and _first.lower() != _stripped.lower():
198
+ stored = load_profile(_first)
199
+ if stored is None:
200
+ return False
201
 
202
  # Build a non-PII-leaking identity summary so the brain can ask
203
  # "are you <name>?" without putting anything on the live profile.
 
255
  return False
256
  # Resolve the staging regardless of outcome.
257
  session.pending_profile_recall = None
258
+ # Bug #25 (2026-05-19) β€” a confirmed OR denied recall is final for
259
+ # this session; never auto-re-stage / re-offer it on a later turn.
260
+ session.recall_probe_done = True
261
  if not confirmed:
262
  return False
263
  stored_fields = pending.get("stored_fields") or {}
backend/single_brain.py CHANGED
@@ -775,6 +775,7 @@ def _affirm_or_deny(text: str):
775
  def _system_instruction(
776
  profile, is_returning_user: bool = False, shortlist_block: str = "",
777
  pending_recall: "Optional[dict]" = None, recall_applied: bool = False,
 
778
  ) -> dict:
779
  """Bake the profile snapshot into the system prompt so each turn the
780
  LLM knows what's already captured. Returned in Gemini's expected
@@ -864,9 +865,31 @@ def _system_instruction(
864
  "straight to retrieve_policies + recommendations. Ask ONLY for "
865
  "a slot that is genuinely ABSENT above β€” never one present."
866
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
867
  text = (
868
- SYSTEM_PROMPT + extra + recall_block + restored_block
869
- + (shortlist_block or "")
870
  )
871
  return {"parts": [{"text": text}]}
872
 
@@ -1887,12 +1910,29 @@ async def handle_turn(
1887
  _pending_recall = None
1888
  # ambiguous β†’ leave staged; the confirm block is re-injected
1889
  # below and the LLM re-asks the "are you <name>?" question.
1890
- elif _current_turn == 1:
1891
- _nm = extract_potential_name(user_text or "")
 
 
 
 
 
 
 
 
 
 
 
 
 
1892
  if _nm:
 
 
 
1893
  # Stages session.pending_profile_recall iff a stored
1894
  # profile for this name exists (no match β‡’ no-op, normal
1895
  # fresh-user flow continues β€” no false confirm prompt).
 
1896
  try_recall_by_name(session, _nm)
1897
  _pending_recall = getattr(
1898
  session, "pending_profile_recall", None
@@ -1913,6 +1953,27 @@ async def handle_turn(
1913
  )
1914
  is_returning_user = (_current_turn == 1) and _has_prior_profile
1915
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1916
  # GOOGLE_API_KEY gate β€” asserted just before anything that talks to
1917
  # Gemini.
1918
  api_key = os.environ.get("GOOGLE_API_KEY", "").strip()
@@ -1958,6 +2019,7 @@ async def handle_turn(
1958
  shortlist_block=_shortlist_block,
1959
  pending_recall=_pending_recall,
1960
  recall_applied=_did_recall_this_turn,
 
1961
  )
1962
 
1963
  # Bug #108 + #110 β€” if the user explicitly declines the pricing /
 
775
  def _system_instruction(
776
  profile, is_returning_user: bool = False, shortlist_block: str = "",
777
  pending_recall: "Optional[dict]" = None, recall_applied: bool = False,
778
+ reconstruct_from_history: bool = False,
779
  ) -> dict:
780
  """Bake the profile snapshot into the system prompt so each turn the
781
  LLM knows what's already captured. Returned in Gemini's expected
 
865
  "straight to retrieve_policies + recommendations. Ask ONLY for "
866
  "a slot that is genuinely ABSENT above β€” never one present."
867
  )
868
+ reconstruct_block = ""
869
+ if reconstruct_from_history:
870
+ reconstruct_block = (
871
+ "\n\n══════════ STATE-RECOVERY MODE ══════════\n"
872
+ "Your in-memory session was reset (server restart / idle "
873
+ "timeout), so the profile snapshot above is empty β€” BUT the "
874
+ "CONVERSATION HISTORY in this request still contains every "
875
+ "fact this user already gave you. This is NOT a new user.\n"
876
+ "ABSOLUTE RULES this turn:\n"
877
+ " 1. Do NOT say you 'lost' / 'don't have' their information, "
878
+ "do NOT apologise for losing it, do NOT say 'let's re-confirm'.\n"
879
+ " 2. Do NOT re-ask the user's name, age, or ANY fact already "
880
+ "stated anywhere in the conversation history.\n"
881
+ " 3. SILENTLY re-capture every profile fact present in the "
882
+ "history by calling save_profile_field once per fact (name, "
883
+ "age, dependents, location, income, primary_goal, "
884
+ "health_conditions, and any pricing slots already given).\n"
885
+ " 4. THEN continue exactly where the conversation left off β€” "
886
+ "answer the user's latest message; if all 7 required slots are "
887
+ "now captured, proceed straight to retrieve_policies and "
888
+ "recommendations. The user must never perceive any loss."
889
+ )
890
  text = (
891
+ SYSTEM_PROMPT + extra + recall_block + reconstruct_block
892
+ + restored_block + (shortlist_block or "")
893
  )
894
  return {"parts": [{"text": text}]}
895
 
 
1910
  _pending_recall = None
1911
  # ambiguous β†’ leave staged; the confirm block is re-injected
1912
  # below and the LLM re-asks the "are you <name>?" question.
1913
+ elif not getattr(session, "recall_probe_done", False):
1914
+ # Bug #25 (2026-05-19) β€” recall must fire whenever the user's
1915
+ # NAME first becomes known, NOT only on turn 1. The fact-find
1916
+ # asks for the name in the bot's FIRST reply, so the user
1917
+ # supplies it on turn >=2; the old `_current_turn == 1` gate
1918
+ # skipped recall for the normal flow entirely, so a returning
1919
+ # user was never recognised. The LLM reliably persists the
1920
+ # name via save_profile_field, so `session.profile.name`
1921
+ # (captured on a prior turn) is the robust trigger; we ALSO
1922
+ # keep the free-text sniff for an explicit "I'm X" stated on
1923
+ # the very first message (before save_profile_field has run).
1924
+ _nm = (
1925
+ (getattr(session.profile, "name", None) or "").strip()
1926
+ or (extract_potential_name(user_text or "") or "")
1927
+ )
1928
  if _nm:
1929
+ # One-shot: never re-probe (the captured name won't
1930
+ # change) and never re-stage every subsequent turn.
1931
+ session.recall_probe_done = True
1932
  # Stages session.pending_profile_recall iff a stored
1933
  # profile for this name exists (no match β‡’ no-op, normal
1934
  # fresh-user flow continues β€” no false confirm prompt).
1935
+ # Still privacy-safe: STAGE only; explicit confirm merges.
1936
  try_recall_by_name(session, _nm)
1937
  _pending_recall = getattr(
1938
  session, "pending_profile_recall", None
 
1953
  )
1954
  is_returning_user = (_current_turn == 1) and _has_prior_profile
1955
 
1956
+ # Bug #26 (2026-05-19) β€” mid-conversation profile loss. Sessions are
1957
+ # in-memory only (session_state._TTL_SECONDS = 1h; KI-118 removed disk
1958
+ # persistence) so an HF container restart / >1h idle between turns
1959
+ # makes get_session() return a BLANK SessionState. next_question then
1960
+ # returns the hardcoded "What's your name?" and the LLM narrates "I
1961
+ # seem to have lost some of your profile information." Recovery
1962
+ # (user's chosen design): when the live profile is blank BUT the
1963
+ # client still carries the conversation, silently re-capture the
1964
+ # already-stated facts from chat_history instead of resetting. Guard:
1965
+ # >=2 history messages β‡’ this is NOT the genuine first turn, so a
1966
+ # blank profile means state was lost, not "fresh user".
1967
+ _reconstruct_from_history = (
1968
+ (not _has_prior_profile)
1969
+ and not is_returning_user
1970
+ and not _pending_recall
1971
+ and bool(chat_history)
1972
+ and len([m for m in (chat_history or [])
1973
+ if (m or {}).get("role") == "user"]) >= 1
1974
+ and len(chat_history) >= 2
1975
+ )
1976
+
1977
  # GOOGLE_API_KEY gate β€” asserted just before anything that talks to
1978
  # Gemini.
1979
  api_key = os.environ.get("GOOGLE_API_KEY", "").strip()
 
2019
  shortlist_block=_shortlist_block,
2020
  pending_recall=_pending_recall,
2021
  recall_applied=_did_recall_this_turn,
2022
+ reconstruct_from_history=_reconstruct_from_history,
2023
  )
2024
 
2025
  # Bug #108 + #110 β€” if the user explicitly declines the pricing /
tests/test_bug2526_recall_and_reconstruct.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression tests for the live bugs #25 and #26 (2026-05-19).
2
+
3
+ #25 β€” returning user NEVER recognised: the recall probe was gated to
4
+ `_current_turn == 1`, but the fact-find asks for the name in the bot's
5
+ FIRST reply, so the name lands on turn >=2 and the probe was skipped
6
+ entirely. Compounded by (b) `extract_potential_name` only matching an
7
+ "I'm X / my name is X" preamble (a bare "rohit sar" β†’ None) and (c) a
8
+ multi-token name slugging to "rohit-sar", missing the stored
9
+ "rohit.json". The fix: probe whenever the LLM-captured
10
+ `session.profile.name` is first known (any turn), with a first-name
11
+ slug fallback, one-shot guarded β€” STILL privacy-safe (STAGE + explicit
12
+ confirm; no auto-merge).
13
+
14
+ #26 β€” profile lost mid-conversation: in-memory sessions
15
+ (_TTL_SECONDS=1h, KI-118 removed disk persistence) get evicted on an HF
16
+ container restart / idle, so get_session() returns a BLANK session and
17
+ the bot says "I seem to have lost some of your profile information.
18
+ What's your name?". Fix (user-chosen): when the live profile is blank
19
+ but the client still carries chat_history, inject STATE-RECOVERY MODE so
20
+ the model silently re-captures the facts from history and continues β€”
21
+ never re-asking the name / never admitting a loss.
22
+
23
+ Each test is written so it FAILS on the pre-fix code (the exact gap
24
+ that let the bug ship) and passes only with the fix.
25
+ """
26
+ import asyncio
27
+ import os
28
+ import random
29
+ import string
30
+ import unittest
31
+ import uuid
32
+ from unittest import mock
33
+
34
+ from backend import single_brain
35
+ from backend.session_state import SessionState, apply_pending_recall
36
+ from backend.profile_persistence import try_recall_by_name # noqa: F401
37
+ from backend.profile_store import save_profile, _normalise_name, _PROFILES_DIR
38
+ from backend.needs_finder import Profile
39
+
40
+
41
+ def _run(coro):
42
+ return asyncio.new_event_loop().run_until_complete(coro)
43
+
44
+
45
+ def _text_payload(text):
46
+ return {"candidates": [{"content": {"parts": [{"text": text}]}}]}
47
+
48
+
49
+ class _FirstNameStoredFixture(unittest.TestCase):
50
+ """Stores a profile under a SINGLE-token name (like the real
51
+ 40-data/profiles/rohit.json) so a later two-token capture
52
+ ("Rohit Sar") must use the first-name slug fallback to resolve it."""
53
+
54
+ def setUp(self):
55
+ self.first = "Firstonly" + "".join(
56
+ random.choices(string.ascii_lowercase, k=7))
57
+ self.full = f"{self.first} Lastname" # what the user types
58
+ self.slug = _normalise_name(self.first)
59
+ p = Profile()
60
+ p.name = self.first
61
+ p.age = 41
62
+ p.dependents = "self+spouse+1 kid"
63
+ p.location_tier = "metro"
64
+ p.income_band = "10L-25L"
65
+ p.primary_goal = "first_buy"
66
+ p.health_conditions = ["none"]
67
+ self.assertTrue(save_profile(self.first, p),
68
+ "fixture save_profile failed")
69
+ self._env = mock.patch.dict(
70
+ os.environ, {"GOOGLE_API_KEY": "test-key"})
71
+ self._env.start()
72
+ self.sys_prompts = []
73
+ self.si_kwargs = []
74
+
75
+ async def _fake_gemini(*_a, **_k):
76
+ self.sys_prompts.append(
77
+ (_k.get("system_instruction") or {})
78
+ .get("parts", [{}])[0].get("text", ""))
79
+ return _text_payload("ok")
80
+
81
+ self._gp = mock.patch.object(
82
+ single_brain, "_gemini_call", _fake_gemini)
83
+ self._gp.start()
84
+
85
+ # Spy on _system_instruction WITHOUT changing behaviour, to assert
86
+ # the #26 reconstruct flag wiring end-to-end.
87
+ _real_si = single_brain._system_instruction
88
+
89
+ def _spy_si(*a, **k):
90
+ self.si_kwargs.append(k)
91
+ return _real_si(*a, **k)
92
+
93
+ self._sp = mock.patch.object(
94
+ single_brain, "_system_instruction", _spy_si)
95
+ self._sp.start()
96
+
97
+ def tearDown(self):
98
+ self._sp.stop()
99
+ self._gp.stop()
100
+ self._env.stop()
101
+ try:
102
+ import json
103
+ for fp in _PROFILES_DIR.glob("*.json"):
104
+ try:
105
+ d = json.loads(fp.read_text())
106
+ except Exception:
107
+ continue
108
+ if d.get("name_slug") == self.slug or \
109
+ (d.get("profile") or {}).get("name") == self.first:
110
+ fp.unlink(missing_ok=True)
111
+ except Exception:
112
+ pass
113
+
114
+
115
+ class TestBug25RecallAfterTurn1(_FirstNameStoredFixture):
116
+ def test_name_captured_on_later_turn_stages_recall(self):
117
+ """The exact #25 flow: turn 1 has no name; the LLM captures the
118
+ name (save_profile_field) so by a later turn session.profile.name
119
+ is a TWO-token string. Old code: `elif _current_turn == 1`
120
+ skipped the probe AND the slug missed the stored file β†’ recall
121
+ NEVER fired. Fixed: staged + confirm prompt injected."""
122
+ sess = SessionState(session_id=f"b25_{uuid.uuid4().hex[:8]}")
123
+ # Turn 1 β€” user states intent only, NO name.
124
+ _run(single_brain.handle_turn(sess, "I want a health policy"))
125
+ self.assertIsNone(getattr(sess, "pending_profile_recall", None),
126
+ "no name yet β†’ must not stage on turn 1")
127
+ # The LLM captured the name via save_profile_field on turn 2;
128
+ # replicate that exact server state (two-token name).
129
+ sess.profile.name = self.full
130
+ # A normal later fact-find turn (turn 3) β€” bare answer, NOT a
131
+ # name preamble, NOT turn 1: the precise pre-fix dead zone.
132
+ _run(single_brain.handle_turn(sess, "no pre-existing conditions"))
133
+ pr = getattr(sess, "pending_profile_recall", None)
134
+ self.assertTrue(
135
+ pr, "#25: recall not staged when name known after turn 1")
136
+ self.assertEqual(pr["name"], self.first,
137
+ "#25: first-name slug fallback did not resolve "
138
+ "the stored profile")
139
+ self.assertIn("RETURNING-USER CHECK", self.sys_prompts[-1],
140
+ "#25: confirm block not injected")
141
+ self.assertTrue(getattr(sess, "recall_probe_done", False),
142
+ "#25: one-shot guard not set")
143
+
144
+ def test_explicit_yes_then_merges(self):
145
+ sess = SessionState(session_id=f"b25y_{uuid.uuid4().hex[:8]}")
146
+ sess.profile.name = self.full
147
+ _run(single_brain.handle_turn(sess, "just me"))
148
+ self.assertTrue(sess.pending_profile_recall)
149
+ r2 = _run(single_brain.handle_turn(sess, "yes, that's me"))
150
+ self.assertTrue(r2.returning_user_recalled)
151
+ self.assertEqual(sess.profile.age, 41,
152
+ "stored profile not merged on explicit yes")
153
+
154
+ def test_declined_recall_not_reoffered(self):
155
+ sess = SessionState(session_id=f"b25n_{uuid.uuid4().hex[:8]}")
156
+ sess.profile.name = self.full
157
+ _run(single_brain.handle_turn(sess, "just me"))
158
+ self.assertTrue(sess.pending_profile_recall)
159
+ apply_pending_recall(sess, confirmed=False)
160
+ self.assertTrue(sess.recall_probe_done)
161
+ # A later turn must NOT re-stage the declined recall.
162
+ _run(single_brain.handle_turn(sess, "income 25L+"))
163
+ self.assertIsNone(getattr(sess, "pending_profile_recall", None),
164
+ "#25: declined recall was re-offered")
165
+
166
+ def test_unknown_name_no_false_recall_later_turn(self):
167
+ sess = SessionState(session_id=f"b25u_{uuid.uuid4().hex[:8]}")
168
+ sess.profile.name = "Zzqxnobodyhasthis Lastname"
169
+ _run(single_brain.handle_turn(sess, "no pre-existing conditions"))
170
+ self.assertIsNone(getattr(sess, "pending_profile_recall", None))
171
+ self.assertNotIn("RETURNING-USER CHECK", self.sys_prompts[-1])
172
+
173
+
174
+ class TestBug26ReconstructFromHistory(_FirstNameStoredFixture):
175
+ def _hist(self):
176
+ return [
177
+ {"role": "user", "content": "I want a health policy"},
178
+ {"role": "assistant", "content": "Sure β€” your name and age?"},
179
+ {"role": "user", "content": "Rohit, 29, Bangalore"},
180
+ {"role": "assistant", "content": "Thanks Rohit. Income band?"},
181
+ ]
182
+
183
+ def test_blank_session_with_history_triggers_reconstruction(self):
184
+ """Evicted/blank session BUT the client still carries the
185
+ conversation β†’ STATE-RECOVERY MODE, not "What's your name?"."""
186
+ sess = SessionState(session_id=f"b26_{uuid.uuid4().hex[:8]}")
187
+ _run(single_brain.handle_turn(
188
+ sess, "income 25L+", chat_history=self._hist()))
189
+ self.assertTrue(
190
+ self.si_kwargs[-1].get("reconstruct_from_history"),
191
+ "#26: reconstruction not triggered for blank+history")
192
+ self.assertIn("STATE-RECOVERY MODE", self.sys_prompts[-1])
193
+ self.assertNotIn("lost some of your profile",
194
+ self.sys_prompts[-1].lower())
195
+
196
+ def test_genuine_first_turn_no_reconstruction(self):
197
+ sess = SessionState(session_id=f"b26f_{uuid.uuid4().hex[:8]}")
198
+ _run(single_brain.handle_turn(sess, "I want a health policy"))
199
+ self.assertFalse(
200
+ self.si_kwargs[-1].get("reconstruct_from_history"),
201
+ "#26: false recovery on a genuine first turn")
202
+ self.assertNotIn("STATE-RECOVERY MODE", self.sys_prompts[-1])
203
+
204
+ def test_populated_session_no_reconstruction(self):
205
+ sess = SessionState(session_id=f"b26p_{uuid.uuid4().hex[:8]}")
206
+ sess.profile.name = "Asha"
207
+ sess.profile.age = 30
208
+ _run(single_brain.handle_turn(
209
+ sess, "income 25L+", chat_history=self._hist()))
210
+ self.assertFalse(
211
+ self.si_kwargs[-1].get("reconstruct_from_history"),
212
+ "#26: reconstruction wrongly fired on a populated session")
213
+
214
+
215
+ if __name__ == "__main__":
216
+ unittest.main()