Spaces:
Sleeping
fix(brain): wire returning-user-by-name recall into single_brain (ADR-041/KI-196)
Browse filesRoot 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>
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 1660 |
-
#
|
| 1661 |
-
#
|
| 1662 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 /
|
|
@@ -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()
|