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

#45: persist chat-only profiles so returning-user recall actually works

Browse files

Real cause of the recurring "returning user not recognised": the #25
recall LOOKUP fix was correct but its verification used a pre-existing
rohit.json, masking that the WRITE side never ran. profile_persistence.
auto_persist_session() (saves named profile to disk + Chroma) was
ORPHANED by the orchestrator→single-LLM rewrite — single_brain.handle_turn
never called it; save_profile() only ran from POST /api/profile (builder
UI). So a user who completed fact-find purely by chat was never saved
and recall had nothing to find (live audit: brand-new "Auditkumar Verma"
→ fresh same-name session → NO recall; no auditkumar*.json written).

Fix: handle_turn now awaits auto_persist_session(session) at end-of-turn
(after all save_profile_field + recall handling). No-ops without
profile.name; swallows all errors so persistence can never block a reply.

Regression tests (red-green verified): chat turn persists <name>.json;
anonymous turn does not; persist-then-fresh-session → recall fires
(the real chat-only flow the pre-seeded verification skipped). Full
suite green.

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

backend/single_brain.py CHANGED
@@ -2490,6 +2490,27 @@ async def handle_turn(
2490
  "the insurer before relying on it."
2491
  )
2492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2493
  return TurnResult(
2494
  reply_text=reply_text,
2495
  citations=citations,
 
2490
  "the insurer before relying on it."
2491
  )
2492
 
2493
+ # Bug #25 WRITE-SIDE (2026-05-19) — persist the named profile so a
2494
+ # returning user can actually be recalled. profile_persistence.
2495
+ # auto_persist_session was ORPHANED by the orchestrator→single-LLM
2496
+ # rewrite (same class as the recall LOOKUP helpers + get_policy_facts
2497
+ # + silent-TTS): nothing on the chat path ever called it, so a user
2498
+ # who completed fact-find PURELY BY CHAT was never saved to the named
2499
+ # store and recall had nothing to find — save_profile() only ran from
2500
+ # the POST /api/profile builder UI. Persisting at end-of-turn (after
2501
+ # all save_profile_field tool calls + recall handling have run) closes
2502
+ # the loop. No-ops without a profile.name; swallows all errors so a
2503
+ # stuck disk / Chroma hiccup can never block the chat reply.
2504
+ try:
2505
+ from backend.profile_persistence import auto_persist_session
2506
+
2507
+ await auto_persist_session(session)
2508
+ except Exception as _pe: # noqa: BLE001 — persistence must NEVER break a turn
2509
+ _log.warning(
2510
+ "auto_persist_session wiring failed: %s: %s",
2511
+ type(_pe).__name__, str(_pe)[:200],
2512
+ )
2513
+
2514
  return TurnResult(
2515
  reply_text=reply_text,
2516
  citations=citations,
tests/test_bug45_chat_profile_persistence.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression test for bug #45 (2026-05-19) — the REAL cause of the
2
+ recurring "returning user not recognised" complaint.
3
+
4
+ `profile_persistence.auto_persist_session()` (saves the named profile to
5
+ disk + Chroma) was ORPHANED by the orchestrator→single-LLM rewrite:
6
+ nothing on the chat path called it, so a user who completed fact-find
7
+ PURELY BY CHAT was never persisted to the named store (save_profile()
8
+ only ran from the POST /api/profile builder UI). The recall LOOKUP fix
9
+ (#25) was correct but its verification used a pre-existing rohit.json,
10
+ masking this write-side gap. A live audit with a brand-new chat-only
11
+ "Auditkumar Verma" then a fresh same-name session got NO recall —
12
+ because nothing wrote auditkumar*.json.
13
+
14
+ Fix: single_brain.handle_turn now awaits auto_persist_session(session)
15
+ at end-of-turn. These tests pin the write-side end-to-end and FAIL on
16
+ the pre-fix code.
17
+ """
18
+ import asyncio
19
+ import os
20
+ import random
21
+ import string
22
+ import unittest
23
+ from unittest import mock
24
+
25
+ from backend import single_brain
26
+ from backend.session_state import SessionState
27
+ from backend.profile_store import load_profile, _normalise_name, _PROFILES_DIR
28
+
29
+
30
+ def _run(coro):
31
+ return asyncio.new_event_loop().run_until_complete(coro)
32
+
33
+
34
+ def _text_payload(text):
35
+ return {"candidates": [{"content": {"parts": [{"text": text}]}}]}
36
+
37
+
38
+ class TestBug45ChatProfilePersistence(unittest.TestCase):
39
+ def setUp(self):
40
+ self.first = "Persisttester" + "".join(
41
+ random.choices(string.ascii_lowercase, k=7))
42
+ self.slug = _normalise_name(self.first)
43
+ self._env = mock.patch.dict(
44
+ os.environ, {"GOOGLE_API_KEY": "test-key"})
45
+ self._env.start()
46
+
47
+ async def _fake_gemini(*_a, **_k):
48
+ return _text_payload("Thanks — noted.")
49
+
50
+ self._gp = mock.patch.object(
51
+ single_brain, "_gemini_call", _fake_gemini)
52
+ self._gp.start()
53
+
54
+ def tearDown(self):
55
+ self._gp.stop()
56
+ self._env.stop()
57
+ try:
58
+ import json
59
+ for fp in _PROFILES_DIR.glob("*.json"):
60
+ try:
61
+ d = json.loads(fp.read_text())
62
+ except Exception:
63
+ continue
64
+ if d.get("name_slug") == self.slug or \
65
+ (d.get("profile") or {}).get("name") == self.first:
66
+ fp.unlink(missing_ok=True)
67
+ except Exception:
68
+ pass
69
+
70
+ def _completed_profile_session(self):
71
+ """A session whose profile the LLM has fully captured by chat
72
+ (exactly the end state of a normal conversational fact-find)."""
73
+ s = SessionState(session_id=f"b45_{self.first.lower()}")
74
+ p = s.profile
75
+ p.name = self.first
76
+ p.age = 38
77
+ p.dependents = "self+spouse+1 kid"
78
+ p.location_tier = "metro"
79
+ p.income_band = "25L+"
80
+ p.primary_goal = "first_buy"
81
+ p.health_conditions = ["none"]
82
+ return s
83
+
84
+ def test_chat_turn_persists_named_profile_to_disk(self):
85
+ """The exact #45 gap: a chat-only completed profile must be
86
+ written to the named store at end-of-turn so a return visit can
87
+ recall it. Pre-fix: auto_persist_session was never called →
88
+ load_profile() is None."""
89
+ self.assertIsNone(load_profile(self.first),
90
+ "fixture leaked a pre-existing profile")
91
+ sess = self._completed_profile_session()
92
+ _run(single_brain.handle_turn(sess, "yes that's all correct"))
93
+ stored = load_profile(self.first)
94
+ self.assertIsNotNone(
95
+ stored,
96
+ "#45: chat fact-find did NOT persist the named profile "
97
+ "(auto_persist_session still orphaned)")
98
+ self.assertEqual(getattr(stored, "age", None), 38,
99
+ "#45: persisted profile is missing captured slots")
100
+
101
+ def test_anonymous_turn_does_not_persist(self):
102
+ """No name → never write to disk (KI-118 privacy gate intact)."""
103
+ s = SessionState(session_id="b45_anon")
104
+ s.profile.age = 30
105
+ _run(single_brain.handle_turn(s, "I want a health policy"))
106
+ # nothing with our unique slug should have been created
107
+ self.assertIsNone(load_profile(self.first))
108
+
109
+ def test_persist_then_fresh_session_recall_fires(self):
110
+ """End-to-end: chat-only persist (#45 write) THEN a brand-new
111
+ session giving the same name → recall stages (#25 lookup). This
112
+ is the real-user flow the prior pre-seeded verification skipped."""
113
+ sess = self._completed_profile_session()
114
+ _run(single_brain.handle_turn(sess, "yes that's all correct"))
115
+ self.assertIsNotNone(load_profile(self.first), "precondition: "
116
+ "profile must be persisted by the chat turn")
117
+ # Brand-new session; user states the same name mid-fact-find.
118
+ s2 = SessionState(session_id="b45_return")
119
+ s2.profile.name = f"{self.first} Kumar" # two-token, like real
120
+ _run(single_brain.handle_turn(s2, "no pre-existing conditions"))
121
+ pr = getattr(s2, "pending_profile_recall", None)
122
+ self.assertTrue(
123
+ pr, "#45+#25: returning user not recalled even though the "
124
+ "chat-only profile was persisted")
125
+ self.assertEqual(pr["name"], self.first)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ unittest.main()