Spaces:
Sleeping
#45: persist chat-only profiles so returning-user recall actually works
Browse filesReal 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>
|
@@ -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,
|
|
@@ -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()
|