rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
3591985
·
1 Parent(s): abbb3a5

fix(brain): wire returning-user-by-name recall into single_brain (ADR-041/KI-196)

Browse files

Root cause: the confirmation-gated profile recall was ORPHANED by the
orchestrator->single-LLM rewrite. extract_potential_name /
try_recall_by_name / apply_pending_recall existed and were unit-tested,
but NOTHING on the live path (single_brain.handle_turn) called them, and
TurnResult.returning_user_recalled was hard-coded False. A same-name
revisit ('Hi, I'm Rohit') therefore never triggered 'are you the same
Rohit?' and never reused the stored profile.

Fix (single_brain.py, fits current architecture; no orchestrator revival):
- turn-1: extract_potential_name -> try_recall_by_name STAGES the match
on session.pending_profile_recall (privacy-safe; never auto-merged).
- _system_instruction injects a RETURNING-USER CHECK block so the LLM's
entire turn is just the 'Welcome back - are you the same <name>?'
confirm (no tools / no fact-find that turn).
- next turn: _affirm_or_deny (new, conservative WORD-BOUNDARY yes/no;
deny wins ties = privacy fail-closed) -> explicit yes calls
apply_pending_recall(confirmed=True) -> merges stored slots into empty
fields + flips returning_user_recalled (frontend Welcome-back banner);
explicit no discards; ambiguous re-asks once.

Verified: 7/7 new tests in tests/test_returning_user_recall_singlebrain.py
(incl. the handle_turn integration that was the untested gap); real
backend probe — turn1 exact confirm prompt, turn2 'yes' recalled the
full profile and did not re-ask; full pytest suite exit 0 (no regression).

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

backend/single_brain.py CHANGED
@@ -656,8 +656,63 @@ def _build_contents(
656
  return out
657
 
658
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
  def _system_instruction(
660
- profile, is_returning_user: bool = False, shortlist_block: str = ""
 
661
  ) -> dict:
662
  """Bake the profile snapshot into the system prompt so each turn the
663
  LLM knows what's already captured. Returned in Gemini's expected
@@ -694,7 +749,35 @@ def _system_instruction(
694
  "say 'Welcome back'):\n"
695
  + json.dumps(snapshot, ensure_ascii=False, sort_keys=True)
696
  )
697
- text = SYSTEM_PROMPT + extra + (shortlist_block or "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
698
  return {"parts": [{"text": text}]}
699
 
700
 
@@ -1656,11 +1739,53 @@ async def handle_turn(
1656
  # conversation — not a returning user.
1657
  _current_turn = int(getattr(session, "turn_idx", 1) or 1)
1658
 
1659
- # The single LLM (RULE 1 → save_profile_field) is the sole fact-find
1660
- # driver. Explicit returning-user recall is the separate
1661
- # POST /api/profile/recall-by-name endpoint, so handle_turn never sets
1662
- # returning_user_recalled True.
 
 
 
 
 
 
1663
  _did_recall_this_turn = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1664
 
1665
  _has_prior_profile = any(
1666
  getattr(session.profile, fld, None) not in (None, "", [])
@@ -1714,6 +1839,7 @@ async def handle_turn(
1714
  session.profile,
1715
  is_returning_user=is_returning_user,
1716
  shortlist_block=_shortlist_block,
 
1717
  )
1718
 
1719
  # Bug #108 + #110 — if the user explicitly declines the pricing /
 
656
  return out
657
 
658
 
659
+ # WHOLE-WORD tokens (matched against the tokenised message, NOT as
660
+ # substrings — "no" must not match "k(no)ws", "ya" must not match "Ma(ya)").
661
+ _RECALL_DENY_TOKENS = {
662
+ "no", "nope", "nah", "naah", "nahi", "wrong", "never", "neither",
663
+ "nopes", "nahin",
664
+ }
665
+ _RECALL_AFFIRM_TOKENS = {
666
+ "yes", "yeah", "yep", "yup", "ya", "yaa", "yaah", "yess", "haan",
667
+ "han", "haa", "correct", "right", "sahi", "bilkul", "sure", "indeed",
668
+ "absolutely", "exactly", "true", "yup",
669
+ }
670
+ # Multi-word phrases — safe to match as substrings.
671
+ _RECALL_DENY_PHRASES = (
672
+ "not me", "isn't me", "isnt me", "not the same", "start fresh",
673
+ "start over", "different person", "new user", "someone else",
674
+ "not rohit", "first time", "never been", "fresh start", "not him",
675
+ "not her", "i'm new", "im new", "not that person", "don't know",
676
+ "dont know", "different one",
677
+ )
678
+ _RECALL_AFFIRM_PHRASES = (
679
+ "that's me", "thats me", "that is me", "it's me", "its me", "i am",
680
+ "pick up", "go ahead", "that's right", "thats right", "that's correct",
681
+ "thats correct", "yes please", "continue where", "same person",
682
+ "carry on", "of course",
683
+ )
684
+ _RECALL_TOKEN_RE = __import__("re").compile(r"[a-z']+")
685
+
686
+
687
+ def _affirm_or_deny(text: str):
688
+ """Conservative yes/no for the returning-user confirm gate.
689
+
690
+ Returns True (affirm), False (deny), or None (ambiguous → re-ask).
691
+ Deny wins ties: privacy is fail-closed — an ambiguous "no, but…" must
692
+ NEVER merge a stranger's stored profile (ADR-041 / KI-196). Short
693
+ tokens are matched whole-word (tokenised), not as substrings, so
694
+ "who knows" / "i don't know" / "now" are NOT read as "no".
695
+ """
696
+ t = (text or "").strip().lower()
697
+ if not t:
698
+ return None
699
+ toks = set(_RECALL_TOKEN_RE.findall(t))
700
+ deny = bool(toks & _RECALL_DENY_TOKENS) or any(
701
+ p in t for p in _RECALL_DENY_PHRASES
702
+ )
703
+ if deny:
704
+ return False
705
+ affirm = bool(toks & _RECALL_AFFIRM_TOKENS) or any(
706
+ p in t for p in _RECALL_AFFIRM_PHRASES
707
+ )
708
+ if affirm:
709
+ return True
710
+ return None
711
+
712
+
713
  def _system_instruction(
714
+ profile, is_returning_user: bool = False, shortlist_block: str = "",
715
+ pending_recall: "Optional[dict]" = None,
716
  ) -> dict:
717
  """Bake the profile snapshot into the system prompt so each turn the
718
  LLM knows what's already captured. Returned in Gemini's expected
 
749
  "say 'Welcome back'):\n"
750
  + json.dumps(snapshot, ensure_ascii=False, sort_keys=True)
751
  )
752
+ recall_block = ""
753
+ if pending_recall:
754
+ _nm = (pending_recall.get("name") or "there").strip()
755
+ _sm = pending_recall.get("summary") or {}
756
+ _bits = []
757
+ for _k in ("age", "location_tier", "dependents", "primary_goal",
758
+ "health_conditions"):
759
+ _v = _sm.get(_k)
760
+ if _v not in (None, "", []):
761
+ _bits.append(f"{_k.replace('_', ' ')}: {_v}")
762
+ _summ = "; ".join(_bits) if _bits else "a saved profile"
763
+ recall_block = (
764
+ "\n\n═══════════════════════════════════\n"
765
+ "RETURNING-USER CHECK — HIGHEST PRIORITY THIS TURN "
766
+ "(overrides RULE 1 / fact-find for this one turn)\n"
767
+ "═══════════════════════════════════\n"
768
+ f"A stored profile already exists under the name the user just "
769
+ f"gave (\"{_nm}\"). Known hints — {_summ}.\n"
770
+ "Your ENTIRE reply this turn MUST be ONLY the confirmation "
771
+ "question below. Do NOT call any tool, do NOT save_profile_field, "
772
+ "do NOT run the 7-question fact-find, do NOT recommend:\n"
773
+ f" \"Welcome back — are you the same {_nm} who spoke with us "
774
+ f"before ({_summ})? If yes, I'll pick up right where we left "
775
+ f"off. If not, no problem — just say so and we'll start fresh.\"\n"
776
+ "Then wait for their yes/no on the NEXT turn. The system "
777
+ "applies or discards the saved profile from their answer — you "
778
+ "never merge anything yourself."
779
+ )
780
+ text = SYSTEM_PROMPT + extra + recall_block + (shortlist_block or "")
781
  return {"parts": [{"text": text}]}
782
 
783
 
 
1739
  # conversation — not a returning user.
1740
  _current_turn = int(getattr(session, "turn_idx", 1) or 1)
1741
 
1742
+ # ── Returning-user recall (ADR-041 / KI-196), wired into single_brain
1743
+ # 2026-05-19. Previously ORPHANED by the orchestrator→single-LLM
1744
+ # rewrite: extract_potential_name / try_recall_by_name /
1745
+ # apply_pending_recall existed and were unit-tested, but NOTHING on the
1746
+ # live path called them — so a same-name revisit ("Hi, I'm Rohit") was
1747
+ # never recognised and the "are you the same Rohit?" prompt never fired.
1748
+ # Privacy-safe by construction: a name match is only STAGED on
1749
+ # session.pending_profile_recall (never auto-merged); only an explicit
1750
+ # "yes" merges the stored slots, an explicit "no" discards, anything
1751
+ # ambiguous leaves it staged so the LLM re-asks the confirm once.
1752
  _did_recall_this_turn = False
1753
+ try:
1754
+ from backend.profile_persistence import (
1755
+ extract_potential_name,
1756
+ try_recall_by_name,
1757
+ )
1758
+ from backend.session_state import apply_pending_recall
1759
+
1760
+ _pending_recall = getattr(session, "pending_profile_recall", None)
1761
+ if _pending_recall:
1762
+ _ans = _affirm_or_deny(user_text)
1763
+ if _ans is True:
1764
+ _did_recall_this_turn = bool(
1765
+ apply_pending_recall(session, confirmed=True)
1766
+ )
1767
+ _pending_recall = None
1768
+ elif _ans is False:
1769
+ apply_pending_recall(session, confirmed=False)
1770
+ _pending_recall = None
1771
+ # ambiguous → leave staged; the confirm block is re-injected
1772
+ # below and the LLM re-asks the "are you <name>?" question.
1773
+ elif _current_turn == 1:
1774
+ _nm = extract_potential_name(user_text or "")
1775
+ if _nm:
1776
+ # Stages session.pending_profile_recall iff a stored
1777
+ # profile for this name exists (no match ⇒ no-op, normal
1778
+ # fresh-user flow continues — no false confirm prompt).
1779
+ try_recall_by_name(session, _nm)
1780
+ _pending_recall = getattr(
1781
+ session, "pending_profile_recall", None
1782
+ )
1783
+ except Exception as _re: # noqa: BLE001 — recall must never break a turn
1784
+ _log.warning(
1785
+ "returning-user recall wiring failed: %s: %s",
1786
+ type(_re).__name__, str(_re)[:200],
1787
+ )
1788
+ _pending_recall = getattr(session, "pending_profile_recall", None)
1789
 
1790
  _has_prior_profile = any(
1791
  getattr(session.profile, fld, None) not in (None, "", [])
 
1839
  session.profile,
1840
  is_returning_user=is_returning_user,
1841
  shortlist_block=_shortlist_block,
1842
+ pending_recall=_pending_recall,
1843
  )
1844
 
1845
  # Bug #108 + #110 — if the user explicitly declines the pricing /
tests/test_returning_user_recall_singlebrain.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression test for the returning-user-by-name recall fix (2026-05-19).
2
+
3
+ Bug: every visit, the user gave the same name ("Rohit") and the bot NEVER
4
+ asked "are you the same Rohit?" / never recalled the stored profile. The
5
+ confirmation-gated recall (ADR-041/KI-196) helpers existed and were
6
+ unit-tested, but the orchestrator→single-LLM rewrite left them ORPHANED —
7
+ nothing on the live path (single_brain.handle_turn) called them, and
8
+ `returning_user_recalled` was a hard-coded False. The integration boundary
9
+ was exactly what had no test (that's how it shipped).
10
+
11
+ These tests pin the now-wired chain end-to-end in single_brain:
12
+ turn-1 name sniff → stage (privacy-safe, never auto-merge) → confirm
13
+ prompt injected → explicit "yes" merges + flips returning_user_recalled
14
+ → explicit "no" discards → unknown name is a no-op (no false prompt).
15
+ """
16
+ import asyncio
17
+ import os
18
+ import random
19
+ import string
20
+ import unittest
21
+ import uuid
22
+ from unittest import mock
23
+
24
+ from backend import single_brain
25
+ from backend import brain_tools
26
+ from backend.session_state import SessionState, apply_pending_recall
27
+ from backend.profile_persistence import extract_potential_name, try_recall_by_name
28
+ from backend.profile_store import save_profile, _normalise_name, _PROFILES_DIR
29
+ from backend.needs_finder import Profile
30
+
31
+
32
+ def _run(coro):
33
+ return asyncio.new_event_loop().run_until_complete(coro)
34
+
35
+
36
+ def _text_payload(text):
37
+ return {"candidates": [{"content": {"parts": [{"text": text}]}}]}
38
+
39
+
40
+ class TestAffirmOrDeny(unittest.TestCase):
41
+ """Word-boundary yes/no — must NOT read 'no' inside 'knows'/'now', and
42
+ deny must win ties (privacy fail-closed)."""
43
+
44
+ def test_table(self):
45
+ cases = [
46
+ ("yes that is me", True),
47
+ ("yeah, same Rohit", True),
48
+ ("haan bilkul", True),
49
+ ("that is me, carry on", True),
50
+ ("no, I'm a new user", False),
51
+ ("not me, someone else", False),
52
+ ("no, but yes", False), # deny wins
53
+ ("i don't know", False), # fail-closed
54
+ ("maybe, who knows", None), # 'knows' must NOT be 'no'
55
+ ("now what", None), # 'now' must NOT be 'no'
56
+ ("hmm", None),
57
+ ("", None),
58
+ ]
59
+ for txt, exp in cases:
60
+ self.assertEqual(single_brain._affirm_or_deny(txt), exp, txt)
61
+
62
+
63
+ class _StoredProfileFixture(unittest.TestCase):
64
+ """Creates a deterministic, uniquely-named stored profile so the test
65
+ never couples to the mutable 40-data/profiles/rohit.json."""
66
+
67
+ def setUp(self):
68
+ # Alphabetic only — extract_potential_name correctly rejects
69
+ # digit-bearing tokens, so a hex suffix would never be sniffed.
70
+ self.name = "Recalltester" + "".join(
71
+ random.choices(string.ascii_lowercase, k=7))
72
+ self.slug = _normalise_name(self.name)
73
+ p = Profile()
74
+ p.name = self.name
75
+ p.age = 41
76
+ p.dependents = "self+spouse+1 kid"
77
+ p.location_tier = "metro"
78
+ p.income_band = "10L-25L"
79
+ p.primary_goal = "first_buy"
80
+ p.health_conditions = ["none"]
81
+ self.assertTrue(save_profile(self.name, p),
82
+ "fixture save_profile failed")
83
+
84
+ def tearDown(self):
85
+ try:
86
+ for fp in _PROFILES_DIR.glob("*.json"):
87
+ try:
88
+ import json
89
+ d = json.loads(fp.read_text())
90
+ except Exception:
91
+ continue
92
+ if d.get("name_slug") == self.slug or \
93
+ (d.get("profile") or {}).get("name") == self.name:
94
+ fp.unlink(missing_ok=True)
95
+ except Exception:
96
+ pass
97
+
98
+
99
+ class TestRecallChain(_StoredProfileFixture):
100
+ def test_stage_inject_merge(self):
101
+ nm = extract_potential_name(f"Hi, I'm {self.name}")
102
+ self.assertTrue(nm and nm.lower().startswith("recalltester"))
103
+ s = SessionState(session_id="rc1")
104
+ try_recall_by_name(s, nm)
105
+ pr = getattr(s, "pending_profile_recall", None)
106
+ self.assertTrue(pr, "name match was not STAGED")
107
+ self.assertEqual(pr["name"], self.name)
108
+ si = single_brain._system_instruction(
109
+ s.profile, pending_recall=pr)["parts"][0]["text"]
110
+ self.assertIn("RETURNING-USER CHECK", si)
111
+ self.assertIn("Welcome back", si)
112
+ self.assertIn(self.name, si)
113
+ # confirm=True merges into empty slots
114
+ self.assertTrue(apply_pending_recall(s, confirmed=True))
115
+ self.assertEqual(s.profile.age, 41)
116
+ self.assertIsNone(getattr(s, "pending_profile_recall", None))
117
+
118
+ def test_deny_discards(self):
119
+ s = SessionState(session_id="rc2")
120
+ try_recall_by_name(s, self.name)
121
+ self.assertTrue(s.pending_profile_recall)
122
+ self.assertFalse(apply_pending_recall(s, confirmed=False))
123
+ self.assertIn(getattr(s.profile, "age", None), (None, "", 0))
124
+ self.assertIsNone(s.pending_profile_recall)
125
+
126
+ def test_unknown_name_no_stage(self):
127
+ s = SessionState(session_id="rc3")
128
+ try_recall_by_name(s, "Zzqxnobodyhasthisname")
129
+ self.assertIsNone(getattr(s, "pending_profile_recall", None))
130
+
131
+
132
+ class TestHandleTurnIntegration(_StoredProfileFixture):
133
+ """The exact gap that let the bug ship: single_brain.handle_turn
134
+ integration with the recall chain."""
135
+
136
+ def setUp(self):
137
+ super().setUp()
138
+ self._env = mock.patch.dict(os.environ,
139
+ {"GOOGLE_API_KEY": "test-key"})
140
+ self._env.start()
141
+ self.sys_prompts = []
142
+
143
+ async def _fake_gemini(*_a, **_k):
144
+ self.sys_prompts.append(
145
+ (_k.get("system_instruction") or {})
146
+ .get("parts", [{}])[0].get("text", ""))
147
+ return _text_payload("Welcome back — are you the same person?")
148
+
149
+ self._gp = mock.patch.object(single_brain, "_gemini_call",
150
+ _fake_gemini)
151
+ self._gp.start()
152
+
153
+ def tearDown(self):
154
+ self._gp.stop()
155
+ self._env.stop()
156
+ super().tearDown()
157
+
158
+ def test_turn1_stages_and_prompts_then_yes_recalls(self):
159
+ sess = SessionState(session_id=f"hti_{uuid.uuid4().hex[:8]}")
160
+ # Turn 1: user states their (returning) name.
161
+ r1 = _run(single_brain.handle_turn(sess, f"Hi, I'm {self.name}"))
162
+ self.assertTrue(getattr(sess, "pending_profile_recall", None),
163
+ "turn-1 name sniff did not STAGE the recall")
164
+ self.assertFalse(r1.returning_user_recalled,
165
+ "must NOT flag recall before the user confirms")
166
+ self.assertIn("RETURNING-USER CHECK", self.sys_prompts[-1],
167
+ "confirm block not injected into the system prompt")
168
+ # Turn 2: user confirms.
169
+ r2 = _run(single_brain.handle_turn(sess, "yes, that's me"))
170
+ self.assertTrue(r2.returning_user_recalled,
171
+ "explicit 'yes' must flip returning_user_recalled")
172
+ self.assertEqual(sess.profile.age, 41,
173
+ "stored profile was not merged on confirm")
174
+ self.assertIsNone(getattr(sess, "pending_profile_recall", None))
175
+
176
+ def test_no_keeps_session_blank(self):
177
+ sess = SessionState(session_id=f"hti_{uuid.uuid4().hex[:8]}")
178
+ _run(single_brain.handle_turn(sess, f"Hello, I am {self.name}"))
179
+ self.assertTrue(sess.pending_profile_recall)
180
+ r2 = _run(single_brain.handle_turn(sess, "no, I'm a new user"))
181
+ self.assertFalse(r2.returning_user_recalled)
182
+ self.assertIn(getattr(sess.profile, "age", None), (None, "", 0))
183
+ self.assertIsNone(sess.pending_profile_recall)
184
+
185
+ def test_unknown_name_normal_flow(self):
186
+ sess = SessionState(session_id=f"hti_{uuid.uuid4().hex[:8]}")
187
+ r1 = _run(single_brain.handle_turn(
188
+ sess, "Hi, I'm Zzqxnobodyhasthisname"))
189
+ self.assertIsNone(getattr(sess, "pending_profile_recall", None))
190
+ self.assertFalse(r1.returning_user_recalled)
191
+ self.assertNotIn("RETURNING-USER CHECK", self.sys_prompts[-1])
192
+
193
+
194
+ if __name__ == "__main__":
195
+ unittest.main()