Spaces:
Sleeping
#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 +21 -1
- backend/single_brain.py +66 -4
- tests/test_bug2526_recall_and_reconstruct.py +216 -0
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {}
|
|
@@ -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 +
|
| 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
|
| 1891 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 /
|
|
@@ -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()
|