rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
6d5684e
·
1 Parent(s): 2be56b4

feat!: remove cross-session profile recall (ADR-043) — net −3700 LOC

Browse files

Sessions are now in-memory only. ADR-041's name-slug + persona_id
two-tier on-disk store and ADR-042 v3+v4's two-fact gate + extended
extractors are removed entirely. Closing the tab discards the profile.

CONTEXT
─────────────────────────────────────────────────────────────────────
Two-week arc of recall hardening (ADR-041 → KI-196 → Bug #25 → Bug #26
→ ADR-042 v1 → v2 → v3 → v4) kept exposing new failure modes. Each
pass was a real bug class, but after v4 the live audit still showed
seeded-test failures because the name-slug pointer was being
overwritten between tests — the structural truth a name-only key
cannot safely disambiguate two visitors who share a name. The
workarounds were accumulating without converging.

Cost/benefit was wrong for an insurance-shopping product (rare-
purchase, return sessions uncommon): ~1500 LOC + 100+ profile JSONs +
2 ADRs + 5 test files + ongoing audit-time state drift, for a feature
whose value is "the bot remembers you next visit." Minimum-data-
retention is a stronger story.

REMOVED
─────────────────────────────────────────────────────────────────────
- backend/profile_store.py (delete)
- backend/profile_persistence.py (delete)
- backend/profile_rag.py (delete)
- backend/session_state.py: rehydrate_by_name, apply_pending_recall,
_AGE_HINT_RE, _extract_*_from_text (4), _parse_user_text_facts,
_LOCATION_TIER_MAP, _RECALL_SUMMARY_FIELDS, pending_profile_recall /
recall_probe_done / recall_match_deferred fields on SessionState
- backend/single_brain.py: _affirm_or_deny, _RECALL_AFFIRM_TOKENS,
_RECALL_DENY_TOKENS, _RECALL_AFFIRM_PHRASES, _RECALL_DENY_PHRASES,
_RECALL_TOKEN_RE, recall_block + restored_block + pending_recall /
recall_applied params on _system_instruction, the ~70-line recall
prelude in handle_turn, the end-of-turn auto_persist_session call
- backend/main.py: /api/profile/recall-by-name endpoint + models,
pre-turn name snapshot, _every_filled_slot_was_set_this_turn,
_FEATURE_B_SLOT_LIST, the auto_persist_session call + returning-
user detector, the /api/profile save_profile + upsert_profile_chunk
side-effects
- backend/brain_tools.py: record_policy_event import in
mark_recommendation — replaced by an inline in-memory mutation of
session.profile.shown_policies
- backend/admin.py: /api/admin/profiles + /api/admin/persona-drift +
/api/admin/recommendation-history converted to read from
session_state._sessions (live in-memory) rather than walking
40-data/profiles/*.json. _LazyProfilesDir + _PROFILES_DIR_FOR_DRIFT
+ _resolve_profiles_dir deleted. /api/profile/select +
/api/profile/reject rewritten to mutate session.profile only
- frontend/src/lib/api.ts: postProfileRecallByName +
RecallByNameResponse
- 40-data/profiles/: directory deleted from repo + disk (rm -r + git rm)
- tests/test_bug2526_recall_and_reconstruct.py (delete)
- tests/test_bug45_chat_profile_persistence.py (delete)
- tests/test_profile_rag_isolation.py (delete)
- tests/test_profile_recall_session_isolation.py (delete)
- tests/test_returning_user_recall_singlebrain.py (delete)

KEPT
─────────────────────────────────────────────────────────────────────
- SessionState.profile (in-memory, 1h idle TTL)
- Bug #26 STATE-RECOVERY from chat_history (in-session resilience,
never reads disk)
- ADR-042's sticky-session retry policy (independent of recall)
- ADR-042's admin LLM Chain refresh wiring / KI-296 (independent)
- tests/test_session_no_disk_persistence.py (docstring updated)

DOCS
─────────────────────────────────────────────────────────────────────
- New: 70-docs/60-decisions/ADR-043-remove-cross-session-recall.md
- ADR-041 marked SUPERSEDED by ADR-043
- ADR-042 marked PARTIALLY SUPERSEDED (sticky-retry + admin refresh
retained; recall portions retired)
- README §2.1 user-journey diagram: Welcome-Back branch removed
- README §2.2 building-blocks diagram + prose: Profile labeled
"in-memory only"
- README §2.3 functional-abstraction diagram: P3 recall_by_name +
D3 read_user_profile dropped; linkStyle indices renumbered
- README §2.6: full rewrite — new in-memory-only diagram + the
"why no cross-session recall" rationale
- README §2.7: 40-data/profiles line removed
- README §3.6 + §3.7: in-session state-recovery only; stored-vs-live
table updated
- README §6 repo map: deleted modules removed
- CLAUDE.md "Session & profile lifecycle": rewritten for ADR-043

VERIFY
─────────────────────────────────────────────────────────────────────
- py_compile clean across every backend/*.py
- import-time clean for every backend.* module under .venv/bin/python
- grep for every removed symbol (profile_store / profile_persistence /
profile_rag / rehydrate_by_name / apply_pending_recall /
try_recall_by_name / extract_potential_name / pending_profile_recall
/ recall_match_deferred / _RECALL_* / _affirm_or_deny / _extract_*
/ _AGE_HINT_RE / _LOCATION_TIER_MAP / record_policy_event /
_LazyProfilesDir / _PROFILES_DIR_FOR_DRIFT) — zero active code refs

NET DIFF: −4,358 / +626 (~3,700 LOC net removal)

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

40-data/profiles/62690d7db881.json DELETED
@@ -1,38 +0,0 @@
1
- {
2
- "name_display": "Rohit",
3
- "name_slug": "rohit",
4
- "persona_id": "62690d7db881",
5
- "profile": {
6
- "name": "Rohit",
7
- "age": 34,
8
- "dependents": "self+spouse+kids",
9
- "income_band": null,
10
- "existing_cover_inr": null,
11
- "primary_goal": null,
12
- "location_tier": "metro",
13
- "parents_to_insure": null,
14
- "parents_age_max": null,
15
- "parents_has_ped": null,
16
- "budget_band": null,
17
- "desired_sum_insured_inr": null,
18
- "health_conditions": [],
19
- "copay_pct": null,
20
- "family_medical_history": [],
21
- "smoker": null,
22
- "asked": [
23
- "name",
24
- "age",
25
- "dependents",
26
- "location_tier"
27
- ],
28
- "free_form_session": false,
29
- "shown_policies": [],
30
- "selected_policies": [],
31
- "rejected_policies": []
32
- },
33
- "first_seen": "2026-05-16T06:41:30Z",
34
- "last_seen": "2026-05-16T06:41:30Z",
35
- "sessions": [
36
- "sessA_7d9d11fb"
37
- ]
38
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40-data/profiles/7f227bef08d1.json DELETED
@@ -1,35 +0,0 @@
1
- {
2
- "name_display": "Rohit",
3
- "name_slug": "rohit",
4
- "persona_id": "7f227bef08d1",
5
- "profile": {
6
- "name": "Rohit",
7
- "age": null,
8
- "dependents": null,
9
- "income_band": null,
10
- "existing_cover_inr": null,
11
- "primary_goal": null,
12
- "location_tier": null,
13
- "parents_to_insure": null,
14
- "parents_age_max": null,
15
- "parents_has_ped": null,
16
- "budget_band": null,
17
- "health_conditions": [],
18
- "asked": [
19
- "name"
20
- ],
21
- "free_form_session": false,
22
- "shown_policies": [],
23
- "selected_policies": [],
24
- "rejected_policies": []
25
- },
26
- "first_seen": "2026-05-14T23:24:52Z",
27
- "last_seen": "2026-05-15T03:36:22Z",
28
- "sessions": [
29
- "ephem_1",
30
- "4556ffba-ef6c-4035-8c67-ce806e681fa7",
31
- "probe_ki151_t1",
32
- "ki151_smoke_1",
33
- "ki158_walk_001"
34
- ]
35
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40-data/profiles/93b74ab9ae9f.json DELETED
@@ -1,41 +0,0 @@
1
- {
2
- "name_display": "Rohit",
3
- "name_slug": "rohit",
4
- "persona_id": "93b74ab9ae9f",
5
- "profile": {
6
- "name": "Rohit",
7
- "age": 34,
8
- "dependents": "self+spouse+kids",
9
- "income_band": "18L",
10
- "existing_cover_inr": null,
11
- "primary_goal": "first_buy",
12
- "location_tier": "metro",
13
- "parents_to_insure": null,
14
- "parents_age_max": null,
15
- "parents_has_ped": null,
16
- "budget_band": null,
17
- "desired_sum_insured_inr": null,
18
- "health_conditions": [],
19
- "copay_pct": null,
20
- "family_medical_history": [],
21
- "smoker": null,
22
- "asked": [
23
- "name",
24
- "age",
25
- "dependents",
26
- "location_tier",
27
- "income_band",
28
- "primary_goal"
29
- ],
30
- "free_form_session": false,
31
- "shown_policies": [],
32
- "selected_policies": [],
33
- "rejected_policies": []
34
- },
35
- "first_seen": "2026-05-16T06:41:30Z",
36
- "last_seen": "2026-05-16T06:42:12Z",
37
- "sessions": [
38
- "sessA_7d9d11fb",
39
- "sessB_595c9c88"
40
- ]
41
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40-data/profiles/9b1da84bb052.json DELETED
@@ -1,44 +0,0 @@
1
- {
2
- "name_display": "Rohit",
3
- "name_slug": "rohit",
4
- "persona_id": "9b1da84bb052",
5
- "profile": {
6
- "name": "Rohit",
7
- "age": 29,
8
- "dependents": "self",
9
- "income_band": "under_5L",
10
- "existing_cover_inr": 500000,
11
- "primary_goal": "first_buy",
12
- "location_tier": null,
13
- "parents_to_insure": null,
14
- "parents_age_max": null,
15
- "parents_has_ped": null,
16
- "budget_band": "under_15k",
17
- "health_conditions": [],
18
- "asked": [
19
- "name",
20
- "location",
21
- "health_conditions"
22
- ],
23
- "free_form_session": false,
24
- "shown_policies": [],
25
- "selected_policies": [],
26
- "rejected_policies": []
27
- },
28
- "first_seen": "2026-05-15T03:36:34Z",
29
- "last_seen": "2026-05-15T03:51:17Z",
30
- "sessions": [
31
- "ki158_walk_001",
32
- "ki155-test-1",
33
- "ki158_verify_001",
34
- "ki155-test-2",
35
- "ki155-test-3",
36
- "ki155-test-4",
37
- "ki155-test-5",
38
- "ki155-final-1",
39
- "ki155-final-2",
40
- "ki155-final-3",
41
- "ki155-final-4",
42
- "ki155-final-5"
43
- ]
44
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40-data/profiles/9f23a75fc19e.json DELETED
@@ -1,35 +0,0 @@
1
- {
2
- "name_display": "Rohit",
3
- "name_slug": "rohit",
4
- "persona_id": "9f23a75fc19e",
5
- "profile": {
6
- "name": "Rohit",
7
- "age": 29,
8
- "dependents": "self",
9
- "income_band": "under_5L",
10
- "existing_cover_inr": 500000,
11
- "primary_goal": "first_buy",
12
- "location_tier": "metro",
13
- "parents_to_insure": null,
14
- "parents_age_max": null,
15
- "parents_has_ped": null,
16
- "budget_band": "30k_60k",
17
- "health_conditions": [],
18
- "asked": [
19
- "name",
20
- "location",
21
- "health_conditions",
22
- "budget"
23
- ],
24
- "free_form_session": false,
25
- "shown_policies": [],
26
- "selected_policies": [],
27
- "rejected_policies": []
28
- },
29
- "first_seen": "2026-05-15T03:36:41Z",
30
- "last_seen": "2026-05-15T03:48:34Z",
31
- "sessions": [
32
- "ki158_walk_001",
33
- "ki158_verify_001"
34
- ]
35
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40-data/profiles/README.md DELETED
@@ -1,67 +0,0 @@
1
- # `data/profiles/` — Named-profile JSON store
2
-
3
- Persistent name-keyed profile store introduced by **KI-040 (2026-05-14)**. Lets a returning visitor say their name and have the bot recognise them + auto-load the stored profile, so they don't have to walk the 9-slot fact-find again.
4
-
5
- Canonical store: `backend/profile_store.py`. ADR (deferred — code self-documents).
6
-
7
- ## File layout
8
-
9
- ```
10
- data/profiles/
11
- └── <normalised-name>.json (one file per user)
12
- ```
13
-
14
- - **Filename slug:** lowercase + alpha-only of the user's first name. `"Rohit"` and `"rohit."` both resolve to `rohit.json`.
15
- - **Inside the file:** the original capitalised display name is preserved.
16
-
17
- ## JSON shape
18
-
19
- ```json
20
- {
21
- "display_name": "Rohit",
22
- "first_seen": "2026-05-14T09:12:33Z",
23
- "last_seen": "2026-05-14T22:41:08Z",
24
- "sessions": ["sess_abc…", "sess_def…"],
25
- "profile": {
26
- "age": 32,
27
- "dependents": "spouse+1_child",
28
- "city_tier": "metro",
29
- "...": "..."
30
- }
31
- }
32
- ```
33
-
34
- Schema: `profile` mirrors the 9-slot `GRAPH` in `backend/needs_finder.py`. Everything else is bookkeeping.
35
-
36
- ## Two-layer sync
37
-
38
- | Layer | Purpose | Where |
39
- | --- | --- | --- |
40
- | JSON (this folder) | Canonical, O(1) name-keyed lookup, deterministic, human-readable, manually editable. | `backend/profile_store.py::save_profile` |
41
- | Chroma vector chunk | Re-embedded on every save so the brain sees the profile alongside policy chunks at retrieval time — powers "what's best for me?" questions. | `backend/profile_rag.py::upsert_profile_chunk` |
42
-
43
- Both stay in sync: `save_profile()` fires the Chroma upsert in the same call. Embedding cost is once per update, not per query.
44
-
45
- ## Why JSON, not Chroma-only
46
-
47
- The original design considered embedding-only. The "why JSON" trade-offs:
48
-
49
- - **Deterministic name lookup.** `Rohit` → `rohit.json` is exact; vector search is approximate and can collide on common first names.
50
- - **Human-readable.** A BFSI auditor can `cat` the file and see the full profile.
51
- - **Manually editable.** Quick repair without a re-embed pipeline.
52
- - **No HNSW bloat exposure.** Profile updates do not touch the policy vector store ([ADR-029](../70-docs/60-decisions/ADR-029-hnsw-bloat-tripwire.md)).
53
-
54
- The Chroma chunk is purely a retrieval-time view of the canonical JSON.
55
-
56
- ## Privacy + retention
57
-
58
- - Profiles are local to the deployed instance. No third-party share.
59
- - Per [ADR-010](../70-docs/60-decisions/ADR-010-secret-handling.md), the folder is not exposed via the HTTP API except through the user's own `session_id`.
60
- - The folder is committed empty (placeholder) — actual profiles are runtime artefacts.
61
-
62
- ## Related
63
-
64
- - `backend/profile_store.py` — the canonical store implementation
65
- - `backend/profile_extractor.py` + [ADR-022](../70-docs/60-decisions/ADR-022-conversational-profile-updates.md) — how conversational asides flow into the profile
66
- - `backend/profile_rag.py` — the embedding mirror
67
- - `backend/needs_finder.py::GRAPH` — the 9-slot schema the `profile` block conforms to
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40-data/profiles/asha.json DELETED
@@ -1,83 +0,0 @@
1
- {
2
- "name_display": "Asha",
3
- "name_slug": "asha",
4
- "persona_id": "ca28dcb8ed8b",
5
- "profile": {
6
- "name": "Asha",
7
- "age": 35,
8
- "dependents": "self+spouse",
9
- "income_band": "10L-25L",
10
- "existing_cover_inr": null,
11
- "primary_goal": "first_buy",
12
- "location_tier": "metro",
13
- "parents_to_insure": null,
14
- "parents_age_max": null,
15
- "parents_has_ped": null,
16
- "budget_band": null,
17
- "desired_sum_insured_inr": null,
18
- "health_conditions": [
19
- "none"
20
- ],
21
- "copay_pct": 0,
22
- "family_medical_history": [],
23
- "smoker": null,
24
- "asked": [
25
- "copay_pct"
26
- ],
27
- "free_form_session": false,
28
- "shown_policies": [
29
- {
30
- "policy_slug": "royal-sundaram__multiplier",
31
- "insurer": "royal-sundaram",
32
- "event_at": "2026-05-16T19:57:41Z",
33
- "session_id": "t_1541ede6",
34
- "reason": "shown_in_recommendation",
35
- "turn_idx": 1
36
- },
37
- {
38
- "policy_slug": "niva-bupa__reassure-3",
39
- "insurer": "niva-bupa",
40
- "event_at": "2026-05-16T19:57:41Z",
41
- "session_id": "t_1541ede6",
42
- "reason": "shown_in_recommendation",
43
- "turn_idx": 2
44
- },
45
- {
46
- "policy_slug": "care__supreme",
47
- "insurer": "care",
48
- "event_at": "2026-05-16T19:57:41Z",
49
- "session_id": "t_1541ede6",
50
- "reason": "shown_in_recommendation",
51
- "turn_idx": 2
52
- }
53
- ],
54
- "selected_policies": [],
55
- "rejected_policies": []
56
- },
57
- "first_seen": "2026-05-16T13:12:45Z",
58
- "last_seen": "2026-05-16T19:57:41Z",
59
- "sessions": [
60
- "t_8f24225c",
61
- "t_e9ffee10",
62
- "t_a40eaba0",
63
- "t_15bcbab1",
64
- "t_ea2b6e61",
65
- "t_f8bf16d3",
66
- "t_02f7e949",
67
- "t_3fa40ae8",
68
- "t_3aa7f39f",
69
- "t_5bcb0d4c",
70
- "t_8874c358",
71
- "t_3c159933",
72
- "t_bfb35b97",
73
- "t_a5549a35",
74
- "t_6602f010",
75
- "t_7f5224e7",
76
- "t_a220f19b",
77
- "t_fd487de1",
78
- "t_29a9cb78",
79
- "t_1541ede6"
80
- ],
81
- "recall_pointer": true,
82
- "points_to_persona_id": "ca28dcb8ed8b"
83
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40-data/profiles/c7b4ef2af903.json DELETED
@@ -1,46 +0,0 @@
1
- {
2
- "name_display": "X7 Smoke User",
3
- "name_slug": "x7-smoke-user",
4
- "persona_id": "c7b4ef2af903",
5
- "profile": {
6
- "name": "X7 Smoke User",
7
- "age": null,
8
- "dependents": null,
9
- "income_band": null,
10
- "existing_cover_inr": null,
11
- "primary_goal": null,
12
- "location_tier": null,
13
- "parents_to_insure": null,
14
- "parents_age_max": null,
15
- "parents_has_ped": null,
16
- "budget_band": null,
17
- "health_conditions": [],
18
- "asked": [],
19
- "free_form_session": false,
20
- "shown_policies": [
21
- {
22
- "policy_slug": "care-supreme",
23
- "insurer": "care_health",
24
- "event_at": "2026-05-15T08:13:05Z",
25
- "session_id": "x7-smoke-25746",
26
- "reason": "shown_in_recommendation",
27
- "turn_idx": 4
28
- },
29
- {
30
- "policy_slug": "star-comprehensive",
31
- "insurer": "star_health",
32
- "event_at": "2026-05-15T08:13:05Z",
33
- "session_id": "x7-smoke-25746",
34
- "reason": "shown_in_recommendation",
35
- "turn_idx": 4
36
- }
37
- ],
38
- "selected_policies": [],
39
- "rejected_policies": []
40
- },
41
- "first_seen": "2026-05-15T08:13:05Z",
42
- "last_seen": "2026-05-15T08:13:05Z",
43
- "sessions": [
44
- "x7-smoke-25746"
45
- ]
46
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40-data/profiles/ca28dcb8ed8b.json DELETED
@@ -1,81 +0,0 @@
1
- {
2
- "name_display": "Asha",
3
- "name_slug": "asha",
4
- "persona_id": "ca28dcb8ed8b",
5
- "profile": {
6
- "name": "Asha",
7
- "age": 35,
8
- "dependents": "self+spouse",
9
- "income_band": "10L-25L",
10
- "existing_cover_inr": null,
11
- "primary_goal": "first_buy",
12
- "location_tier": "metro",
13
- "parents_to_insure": null,
14
- "parents_age_max": null,
15
- "parents_has_ped": null,
16
- "budget_band": null,
17
- "desired_sum_insured_inr": null,
18
- "health_conditions": [
19
- "none"
20
- ],
21
- "copay_pct": 0,
22
- "family_medical_history": [],
23
- "smoker": null,
24
- "asked": [
25
- "copay_pct"
26
- ],
27
- "free_form_session": false,
28
- "shown_policies": [
29
- {
30
- "policy_slug": "royal-sundaram__multiplier",
31
- "insurer": "royal-sundaram",
32
- "event_at": "2026-05-16T19:57:41Z",
33
- "session_id": "t_1541ede6",
34
- "reason": "shown_in_recommendation",
35
- "turn_idx": 1
36
- },
37
- {
38
- "policy_slug": "niva-bupa__reassure-3",
39
- "insurer": "niva-bupa",
40
- "event_at": "2026-05-16T19:57:41Z",
41
- "session_id": "t_1541ede6",
42
- "reason": "shown_in_recommendation",
43
- "turn_idx": 2
44
- },
45
- {
46
- "policy_slug": "care__supreme",
47
- "insurer": "care",
48
- "event_at": "2026-05-16T19:57:41Z",
49
- "session_id": "t_1541ede6",
50
- "reason": "shown_in_recommendation",
51
- "turn_idx": 2
52
- }
53
- ],
54
- "selected_policies": [],
55
- "rejected_policies": []
56
- },
57
- "first_seen": "2026-05-16T13:12:45Z",
58
- "last_seen": "2026-05-16T19:57:41Z",
59
- "sessions": [
60
- "t_8f24225c",
61
- "t_e9ffee10",
62
- "t_a40eaba0",
63
- "t_15bcbab1",
64
- "t_ea2b6e61",
65
- "t_f8bf16d3",
66
- "t_02f7e949",
67
- "t_3fa40ae8",
68
- "t_3aa7f39f",
69
- "t_5bcb0d4c",
70
- "t_8874c358",
71
- "t_3c159933",
72
- "t_bfb35b97",
73
- "t_a5549a35",
74
- "t_6602f010",
75
- "t_7f5224e7",
76
- "t_a220f19b",
77
- "t_fd487de1",
78
- "t_29a9cb78",
79
- "t_1541ede6"
80
- ]
81
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40-data/profiles/dd8cd6c71443.json DELETED
@@ -1,34 +0,0 @@
1
- {
2
- "name_display": "Rohit",
3
- "name_slug": "rohit",
4
- "persona_id": "dd8cd6c71443",
5
- "profile": {
6
- "name": "Rohit",
7
- "age": 29,
8
- "dependents": null,
9
- "income_band": "under_5L",
10
- "existing_cover_inr": null,
11
- "primary_goal": null,
12
- "location_tier": null,
13
- "parents_to_insure": null,
14
- "parents_age_max": null,
15
- "parents_has_ped": null,
16
- "budget_band": "under_15k",
17
- "health_conditions": [],
18
- "asked": [
19
- "name",
20
- "age",
21
- "income_band",
22
- "budget"
23
- ],
24
- "free_form_session": false,
25
- "shown_policies": [],
26
- "selected_policies": [],
27
- "rejected_policies": []
28
- },
29
- "first_seen": "2026-05-15T03:36:28Z",
30
- "last_seen": "2026-05-15T03:36:28Z",
31
- "sessions": [
32
- "ki158_walk_001"
33
- ]
34
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40-data/profiles/f31d3896ff05.json DELETED
@@ -1,36 +0,0 @@
1
- {
2
- "name_display": "Rohit",
3
- "name_slug": "rohit",
4
- "persona_id": "f31d3896ff05",
5
- "profile": {
6
- "name": "Rohit",
7
- "age": 32,
8
- "dependents": "self+spouse+kids",
9
- "income_band": null,
10
- "existing_cover_inr": 0,
11
- "primary_goal": "first_buy",
12
- "location_tier": null,
13
- "parents_to_insure": null,
14
- "parents_age_max": null,
15
- "parents_has_ped": null,
16
- "budget_band": "15k_30k",
17
- "health_conditions": [],
18
- "asked": [
19
- "name",
20
- "age",
21
- "dependents",
22
- "primary_goal",
23
- "existing_cover",
24
- "budget"
25
- ],
26
- "free_form_session": false,
27
- "shown_policies": [],
28
- "selected_policies": [],
29
- "rejected_policies": []
30
- },
31
- "first_seen": "2026-05-15T01:02:36Z",
32
- "last_seen": "2026-05-15T01:02:36Z",
33
- "sessions": [
34
- "2e4536ac-41ec-4683-aac4-7a069b10f1f6"
35
- ]
36
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40-data/profiles/rohit.json DELETED
@@ -1,43 +0,0 @@
1
- {
2
- "name_display": "Rohit",
3
- "name_slug": "rohit",
4
- "persona_id": "93b74ab9ae9f",
5
- "profile": {
6
- "name": "Rohit",
7
- "age": 34,
8
- "dependents": "self+spouse+kids",
9
- "income_band": "18L",
10
- "existing_cover_inr": null,
11
- "primary_goal": "first_buy",
12
- "location_tier": "metro",
13
- "parents_to_insure": null,
14
- "parents_age_max": null,
15
- "parents_has_ped": null,
16
- "budget_band": null,
17
- "desired_sum_insured_inr": null,
18
- "health_conditions": [],
19
- "copay_pct": null,
20
- "family_medical_history": [],
21
- "smoker": null,
22
- "asked": [
23
- "name",
24
- "age",
25
- "dependents",
26
- "location_tier",
27
- "income_band",
28
- "primary_goal"
29
- ],
30
- "free_form_session": false,
31
- "shown_policies": [],
32
- "selected_policies": [],
33
- "rejected_policies": []
34
- },
35
- "first_seen": "2026-05-16T06:41:30Z",
36
- "last_seen": "2026-05-16T06:42:12Z",
37
- "sessions": [
38
- "sessA_7d9d11fb",
39
- "sessB_595c9c88"
40
- ],
41
- "recall_pointer": true,
42
- "points_to_persona_id": "93b74ab9ae9f"
43
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70-docs/60-decisions/ADR-041-session-profile-lifecycle.md CHANGED
@@ -1,9 +1,10 @@
1
  # ADR-041 — Session & profile lifecycle (KI-196)
2
 
3
- **Status:** Accepted 2026-05-15
 
4
  **Owner:** Rohit Saraf
5
  **Related KIs:** KI-020 (legacy clear-chat), KI-040 (silent name-based recall), KI-062 (persona_id keying), KI-077 (named profile recovery), KI-118 (in-memory-only sessions + cross-session name match), KI-167 (LLM-driven sales brain)
6
- **Related ADRs:** [ADR-022](ADR-022-conversational-profile-updates.md), [ADR-039](ADR-039-llm-driven-sales-brain.md)
7
 
8
  ## Context
9
 
 
1
  # ADR-041 — Session & profile lifecycle (KI-196)
2
 
3
+ **Status:** **SUPERSEDED by [ADR-043](ADR-043-remove-cross-session-recall.md) (2026-05-27).** The two-tier persona_id + name-slug pointer store described below was removed entirely. Sessions are now in-memory only. Kept here for historical record.
4
+ **Originally accepted:** 2026-05-15
5
  **Owner:** Rohit Saraf
6
  **Related KIs:** KI-020 (legacy clear-chat), KI-040 (silent name-based recall), KI-062 (persona_id keying), KI-077 (named profile recovery), KI-118 (in-memory-only sessions + cross-session name match), KI-167 (LLM-driven sales brain)
7
+ **Related ADRs:** [ADR-022](ADR-022-conversational-profile-updates.md), [ADR-039](ADR-039-llm-driven-sales-brain.md), [ADR-043](ADR-043-remove-cross-session-recall.md) (supersedes this ADR)
8
 
9
  ## Context
10
 
70-docs/60-decisions/ADR-042-privacy-hardening-and-sticky-retry.md CHANGED
@@ -1,9 +1,8 @@
1
  # ADR-042 — Privacy hardening (recall redaction + match-before-merge), sticky-retry schedule, honest canned-failure copy, admin refresh wiring
2
 
3
  **Date:** 2026-05-27
4
- **Status:** Accepted
5
- **Supersedes:** Extends ADR-041 (`session-profile-lifecycle.md`) on the recall confirm gate; does not replace it.
6
- **Commits:** `2acdc9e` (v1), `10e6843` (v2 same-turn age contradiction).
7
 
8
  ## Context
9
 
 
1
  # ADR-042 — Privacy hardening (recall redaction + match-before-merge), sticky-retry schedule, honest canned-failure copy, admin refresh wiring
2
 
3
  **Date:** 2026-05-27
4
+ **Status:** **PARTIALLY SUPERSEDED by [ADR-043](ADR-043-remove-cross-session-recall.md) (same day).** The recall-prompt redaction (D3), match-before-merge guard (D4), v3 extended extractors and v4 two-fact gate are obsolete — cross-session recall was removed entirely. The sticky-retry schedule (D1), honest canned-failure copy (D2) and admin refresh wiring (D5) are KEPT and remain in production.
5
+ **Commits:** `2acdc9e` (v1), `10e6843` (v2 same-turn age contradiction), `2be56b4` (v3+v4 extended extractors + two-fact gate).
 
6
 
7
  ## Context
8
 
70-docs/60-decisions/ADR-043-remove-cross-session-recall.md ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-043 — Remove cross-session profile recall entirely
2
+
3
+ **Date:** 2026-05-27
4
+ **Status:** Accepted
5
+ **Supersedes:** ADR-041 (`session-profile-lifecycle.md`), ADR-042 (`privacy-hardening-and-sticky-retry.md`). The portions of ADR-042 covering sticky-session retry and the admin LLM Chain refresh wiring are KEPT — those concerns are independent of the recall feature and remain live.
6
+
7
+ ## Context
8
+
9
+ Two-week arc of recall hardening that kept exposing new failure modes:
10
+
11
+ | Doc / Fix | When | Hardening pass |
12
+ |---|---|---|
13
+ | ADR-041 (session-profile-lifecycle) | 2026-05-15 | Original two-tier name-slug + persona_id JSON store with a staged-confirm Welcome Back gate |
14
+ | KI-196 / Bug #25 | 2026-05-19 | Probe must fire when name lands on turn ≥2, not just turn 1 |
15
+ | Bug #26 (state-recovery) | 2026-05-19 | Container restart mid-conversation → rebuild profile from chat_history |
16
+ | ADR-042 v1 | 2026-05-27 | Redact stored attrs from the Welcome-Back prompt + prior-turn match-before-merge guard |
17
+ | ADR-042 v2 | 2026-05-27 | Same-turn age regex guard (since `_affirm_or_deny` fires before `save_profile_field`) |
18
+ | ADR-042 v3 | 2026-05-27 | Extend extractors to dependents / location_tier / income_band |
19
+ | ADR-042 v4 | 2026-05-27 | Two-fact recall gate in `rehydrate_by_name` — bare name no longer stages |
20
+
21
+ Each pass was a real bug class. After v4 the live audit still showed seeded-test failures because the slug pointer was being overwritten between tests, exposing the deeper structural truth: **a name-only key cannot safely distinguish two visitors who share the name**, and the workarounds keep accumulating without converging.
22
+
23
+ Cost/benefit for an insurance-shopping product:
24
+
25
+ - **Value of recall:** "the bot remembers you next visit." Insurance shopping is rare-purchase (typical user buys once every several years); return sessions are uncommon.
26
+ - **Complexity tax:** ~1500 LOC across `profile_store.py`, `profile_persistence.py`, `profile_rag.py`, ~half of `session_state.py`, the `recall_block` / `restored_block` / `_affirm_or_deny` / `_RECALL_*` constants and the ~70-line prelude in `single_brain.handle_turn`, the `/api/profile/recall-by-name` endpoint + frontend wrapper, `40-data/profiles/<name>.json` × 100+ files on disk, two ADRs, multiple dedicated test files, and audit-time state drift on the live HF Space.
27
+ - **Privacy surface:** the name-slug pointer collides across distinct users; every layer of guard (redaction, match-before-merge, two-fact gate) was added because that collision creates a leak vector. A "minimum data retention" posture sidesteps all of it.
28
+
29
+ The choice that simplifies all of that in one move: **don't carry anything across sessions.**
30
+
31
+ ## Decision
32
+
33
+ Remove the cross-session profile recall feature entirely. Sessions are in-memory only.
34
+
35
+ Concretely:
36
+
37
+ - **Deleted modules:** `backend/profile_store.py`, `backend/profile_persistence.py`, `backend/profile_rag.py`.
38
+ - **Deleted endpoint:** `POST /api/profile/recall-by-name`. Old clients pinging the path get 404 — the correct degraded response.
39
+ - **Deleted frontend caller:** `postProfileRecallByName` + `RecallByNameResponse` type in `frontend/src/lib/api.ts`.
40
+ - **Deleted symbols (formerly in `backend/session_state.py`):** `rehydrate_by_name`, `apply_pending_recall`, `_AGE_HINT_RE`, `_extract_age_from_text`, `_extract_dependents_from_text`, `_extract_location_tier_from_text`, `_extract_income_band_from_text`, `_parse_user_text_facts`, `_LOCATION_TIER_MAP`, `_RECALL_SUMMARY_FIELDS`, the `pending_profile_recall` / `recall_probe_done` / `recall_match_deferred` fields on `SessionState`.
41
+ - **Deleted symbols (formerly in `backend/single_brain.py`):** `_affirm_or_deny`, `_RECALL_AFFIRM_TOKENS`, `_RECALL_DENY_TOKENS`, `_RECALL_AFFIRM_PHRASES`, `_RECALL_DENY_PHRASES`, `_RECALL_TOKEN_RE`, the entire `recall_block` + `restored_block` prompt sections, the `pending_recall` / `recall_applied` parameters to `_system_instruction`, and the ~70-line recall prelude in `handle_turn`.
42
+ - **Deleted data:** `40-data/profiles/` directory removed from the repo (`git rm -r`) and from the live HF Space's working filesystem on next rebuild.
43
+ - **Deleted tests:** `test_bug2526_recall_and_reconstruct.py`, `test_bug45_chat_profile_persistence.py`, `test_profile_rag_isolation.py`, `test_profile_recall_session_isolation.py`, `test_returning_user_recall_singlebrain.py`. `test_session_no_disk_persistence.py` is KEPT and its docstring updated.
44
+ - **Converted to in-memory:** the `POST /api/profile/select` + `POST /api/profile/reject` shortlist endpoints in `backend/admin.py` now mutate only `SessionState.profile.selected_policies` / `rejected_policies` — no disk write. `brain_tools.mark_recommendation`'s shown-policy tracking is similarly in-memory only.
45
+ - **Converted to live-session view:** `/api/admin/profiles`, `/api/admin/persona-drift`, `/api/admin/recommendation-history` now read from `session_state._sessions` (the in-memory dict of currently live sessions) instead of walking on-disk JSON files.
46
+
47
+ ## What's kept
48
+
49
+ - **`SessionState.profile`** — still the per-session dataclass, populated by `save_profile_field` and by `POST /api/profile`. Evicted on `_TTL_SECONDS = 60 * 60` idle.
50
+ - **State-recovery from chat_history (Bug #26)** — in-session only. If a container restart blanks `_sessions` but the browser is still on the same tab, the brain rebuilds the profile from the chat history the client re-sends. Never reads disk.
51
+ - **The sticky-session retry policy from ADR-042** (`_gemini_call(is_sticky=True)` with jittered exp backoffs) — kept as-is; it has nothing to do with recall.
52
+ - **The admin LLM Chain refresh wiring from ADR-042** (KI-296) — kept; same independence.
53
+
54
+ ## Consequences
55
+
56
+ - **Privacy by default.** Closing the tab is a complete forget. No name-keyed cross-session inheritance is possible because no such mechanism exists.
57
+ - **Code volume.** Net deletion ≈ 1,500 LOC plus the 100+ profile JSONs.
58
+ - **Operator view changes shape.** Admin profile / drift / recommendation-history endpoints now show live in-memory sessions only; historical events evict with the session. If long-term analytics are wanted, the right channel is an append-only anonymised `usage_log.jsonl`, not a profile JSON store.
59
+ - **No more "Welcome back, <name>?" UX.** Trade-off accepted — the slug-collision class of bug goes away with it.
60
+ - **State recovery still survives container restarts** for users who stay on the same tab. The common operational concern from Bug #26 is unaffected.
61
+ - **Frontend changes are minimal.** `postProfileRecallByName` is deleted; no callers existed. The Clear-chat / profile-builder UX paths continue to work unchanged.
62
+
63
+ ## Verification
64
+
65
+ - Compile: every backend module compiles after the deletion.
66
+ - Imports: every backend module imports cleanly under `.venv/bin/python` — no orphan references to removed modules.
67
+ - Grep sweep across `backend/`, `frontend/`, and `tests/` for `profile_store`, `profile_persistence`, `profile_rag`, `rehydrate_by_name`, `apply_pending_recall`, `try_recall_by_name`, `recall_by_name_payload`, `extract_potential_name`, `auto_persist_session`, `pending_profile_recall`, `recall_match_deferred`, `recall_probe_done`, `_RECALL_AFFIRM`, `_RECALL_DENY`, `_affirm_or_deny`, `_extract_age_from_text`, `_extract_dependents_from_text`, `_extract_location_tier_from_text`, `_extract_income_band_from_text`, `_parse_user_text_facts`, `_AGE_HINT_RE`, `_LOCATION_TIER_MAP`, `record_policy_event`, `_LazyProfilesDir`, `_PROFILES_DIR_FOR_DRIFT` — all return zero active code references (only the comment lines in this ADR and the prose pointers in CLAUDE.md / README.md remain).
68
+ - Live audit (deferred to deploy commit) — confirms no Welcome Back text fires regardless of name, the bot proceeds with normal fact-find, and the admin tabs render an empty/live-only view.
69
+
70
+ ## Open follow-ups
71
+
72
+ None. The deferred recall-feature follow-ups from ADR-042 are dissolved by the removal — there is nothing to disambiguate.
73
+
74
+ ## How this is told in an interview
75
+
76
+ - Build of trust → forced explicit privacy choices. The product holds user health and family data; "minimum data retention" is a stronger story than "we keep your profile but redact the prompt."
77
+ - Documented the iteration honestly. Five hardening passes on a feature with a high complexity-to-value ratio was the signal to delete, not the signal to ship pass six.
78
+ - Showed the operating discipline of cutting your own code. The change is a 1,500-LOC *deletion*. The simpler architecture is easier to explain, easier to audit, and provably free of the collision-class bug.
CLAUDE.md CHANGED
@@ -60,7 +60,7 @@ Every LLM role is a `NimChainLLM` candidate pool, NOT a hardcoded single model.
60
  - **Single LLM call per turn** via `NimChainLLM(FAST_BRAIN_CHAIN)` against the [ADR-040](70-docs/60-decisions/ADR-040-google-gemini-primary.md) three-tier pool (Google Tier 0 → NIM Tier 1 → OpenRouter Tier 2). Google uses **`response_mime_type=application/json`**; NIM uses **`response_format={"type":"json_object"}`**; OpenRouter free-tier candidates (`nvidia/nemotron-3-super-120b-a12b:free`, `qwen/qwen3-next-80b-a3b-instruct:free`) also support `response_format` natively (KI-178 audit). Every candidate enforces structured output server-side — same contract already validated in `backend/translation_check.py`, `backend/faithfulness.py`, and `backend/security.py`. Response shape: `{"reply": "<prose for user>", "captures": {<slot_id>: <raw>}, "slot_driving": "<slot_id|null>", "complete": <bool>}`.
61
  - **System prompt** carries the 9-slot schema + current profile state. The LLM is free to ask in any order, in any voice, multi-fact in one turn or one slot at a time. No scripted opener, no acknowledger prefix, no `prompt_en` template.
62
  - **Deterministic post-processor** (`backend/sales_brain_normalizer.py`) takes the LLM's loose `captures` dict and emits a `{canonical_field: validated_value}` map: alias resolution (`location` → `location_tier`), enum normalization (`Bangalore` → `metro`), INR-amount parsing, null/empty drop, type/bounds validation. No LLM calls — pure rules. Override: if LLM sets `complete: true` while any required slot is empty, force `complete: false`.
63
- - **Profile persistence unchanged.** Captures flow through `session.update_profile_field()` `backend/profile_store.save_profile()` (disk durability) `backend/profile_rag.upsert_profile_chunk()` (retrieval-side visibility). KI-102 / KI-107 / KI-112 session-isolation guards remain in force.
64
  - **No scripted prompts, no canonical_fallback, no trailer convention.** `backend/needs_finder.py::GRAPH` slot-id data stays as a schema source for the system prompt, but `Question.prompt_en` is dead text — never consulted by the fact-find branch. `_canonical_fallback`, `_normalize_for_slot`, `_pick_opener`, `_NEUTRAL_OPENERS`, `_FAMILY_OPENERS`, `_contains_self_introduction`, the lenient `<FF>` parser ladder, and the `"Got that — {slot}."` prefix logic are deleted.
65
  - **Outer 25s `asyncio.wait_for` ceiling retained.** On total chain exhaustion (all of Google → NIM → OpenRouter fail in a single turn) the orchestrator returns the graceful error to the user (fail-loud) rather than cascading to a scripted reply. There is no scripted safety net — that is intentional.
66
  - **DELETED in KI-167:** `backend/fact_find_brain.py` (441 LOC); `_canonical_fallback` + `_pick_opener` branches in `backend/orchestrator.py`; `_ff_failed_attempts` / `_ff_skipped_slots` session fields; every `fact_find_brain::fallback:*` telemetry variant; the lenient `<FF>` / fenced-`json` / bare-JSON-tail parser ladder.
@@ -68,13 +68,13 @@ Every LLM role is a `NimChainLLM` candidate pool, NOT a hardcoded single model.
68
  - **Natural-conversation escape (KI-045):** intent_change phrases / off-topic questions still exit fact-find by routing through `should_route_to_fact_find` upstream of the brain.
69
  - **Indic queries** route through Sarvam-M for translation on input + output; the sales brain runs in English on the translated text.
70
 
71
- ## Session & profile lifecycle (ADR-041, hardened by ADR-042) KI-196, KI-296
72
 
73
- - **Three identity layers stay distinct.** `session_id` (UUID, in-memory only via `backend/session_state.py`) `persona_id` (12-char SHA1, keys `40-data/profiles/<persona_id>.json`) `name_slug` (canonical first-name, Chroma chunk key). New tab = new `session_id`; same-tab refresh = same `session_id` (`sessionStorage`); on-disk profile JSONs are durable user data and are NEVER deleted by frontend actions.
74
- - **`POST /api/session/clear`** is the canonical "Clear chat" endpoint. Body `{session_id}`; reply `{cleared, new_session_id}`. Wipes the in-memory entry via `clear_session()` and always mints a fresh UUID. The legacy `POST /api/session/reset` (KI-020) is left in place for backwards compatibility but the frontend Clear-chat button calls `/api/session/clear`.
75
- - **Confirmation-gated profile recall (privacy-hardened 2026-05-27, ADR-042).** When a fresh session captures a `name` that matches an on-disk profile, the brain stages the match into `session.pending_profile_recall` and emits a **redacted** welcome-back ask — *"Welcome back — have we spoken before? If yes, please share your age so I can pull up the right profile. If not, no problem — just say so and we'll start fresh."* — NEVER disclosing the staged attrs (age / dependents / location / goal). The user's NEXT message hits the affirm/deny gate at the top of `handle_turn`. **Match-before-merge guard** in `apply_pending_recall(session, *, confirmed, user_text)` discards the staged recall when any decision-critical staged field contradicts either a prior-turn live capture *or* a same-turn age parsed from `user_text` (best-effort regex `_extract_age_from_text`). Two-tier scheme on disk: `<persona_id>.json` is canonical, `<name_slug>.json` is the recall pointer rewritten by every save.
76
- - **Sticky-session retry policy (ADR-042).** `_gemini_call` accepts `is_sticky` (read once from `session.single_brain_sticky` in `handle_turn`). Non-sticky: 1 retry @ 1.5 s (fast-fail to nim_fallback on cold-start). Sticky: 2 retries with jittered exp backoffs (1.5 s → 3 s, ±25 %). The user-facing canned reply on exhausted retries is now *"My model service had a brief blip on that turn — please send the same message again, it should go through now."* (not "could you say that again?").
77
- - **Profile completeness gates on `Profile.asked`.** Default `dependents="self"` pre-fill in the builder form no longer registers as "done"; `profile_completeness_view` + `POST /api/profile` mask any field not in the `asked` list before scoring. The "Your profile X% done" badge now starts at 0% for a brand-new session and only ticks up on explicit captures.
78
 
79
  ## Refusal precision (KI-046)
80
 
 
60
  - **Single LLM call per turn** via `NimChainLLM(FAST_BRAIN_CHAIN)` against the [ADR-040](70-docs/60-decisions/ADR-040-google-gemini-primary.md) three-tier pool (Google Tier 0 → NIM Tier 1 → OpenRouter Tier 2). Google uses **`response_mime_type=application/json`**; NIM uses **`response_format={"type":"json_object"}`**; OpenRouter free-tier candidates (`nvidia/nemotron-3-super-120b-a12b:free`, `qwen/qwen3-next-80b-a3b-instruct:free`) also support `response_format` natively (KI-178 audit). Every candidate enforces structured output server-side — same contract already validated in `backend/translation_check.py`, `backend/faithfulness.py`, and `backend/security.py`. Response shape: `{"reply": "<prose for user>", "captures": {<slot_id>: <raw>}, "slot_driving": "<slot_id|null>", "complete": <bool>}`.
61
  - **System prompt** carries the 9-slot schema + current profile state. The LLM is free to ask in any order, in any voice, multi-fact in one turn or one slot at a time. No scripted opener, no acknowledger prefix, no `prompt_en` template.
62
  - **Deterministic post-processor** (`backend/sales_brain_normalizer.py`) takes the LLM's loose `captures` dict and emits a `{canonical_field: validated_value}` map: alias resolution (`location` → `location_tier`), enum normalization (`Bangalore` → `metro`), INR-amount parsing, null/empty drop, type/bounds validation. No LLM calls — pure rules. Override: if LLM sets `complete: true` while any required slot is empty, force `complete: false`.
63
+ - **Profile persistence (post-ADR-043, 2026-05-27).** Captures flow through `session.update_profile_field()` into the in-memory `SessionState.profile` only. The previous `backend/profile_store.save_profile()` disk write and `backend/profile_rag.upsert_profile_chunk()` Chroma write were **removed entirely** when cross-session recall was retired (ADR-043). Closing the tab discards the profile.
64
  - **No scripted prompts, no canonical_fallback, no trailer convention.** `backend/needs_finder.py::GRAPH` slot-id data stays as a schema source for the system prompt, but `Question.prompt_en` is dead text — never consulted by the fact-find branch. `_canonical_fallback`, `_normalize_for_slot`, `_pick_opener`, `_NEUTRAL_OPENERS`, `_FAMILY_OPENERS`, `_contains_self_introduction`, the lenient `<FF>` parser ladder, and the `"Got that — {slot}."` prefix logic are deleted.
65
  - **Outer 25s `asyncio.wait_for` ceiling retained.** On total chain exhaustion (all of Google → NIM → OpenRouter fail in a single turn) the orchestrator returns the graceful error to the user (fail-loud) rather than cascading to a scripted reply. There is no scripted safety net — that is intentional.
66
  - **DELETED in KI-167:** `backend/fact_find_brain.py` (441 LOC); `_canonical_fallback` + `_pick_opener` branches in `backend/orchestrator.py`; `_ff_failed_attempts` / `_ff_skipped_slots` session fields; every `fact_find_brain::fallback:*` telemetry variant; the lenient `<FF>` / fenced-`json` / bare-JSON-tail parser ladder.
 
68
  - **Natural-conversation escape (KI-045):** intent_change phrases / off-topic questions still exit fact-find by routing through `should_route_to_fact_find` upstream of the brain.
69
  - **Indic queries** route through Sarvam-M for translation on input + output; the sales brain runs in English on the translated text.
70
 
71
+ ## Session & profile lifecycle (ADR-043 supersedes ADR-041 + ADR-042)
72
 
73
+ - **Sessions are in-memory only.** ADR-043 (2026-05-27) removed the cross-session recall layer entirely. `SessionState` lives in process memory in `session_state._sessions`; idle entries are evicted after `_TTL_SECONDS = 60 * 60`. There is no on-disk profile store, no name-slug pointer, no persona_id JSON, no profile_rag Chroma chunk, no Welcome-Back prompt, no `pending_profile_recall`, no `apply_pending_recall`, no `try_recall_by_name`, no `_extract_*_from_text` extractors. Closing the tab or hitting *Clear chat* discards the profile permanently that's the privacy contract.
74
+ - **`POST /api/session/clear`** is the canonical "Clear chat" endpoint. Body `{session_id}`; reply `{cleared, new_session_id}`. Wipes the in-memory entry via `clear_session()` and always mints a fresh UUID. The legacy `POST /api/session/reset` (KI-020) is left in place for backwards compatibility.
75
+ - **State-recovery from chat_history (Bug #26, in-session only).** If a container restart / >1 h idle blanked the server-side session BUT the browser is still on the same tab carrying the chat history, the brain enters **STATE-RECOVERY MODE** and silently re-captures the already-stated facts from history via `save_profile_field`. This is purely in-session resilience it never reads disk, never crosses sessions.
76
+ - **Sticky-session retry policy (kept from ADR-042).** `_gemini_call` accepts `is_sticky` (read once from `session.single_brain_sticky` in `handle_turn`). Non-sticky: 1 retry @ 1.5 s (fast-fail to nim_fallback on cold-start). Sticky: 2 retries with jittered exp backoffs (1.5 s → 3 s, ±25 %). The user-facing canned reply on exhausted retries: *"My model service had a brief blip on that turn — please send the same message again, it should go through now."*
77
+ - **Profile completeness gates on `Profile.asked`.** Default `dependents="self"` pre-fill in the builder form no longer registers as "done"; `profile_completeness_view` + `POST /api/profile` mask any field not in the `asked` list before scoring. The "Your profile X% done" badge starts at 0% for a brand-new session and only ticks up on explicit captures.
78
 
79
  ## Refusal precision (KI-046)
80
 
README.md CHANGED
@@ -125,13 +125,8 @@ to deciding with confidence.
125
 
126
  ```mermaid
127
  flowchart TD
128
- S["🌐 You open the app — web or mobile, nothing to install"] --> R{"Used it before?"}
129
- R -->|"Returning"| WB["👋 &quot;Welcome back, &lt;name&gt;?&quot; your earlier profile is offered back"]
130
- R -->|"First time"| TELL
131
- WB -->|"yes, that's me"| KNOWN["Picks up with your saved profile — no re-typing"]
132
- WB -->|"no / not me"| TELL
133
- KNOWN --> REC
134
- TELL["🗣️ Tell it about you — a short chat, typed OR spoken, English / Hindi-Hinglish<br/>age · family · budget · health · what you care about"] --> ASK["❓ It asks just 2–3 clarifying questions<br/>(a real conversation, never a long form)"]
135
  ASK --> REC["🎯 A personalised shortlist — plans ranked for YOUR fit, each with the reason it fits"]
136
  REC --> WHY["🔍 Open any plan: every fact is backed by the exact clause in the real policy PDF<br/>an honest &quot;not stated in the document&quot; instead of a guess"]
137
  WHY --> EXPLORE{"Want to dig deeper?"}
@@ -148,16 +143,14 @@ flowchart TD
148
  VOICE -.-> QA
149
  ```
150
 
151
- **Summary.** A first-time user opens the app and ends the
152
- session having decided on a plan with confidence — and how the system loops
153
- through compare / browse / Q&A / upload along the way. No backend in this
154
- view; just the human path.
 
155
 
156
  **How it flows:**
157
 
158
- - **Returning-user check up front.** If the app recognises your name from
159
- before, it offers to restore your saved profile (so you never re-type);
160
- *no* takes you down the same path as a first-time user.
161
  - **Conversational fact-find.** A short typed-or-spoken back-and-forth
162
  (English or Hindi-Hinglish) captures age, family, budget, health and
163
  what you care about — instead of a long form.
@@ -204,7 +197,7 @@ flowchart LR
204
  API["HTTP endpoints + orchestration<br/>backend/main.py"]
205
  BRAIN["🧠 LLM Brain<br/>Google Gemini + function-calling tools<br/>(NIM fallback chain on failure)"]
206
  SCORE["🎯 Scoring + Pricing<br/>scorecard.py · premium_calculator.py"]
207
- PROF["👤 Profile & Persistence<br/>session_state · profile_store"]
208
  end
209
  subgraph DATA["📚 Data layer"]
210
  VEC["Vector DB (Chroma) — policy chunks<br/>+ per-session quarantine (uploads)"]
@@ -233,7 +226,7 @@ flowchart LR
233
 
234
  - **1. Frontend (browser · Next.js).** Renders chat, marketplace, compare, and the profile builder. Sends typed text and audio over HTTP, plays the synthesised reply.
235
  - **2. Voice.** `Sarvam STT (in)` turns spoken audio into a text turn; `Sarvam TTS (out)` turns the reply text back into spoken audio.
236
- - **3. Backend (FastAPI).** Four sub-blocks — **3a** HTTP endpoints + orchestration (`backend/main.py`); **3b** LLM Brain (Gemini + function-calling tools; NIM fallback on failure); **3c** Scoring + Pricing (`scorecard.py` + `premium_calculator.py`); **3d** Profile & Persistence (`session_state` + `profile_store`).
237
  - **4. Data layer.** Two stores — the Chroma **vector DB** (shared policy chunks + per-session quarantine for uploads) and curated **JSON facts** at `40-data/policy_facts/*.json`. The brain, scoring, and pricing all read from these.
238
 
239
  **Diagram legend (used throughout §2):**
@@ -283,10 +276,9 @@ flowchart TB
283
  SC1["grade_per_profile<br/>scorecard.py"]
284
  SC2["estimate_premium<br/>premium_calculator.py"]
285
  end
286
- subgraph BE_PROF["3d. Profile & Persistence"]
287
- P1["update_session_profile"]
288
- P2["persist_to_disk<br/>end of turn"]
289
- P3["recall_by_name<br/>returning user"]
290
  end
291
  end
292
 
@@ -294,7 +286,6 @@ flowchart TB
294
  direction TB
295
  D1["vector_search<br/>Chroma · BGE-small"]
296
  D2["fact_lookup<br/>40-data/policy_facts/*.json"]
297
- D3["read_user_profile<br/>40-data/profiles/&lt;name&gt;.json"]
298
  end
299
 
300
  %% forward edges (input / down the pipeline)
@@ -310,19 +301,17 @@ flowchart TB
310
  B2 --> P1
311
  B3 --> D1
312
  B4 --> D2
313
- P3 --> D3
314
  A2 --> SC1
315
  A2 --> SC2
316
  SC1 -->|"reads"| D2
317
  SC2 -->|"reads"| D2
318
  SC1 -->|"reads"| P1
319
  SC2 -->|"reads"| P1
320
- P1 -.->|"end of turn"| P2
321
 
322
  %% return edges (output / back to caller)
323
  D1 -.->|"top-k chunks"| B3
324
  D2 -.->|"per-policy facts"| B4
325
- D3 -.->|"saved profile"| P3
326
  SC1 -.->|"grade"| A2
327
  SC2 -.->|"premium range"| A2
328
  B1 -.->|"reply + citations"| A2
@@ -331,8 +320,8 @@ flowchart TB
331
  V2 -.->|"audio"| F2
332
 
333
  %% blue solid = forward · orange dashed = return
334
- linkStyle 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18 stroke:#1565c0,stroke-width:2px
335
- linkStyle 19,20,21,22,23,24,25,26,27 stroke:#e65100,stroke-width:2px,stroke-dasharray:6 3
336
  ```
337
 
338
  **Legend.** Blue solid = forward flow (input / call down the pipeline). Orange dashed = return flow (result / reply back up).
@@ -346,8 +335,8 @@ flowchart TB
346
  - **3a. HTTP + orchestration.** `route_request` maps the URL to a handler; `orchestrate_turn` is the per-turn supervisor — it owns the request lifecycle and ties brain + scoring + voice + persistence together.
347
  - **3b. LLM Brain.** One `handle_turn` per turn calls Gemini, which chooses which of `fact_find` / `retrieve` / `lookup_facts` / `recommend` to run as tools. The brain may only state what its tools returned.
348
  - **3c. Scoring + Pricing.** `grade_per_profile` and `estimate_premium` read curated facts **and** the live profile, compute on every request (never stored), and hand back to `orchestrate_turn`.
349
- - **3d. Profile & Persistence.** `update_session_profile` reflects each `fact_find` write into the live session; `persist_to_disk` runs end-of-turn so the next visit can call `recall_by_name`.
350
- - **4. Data layer.** Three reads — `vector_search` for free-form Q&A, `fact_lookup` for decision-critical numbers with verbatim quotes, and `read_user_profile` for returning-user recall. The data layer does no writes during a request; those happen offline (vectors) or end-of-turn (profile).
351
 
352
  ### 2.4 LLM brain + fail-loud fallback chain
353
 
@@ -462,67 +451,60 @@ over the bot (**barge-in**) pauses that audio **and** aborts the in-flight
462
  push-to-talk; the live interim transcript accumulates the full utterance
463
  while you speak.
464
 
465
- ### 2.6 Profile, personalisation & returning-user recall
466
 
467
  ```mermaid
468
  flowchart TB
469
- A["user answers (chat or profile builder)"] --> SPF["save_profile_field → session_state.profile"]
470
- SPF --> ENDT["end of handle_turn"]
471
- ENDT --> AP["auto_persist_session()<br/>(no-op if no name; errors swallowed)"]
472
- AP --> DISK["profile_store: 40-data/profiles/&lt;name&gt;.json"]
473
- AP --> EMB["profile_rag: session-scoped profile chunk"]
474
- RV["return visit — user states a name"] --> EX["extract name"]
475
- EX --> TR["try_recall_by_name (first-name slug fallback)"]
476
- TR --> DISK
477
- TR --> STAGE["session.pending_profile_recall (STAGED, never auto-merged)"]
478
- STAGE --> ASK["bot: 'Welcome back — have we spoken before? Please share your age'<br/>(stored attrs NEVER disclosed to an unconfirmed identity)"]
479
- ASK -->|"yes + matching age"| MERGE["apply_pending_recall (match-before-merge) → merge stored slots"]
480
- ASK -->|"yes + mismatching age"| DROP["same-turn age contradiction → discard staged · fresh fact-find"]
481
- ASK -->|"no"| FRESH["discard · continue as new user"]
482
- SPF --> FIT["scorecard fit + grade"]
483
- SPF --> PREM["illustrative premium"]
484
- note1["evicted/blank session + carried chat_history →<br/>STATE-RECOVERY: rebuild profile from history,<br/>never re-ask the name"] -.-> ENDT
485
  ```
486
 
487
- **Summary.** How the system captures a profile during a
488
- conversation, persists it for the user's next visit, recalls it *safely* on
489
- return, and recovers when the server forgot it.
 
 
 
 
490
 
491
  **How it flows:**
492
 
493
- - **Capture.** Every answer (chat or profile-builder) is persisted via
494
- `save_profile_field` into the live `session_state.profile`.
495
- - **End-of-turn persist.** `auto_persist_session` writes a
496
- `<name>.json` to disk **and** embeds the profile as a session-scoped
497
- chunk — so a returning user can be recognised, and "given my situation"
498
- references can be grounded.
499
- - **Privacy-safe recall (hardened 2026-05-27).** On a return visit the
500
- captured name is matched against the stored profile; a match is
501
- **staged** on `pending_profile_recall` and the bot asks *"Welcome
502
- back — have we spoken before? Please share your age."* The prompt
503
- **never discloses** the staged attributes (age / dependents /
504
- location / goal) — a stranger sharing the name slug can't learn the
505
- prior visitor's profile from the prompt alone. Only an explicit *yes*
506
- with a **matching age** merges stored slots (`apply_pending_recall`
507
- runs a *match-before-merge* contradiction guard against both prior
508
- live-session captures *and* the just-said `user_text`); a *no* or a
509
- mismatching age discards the stage and the user continues fresh.
510
- - **State recovery.** If the server's in-memory session was evicted /
511
- restarted but the browser still carries `chat_history`, the brain
512
- enters **STATE-RECOVERY MODE** — silently re-captures the profile from
513
- the conversation history instead of asking the user's name again.
514
  - **Drives scoring + pricing.** The same profile feeds the scorecard
515
- fit-and-grade and the live premium estimate.
516
-
517
- **More on profile & personalisation.** Your answers build a session
518
- profile (`backend/session_state.py`, `profile_store.py`,
519
- `profile_persistence.py`). The profile is also embedded as a
520
- *session-scoped* chunk (`backend/profile_rag.py`) so the brain can ground
521
- "given my situation" references, with strict per-session isolation. The
522
- *same* profile drives both recommendation fit (scorecard) and the
523
- illustrative premium estimate (`backend/premium_calculator.py`,
524
- `sum_insured.py`) see §3.2 and §3.3 for what each function actually
525
- computes.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
 
527
  ### 2.7 Data architecture — where the JSON lives, where the vectors live
528
 
@@ -544,9 +526,9 @@ computes.
544
  - **`40-data/reviews/`** — sourced insurer reviews (claims stories, regulator notes).
545
  - **`40-data/premiums/`** — illustrative public rate-card combinations consumed by the multivariate premium estimator (§3.3).
546
  - **`40-data/insurer_network.json`** — hospital-network counts per insurer.
547
- - **`40-data/profiles/<name>.json`** saved user profiles for returning-user recall. Per-named-user, server-side, never indexed into the shared corpus.
548
 
549
- All four sit in the **code repo** (under `40-data/`) because they're small, human-reviewed, and decision-critical — safe to version alongside the code.
550
 
551
  #### 2.7.3 Where each piece physically lives
552
 
@@ -824,39 +806,38 @@ sequenceDiagram
824
  - **No LLM in the path.** A plain file read of `40-data/policy_facts/<id>.json`. The brain quotes the value (and the `source_quote`) verbatim.
825
  - **Used by more than the brain.** `scorecard.py` and `premium_calculator.py` also read these JSON files directly — see §2.3 (the brain's edge is not the only edge into the JSON).
826
 
827
- ### 3.6 Returning-user recall
828
 
829
  ```mermaid
830
  sequenceDiagram
831
  autonumber
832
  participant U as User
 
833
  participant B as 3b. LLM Brain
834
- participant T as save_profile_field
835
- participant S as 3d. session_state
836
- participant D as 4. saved profile JSON file
837
- U->>B: "Hi, I'm Neha"
838
- B->>T: save_profile_field("name", "Neha")
839
- T->>S: write name
840
- S->>D: try_recall_by_name("Neha")
841
- D-->>S: stored profile (staged on pending_profile_recall)
842
- S-->>B: "Welcome back — are you the same Neha?"
843
- U->>B: "yes" / "no"
844
- alt yes
845
- B->>S: apply_pending_recall → merge stored slots
846
- else no
847
- B->>S: discard stage · continue as fresh user
848
- end
849
  ```
850
 
851
- **Summary.** When a returning user gives a name that matches a stored profile, the match is **staged**, never auto-merged — the bot asks "are you the same Neha?" and only an explicit *yes* restores the saved slots. This prevents a name collision from leaking a stranger's profile.
 
 
 
 
852
 
853
  **How it flows:**
854
 
855
- - **Capture name.** Same path as any other fact (§3.1).
856
- - **Lookup.** `try_recall_by_name` checks `40-data/profiles/<slug>.json` with a first-name slug fallback.
857
- - **Stage, don't merge.** A match goes on `session.pending_profile_recall`*not* into `session.profile`.
858
- - **Confirm gate.** The bot asks the user to confirm. *yes* → merge stored slots into the live profile. *no* → discard the stage and continue as a fresh user.
859
- - **Bonus: state recovery.** If the server's in-memory session was evicted but the browser still carries `chat_history`, the brain enters **STATE-RECOVERY MODE** and silently rebuilds the profile from history rather than asking for the name again.
 
860
 
861
  ### 3.7 What is stored vs what is live-only
862
 
@@ -864,7 +845,7 @@ sequenceDiagram
864
  |---|---|---|
865
  | Policy PDFs + vector chunks | `rag/corpus/` + Chroma store (HF dataset → pulled at build) | Built once, offline; read every request |
866
  | Curated policy facts (per policy) | `40-data/policy_facts/*.json` (code repo) | Small, human-reviewed, versioned with code |
867
- | Saved user profile (by name) | `40-data/profiles/<name>.json` (server-side, per-named-user) | So a returning user can be recognised |
868
  | Per-policy **grade / scorecard** | **Not stored — live per request** | Two users get two grades for the same policy (profile-aware) |
869
  | **Premium range** for a policy | **Not stored — live per request** | Same reason as the grade |
870
  | Uploaded PDFs | Per-session Chroma quarantine, **24 h TTL** | Isolated to the uploader, never the shared corpus |
@@ -1005,8 +986,7 @@ these:
1005
  │ │ retrieval_filters.py
1006
  │ ├── premium_calculator.py profile → illustrative premium
1007
  │ │ sum_insured.py
1008
- │ ├── session_state.py / per-session profile + persistence
1009
- │ │ profile_store.py / profile_persistence.py / profile_rag.py
1010
  │ ├── voice_format.py TTS pre-processing (money/Indic normalisation)
1011
  │ ├── admin.py /api/admin/* (health, telemetry)
1012
  │ └── providers/ thin clients: google_gemini, nvidia_nim, sarvam_*,
 
125
 
126
  ```mermaid
127
  flowchart TD
128
+ S["🌐 You open the app — web or mobile, nothing to install"] --> TELL["🗣️ Tell it about you — a short chat, typed OR spoken, English / Hindi-Hinglish<br/>age · family · budget · health · what you care about"]
129
+ TELL --> ASK[" It asks just 2–3 clarifying questions<br/>(a real conversation, never a long form)"]
 
 
 
 
 
130
  ASK --> REC["🎯 A personalised shortlist — plans ranked for YOUR fit, each with the reason it fits"]
131
  REC --> WHY["🔍 Open any plan: every fact is backed by the exact clause in the real policy PDF<br/>an honest &quot;not stated in the document&quot; instead of a guess"]
132
  WHY --> EXPLORE{"Want to dig deeper?"}
 
143
  VOICE -.-> QA
144
  ```
145
 
146
+ **Summary.** A user opens the app and ends the session having decided on
147
+ a plan with confidence — and how the system loops through compare /
148
+ browse / Q&A / upload along the way. No backend in this view; just the
149
+ human path. Every session starts fresh — there is no cross-session
150
+ memory; closing the tab forgets you (privacy-by-design, see ADR-043).
151
 
152
  **How it flows:**
153
 
 
 
 
154
  - **Conversational fact-find.** A short typed-or-spoken back-and-forth
155
  (English or Hindi-Hinglish) captures age, family, budget, health and
156
  what you care about — instead of a long form.
 
197
  API["HTTP endpoints + orchestration<br/>backend/main.py"]
198
  BRAIN["🧠 LLM Brain<br/>Google Gemini + function-calling tools<br/>(NIM fallback chain on failure)"]
199
  SCORE["🎯 Scoring + Pricing<br/>scorecard.py · premium_calculator.py"]
200
+ PROF["👤 Profile (in-memory only)<br/>session_state.SessionState · 1h idle TTL"]
201
  end
202
  subgraph DATA["📚 Data layer"]
203
  VEC["Vector DB (Chroma) — policy chunks<br/>+ per-session quarantine (uploads)"]
 
226
 
227
  - **1. Frontend (browser · Next.js).** Renders chat, marketplace, compare, and the profile builder. Sends typed text and audio over HTTP, plays the synthesised reply.
228
  - **2. Voice.** `Sarvam STT (in)` turns spoken audio into a text turn; `Sarvam TTS (out)` turns the reply text back into spoken audio.
229
+ - **3. Backend (FastAPI).** Four sub-blocks — **3a** HTTP endpoints + orchestration (`backend/main.py`); **3b** LLM Brain (Gemini + function-calling tools; NIM fallback on failure); **3c** Scoring + Pricing (`scorecard.py` + `premium_calculator.py`); **3d** Profile (in-memory only `session_state.SessionState`, no disk).
230
  - **4. Data layer.** Two stores — the Chroma **vector DB** (shared policy chunks + per-session quarantine for uploads) and curated **JSON facts** at `40-data/policy_facts/*.json`. The brain, scoring, and pricing all read from these.
231
 
232
  **Diagram legend (used throughout §2):**
 
276
  SC1["grade_per_profile<br/>scorecard.py"]
277
  SC2["estimate_premium<br/>premium_calculator.py"]
278
  end
279
+ subgraph BE_PROF["3d. Profile (in-memory)"]
280
+ P1["update_session_profile<br/>session_state.SessionState"]
281
+ P2["evict_on_idle<br/>1h TTL · no disk"]
 
282
  end
283
  end
284
 
 
286
  direction TB
287
  D1["vector_search<br/>Chroma · BGE-small"]
288
  D2["fact_lookup<br/>40-data/policy_facts/*.json"]
 
289
  end
290
 
291
  %% forward edges (input / down the pipeline)
 
301
  B2 --> P1
302
  B3 --> D1
303
  B4 --> D2
 
304
  A2 --> SC1
305
  A2 --> SC2
306
  SC1 -->|"reads"| D2
307
  SC2 -->|"reads"| D2
308
  SC1 -->|"reads"| P1
309
  SC2 -->|"reads"| P1
310
+ P1 -.->|"idle 1h"| P2
311
 
312
  %% return edges (output / back to caller)
313
  D1 -.->|"top-k chunks"| B3
314
  D2 -.->|"per-policy facts"| B4
 
315
  SC1 -.->|"grade"| A2
316
  SC2 -.->|"premium range"| A2
317
  B1 -.->|"reply + citations"| A2
 
320
  V2 -.->|"audio"| F2
321
 
322
  %% blue solid = forward · orange dashed = return
323
+ linkStyle 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17 stroke:#1565c0,stroke-width:2px
324
+ linkStyle 18,19,20,21,22,23,24,25 stroke:#e65100,stroke-width:2px,stroke-dasharray:6 3
325
  ```
326
 
327
  **Legend.** Blue solid = forward flow (input / call down the pipeline). Orange dashed = return flow (result / reply back up).
 
335
  - **3a. HTTP + orchestration.** `route_request` maps the URL to a handler; `orchestrate_turn` is the per-turn supervisor — it owns the request lifecycle and ties brain + scoring + voice + persistence together.
336
  - **3b. LLM Brain.** One `handle_turn` per turn calls Gemini, which chooses which of `fact_find` / `retrieve` / `lookup_facts` / `recommend` to run as tools. The brain may only state what its tools returned.
337
  - **3c. Scoring + Pricing.** `grade_per_profile` and `estimate_premium` read curated facts **and** the live profile, compute on every request (never stored), and hand back to `orchestrate_turn`.
338
+ - **3d. Profile (in-memory).** `update_session_profile` reflects each `fact_find` write into the live `SessionState.profile`. State lives in process memory only; an idle session is evicted after 1 h. There is no disk persistence and no cross-session recall (see ADR-043, 2026-05-27).
339
+ - **4. Data layer.** Two reads — `vector_search` for free-form Q&A, and `fact_lookup` for decision-critical numbers with verbatim quotes. The data layer does no writes during a request those happen offline only (vector ingest, curated-facts edits).
340
 
341
  ### 2.4 LLM brain + fail-loud fallback chain
342
 
 
451
  push-to-talk; the live interim transcript accumulates the full utterance
452
  while you speak.
453
 
454
+ ### 2.6 Profile & personalisation (in-memory only)
455
 
456
  ```mermaid
457
  flowchart TB
458
+ A["user answers (chat or profile builder)"] --> SPF["save_profile_field → SessionState.profile<br/>(in-memory dict only)"]
459
+ SPF --> FIT["scorecard fit + grade<br/>(reads live profile)"]
460
+ SPF --> PREM["illustrative premium<br/>(reads live profile)"]
461
+ SPF --> TURN["next chat turn<br/>(brain sees full live profile)"]
462
+ SPF -.->|"1h idle"| EVICT["session evicted from memory<br/>profile gone forever"]
463
+ SPF -.->|"close tab"| EVICT
464
+ RECOV["server restart / 1h idle WHILE tab still open<br/>+ chat_history carried by browser"] -.-> SR["STATE-RECOVERY MODE<br/>brain rebuilds profile from chat_history<br/>(in-session only · never reads disk)"]
465
+ SR --> SPF
 
 
 
 
 
 
 
 
466
  ```
467
 
468
+ **Summary.** The profile is captured into a per-session in-memory dict
469
+ (`SessionState.profile`), feeds scoring + pricing + the next turn's
470
+ brain prompt, and is discarded the moment the session evicts (1 h idle
471
+ or "Clear chat"). There is no on-disk persistence and no cross-session
472
+ recall. The in-session **STATE-RECOVERY** path covers container
473
+ restarts by rebuilding the profile from the chat history the browser
474
+ still carries — it never touches disk.
475
 
476
  **How it flows:**
477
 
478
+ - **Capture.** Every answer (chat or profile-builder form) is written via
479
+ `save_profile_field` (or the `POST /api/profile` endpoint) into the
480
+ live `SessionState.profile`. This is a regular Python dataclass field
481
+ on the in-memory session object.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  - **Drives scoring + pricing.** The same profile feeds the scorecard
483
+ fit-and-grade (§3.2) and the live premium estimate (§3.3) on every
484
+ request — both reads, never persisted.
485
+ - **Evicted on idle / close.** A session is evicted from the
486
+ `_sessions` dict after 1 h of inactivity (`_TTL_SECONDS`). Hitting
487
+ *Clear chat* (`POST /api/session/clear`) evicts immediately. Closing
488
+ the tab disconnects the browser — the server-side session ages out on
489
+ the same TTL.
490
+ - **State recovery (in-session only).** If the server restarted or the
491
+ session evicted *while the browser still has the chat open*, the
492
+ client re-sends its `chat_history` with the next turn. The brain
493
+ enters **STATE-RECOVERY MODE** and silently re-captures the facts
494
+ already stated in history — without ever asking the user's name
495
+ again. This is **not** cross-session; it only resolves the case
496
+ where the user is still in the same conversation.
497
+
498
+ **Why no cross-session recall (ADR-043, 2026-05-27).** An earlier
499
+ design persisted profiles to `40-data/profiles/<name>.json` and offered
500
+ a "Welcome back, <name>?" prompt on return. The name-only slug key
501
+ collided across distinct users (every "Rohit" wrote to the same file),
502
+ which required four sequential hardening passes — prompt redaction,
503
+ match-before-merge guards, same-turn fact extractors, a two-fact gate —
504
+ to keep contained. The cost/benefit for an insurance-shopping app
505
+ (rare-purchase, return sessions uncommon) didn't justify the surface.
506
+ The simpler "session is in-memory only" model matches the privacy story
507
+ the product wants to tell.
508
 
509
  ### 2.7 Data architecture — where the JSON lives, where the vectors live
510
 
 
526
  - **`40-data/reviews/`** — sourced insurer reviews (claims stories, regulator notes).
527
  - **`40-data/premiums/`** — illustrative public rate-card combinations consumed by the multivariate premium estimator (§3.3).
528
  - **`40-data/insurer_network.json`** — hospital-network counts per insurer.
529
+ _Pre-ADR-043 there was also a `40-data/profiles/<name>.json` directory of saved user profiles for cross-session recall. That mechanism was removed (see §2.6 sessions are now in-memory only)._
530
 
531
+ All three remaining stores sit in the **code repo** (under `40-data/`) because they're small, human-reviewed, and decision-critical — safe to version alongside the code.
532
 
533
  #### 2.7.3 Where each piece physically lives
534
 
 
806
  - **No LLM in the path.** A plain file read of `40-data/policy_facts/<id>.json`. The brain quotes the value (and the `source_quote`) verbatim.
807
  - **Used by more than the brain.** `scorecard.py` and `premium_calculator.py` also read these JSON files directly — see §2.3 (the brain's edge is not the only edge into the JSON).
808
 
809
+ ### 3.6 In-session state recovery (server restart resilience)
810
 
811
  ```mermaid
812
  sequenceDiagram
813
  autonumber
814
  participant U as User
815
+ participant Br as Browser (carries chat_history)
816
  participant B as 3b. LLM Brain
817
+ participant S as 3d. SessionState (in-memory)
818
+ U->>Br: "what about premium?"
819
+ Br->>B: chat turn + chat_history[N msgs]
820
+ B->>S: get_session(session_id)
821
+ Note over S: session was evicted<br/>(1h idle / restart)<br/>profile = BLANK
822
+ B->>B: STATE-RECOVERY MODE<br/>chat_history has prior facts
823
+ B->>S: save_profile_field for each fact in history
824
+ B-->>Br: reply that picks up where it left off<br/>(never re-asks the name)
 
 
 
 
 
 
 
825
  ```
826
 
827
+ **Summary.** Sessions are in-memory only (`_TTL_SECONDS = 1h`), so a
828
+ container restart or long idle wipes the server-side profile. When the
829
+ browser still carries the conversation, the brain silently rebuilds the
830
+ profile from the chat history instead of starting over. No disk read,
831
+ no cross-session memory — purely a same-conversation resilience path.
832
 
833
  **How it flows:**
834
 
835
+ - **Detect.** `get_session()` returns a blank `SessionState`, but `chat_history` arrives with ≥2 messages including a prior user turn → state was lost, not "fresh user".
836
+ - **Re-capture from history.** A high-priority **STATE-RECOVERY MODE** prompt block tells the LLM: do not say you lost anything, do not re-ask the name, instead call `save_profile_field` for every fact present in the conversation so far, then continue.
837
+ - **Resume.** From the LLM's point of view the next reply is just the next turn in an ongoing chat the user never perceives the eviction.
838
+
839
+ (There is no cross-session recall see §2.6 and ADR-043 for why that
840
+ was removed.)
841
 
842
  ### 3.7 What is stored vs what is live-only
843
 
 
845
  |---|---|---|
846
  | Policy PDFs + vector chunks | `rag/corpus/` + Chroma store (HF dataset → pulled at build) | Built once, offline; read every request |
847
  | Curated policy facts (per policy) | `40-data/policy_facts/*.json` (code repo) | Small, human-reviewed, versioned with code |
848
+ | User profile (current session) | **In-memory only — `SessionState.profile` (1 h idle TTL, no disk)** | Closing the tab / clearing chat forgets the profile by design (ADR-043) |
849
  | Per-policy **grade / scorecard** | **Not stored — live per request** | Two users get two grades for the same policy (profile-aware) |
850
  | **Premium range** for a policy | **Not stored — live per request** | Same reason as the grade |
851
  | Uploaded PDFs | Per-session Chroma quarantine, **24 h TTL** | Isolated to the uploader, never the shared corpus |
 
986
  │ │ retrieval_filters.py
987
  │ ├── premium_calculator.py profile → illustrative premium
988
  │ │ sum_insured.py
989
+ │ ├── session_state.py per-session profile (in-memory only, ADR-043)
 
990
  │ ├── voice_format.py TTS pre-processing (money/Indic normalisation)
991
  │ ├── admin.py /api/admin/* (health, telemetry)
992
  │ └── providers/ thin clients: google_gemini, nvidia_nim, sarvam_*,
backend/admin.py CHANGED
@@ -280,7 +280,7 @@ async def admin_usage(
280
 
281
 
282
  # ---------------------------------------------------------------------------
283
- # /api/admin/profiles — list every named profile + summary
284
  # ---------------------------------------------------------------------------
285
 
286
  @router.get("/api/admin/profiles")
@@ -288,37 +288,49 @@ async def admin_profiles(
288
  request: Request,
289
  x_admin_password: Optional[str] = Header(default=None, alias="X-Admin-Password"),
290
  ):
291
- """List every named profile in the JSON store + lightweight summary.
292
-
293
- Returned shape:
294
- {
295
- "profiles": [ {name_display, name_slug, first_seen, last_seen,
296
- session_count, profile_complete_fields}, ... ],
297
- "total": N,
298
- "snapshot_ts": "2026-05-14T..."
299
- }
300
  """
301
  _check_admin(request, x_admin_password)
302
 
303
- from backend import profile_store
304
- profiles = profile_store.list_profiles()
305
- return {
306
- "profiles": profiles,
307
- "total": len(profiles),
308
- "snapshot_ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
309
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
 
311
 
312
  # ---------------------------------------------------------------------------
313
- # KI-063 (2026-05-15)user-facing profile-event endpoints.
314
- #
315
- # These are NOT admin-gated — they're invoked by the frontend when a logged-
316
- # in user (one with a stored profile.name) clicks the select/reject buttons
317
- # on a policy card. Both look up the session, validate that the session has
318
- # a named profile, then append the event through `profile_store.record_policy_event`.
319
  #
320
- # Anonymous sessions (no profile.name) get 400 there's no key to persist
321
- # against. The frontend should hide the buttons in that case.
 
322
  # ---------------------------------------------------------------------------
323
 
324
 
@@ -329,46 +341,51 @@ class _PolicyEventBody(BaseModel):
329
  reason: Optional[str] = None
330
 
331
 
 
 
 
 
 
 
332
  def _do_record_policy_event(body: _PolicyEventBody, event_type: str) -> dict:
333
- """Shared handler for /api/profile/select + /api/profile/reject."""
334
  if not body.session_id or not body.policy_slug or not body.insurer:
335
  raise HTTPException(
336
  status_code=400,
337
  detail="session_id, policy_slug, and insurer are required",
338
  )
 
 
339
  from backend.session_state import get_session
340
- from backend.profile_store import record_policy_event
341
 
342
  session = get_session(body.session_id)
343
- if not session.profile.name:
344
- raise HTTPException(
345
- status_code=400,
346
- detail="No named profile on this session cannot persist event.",
347
- )
348
- ok = record_policy_event(
349
- persona_id_or_name=session.profile.name,
350
- profile=session.profile,
351
- event_type=event_type, # type: ignore[arg-type]
352
- policy_slug=body.policy_slug,
353
- insurer=body.insurer,
354
- session_id=body.session_id,
355
- reason=body.reason,
356
  )
357
- if not ok:
358
- raise HTTPException(status_code=500, detail="profile save failed")
359
- # Also persist via the session flush so an in-memory consumer (e.g. the
360
- # welcome-back greeter) reads the same state without a full disk reload.
361
- session._flush()
362
- field_name = {
363
- "shown": "shown_policies",
364
- "selected": "selected_policies",
365
- "rejected": "rejected_policies",
366
- }[event_type]
 
 
 
367
  return {
368
  "ok": True,
369
  "event_type": event_type,
370
  "policy_slug": body.policy_slug,
371
- "count": len(getattr(session.profile, field_name, []) or []),
372
  }
373
 
374
 
@@ -1011,37 +1028,32 @@ async def admin_persona_drift(
1011
  request: Request,
1012
  x_admin_password: Optional[str] = Header(default=None, alias="X-Admin-Password"),
1013
  ):
1014
- """Return slot-capture completeness for the last 20 personas, newest first.
1015
 
1016
- Each row: { persona_id, name_display, last_seen, captured_slots,
1017
- completeness_pct, missing_slots }
1018
- The frontend highlights any row with completeness_pct < 50.
1019
  """
1020
  _check_admin(request, x_admin_password)
1021
- if not _PROFILES_DIR_FOR_DRIFT.exists():
1022
- return {"personas": [], "total": 0,
1023
- "snapshot_ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")}
1024
 
1025
  rows: list[dict] = []
1026
- for p in _PROFILES_DIR_FOR_DRIFT.glob("*.json"):
1027
- try:
1028
- raw = json.loads(p.read_text())
1029
- except Exception:
1030
- continue
1031
- profile = raw.get("profile") or {}
1032
- asked = profile.get("asked") or []
1033
- captured = [s for s in _PERSONA_DRIFT_SLOTS if _slot_captured(profile, s, asked)]
1034
- missing = [s for s in _PERSONA_DRIFT_SLOTS if s not in captured]
1035
- rows.append({
1036
- "persona_id": raw.get("persona_id") or raw.get("name_slug") or p.stem,
1037
- "name_display": raw.get("name_display") or "—",
1038
- "last_seen": raw.get("last_seen"),
1039
- "captured_slots": captured,
1040
- "missing_slots": missing,
1041
- "completeness_pct": round(100.0 * len(captured) / len(_PERSONA_DRIFT_SLOTS), 1),
1042
- "session_count": len(raw.get("sessions") or []),
1043
- })
1044
- # Newest first by last_seen (None sorts last)
1045
  rows.sort(key=lambda r: (r["last_seen"] or ""), reverse=True)
1046
  rows = rows[:20]
1047
  return {
@@ -1052,36 +1064,6 @@ async def admin_persona_drift(
1052
  }
1053
 
1054
 
1055
- # Cached path resolver — re-uses profile_store's _PROFILES_DIR but we import
1056
- # lazily to avoid a circular import at module top.
1057
- def _resolve_profiles_dir() -> Path:
1058
- from backend import profile_store
1059
- return profile_store._PROFILES_DIR
1060
-
1061
-
1062
- # Lazy-evaluated singleton — instantiate on first call. We can't reference
1063
- # profile_store at module top because admin.py imports llm_health which
1064
- # may not yet have its config wired during tests.
1065
- class _LazyProfilesDir:
1066
- def __init__(self) -> None:
1067
- self._p: Optional[Path] = None
1068
- def __getattr__(self, name: str):
1069
- if self._p is None:
1070
- self._p = _resolve_profiles_dir()
1071
- return getattr(self._p, name)
1072
- def exists(self) -> bool:
1073
- if self._p is None:
1074
- self._p = _resolve_profiles_dir()
1075
- return self._p.exists()
1076
- def glob(self, pat: str):
1077
- if self._p is None:
1078
- self._p = _resolve_profiles_dir()
1079
- return self._p.glob(pat)
1080
-
1081
-
1082
- _PROFILES_DIR_FOR_DRIFT = _LazyProfilesDir()
1083
-
1084
-
1085
  # ---------------------------------------------------------------------------
1086
  # A5 — Audit fix #5: /api/admin/recommendation-history — last 10 policy
1087
  # recommendation events across all profiles, newest first.
@@ -1092,46 +1074,35 @@ async def admin_recommendation_history(
1092
  request: Request,
1093
  x_admin_password: Optional[str] = Header(default=None, alias="X-Admin-Password"),
1094
  ):
1095
- """Return the last 10 policy-event entries across every profile,
1096
- newest first.
1097
-
1098
- Each row: { persona_id, name_display, event_type, policy_slug, insurer,
1099
- event_at, session_id, outcome }
1100
- outcome: 'selected' / 'rejected' / 'shown' (passthrough from event_type;
1101
- callers may map 'shown' → 'abandoned' if no follow-up exists,
1102
- but we leave the raw label so the operator can decide).
1103
  """
1104
  _check_admin(request, x_admin_password)
 
1105
  events: list[dict] = []
1106
- if not _PROFILES_DIR_FOR_DRIFT.exists():
1107
- return {"events": [], "total": 0,
1108
- "snapshot_ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")}
1109
-
1110
- for p in _PROFILES_DIR_FOR_DRIFT.glob("*.json"):
1111
- try:
1112
- raw = json.loads(p.read_text())
1113
- except Exception:
1114
- continue
1115
- profile = raw.get("profile") or {}
1116
- persona_id = raw.get("persona_id") or raw.get("name_slug") or p.stem
1117
- name_display = raw.get("name_display") or "—"
1118
- for evt_type, field_name in (("shown", "shown_policies"),
1119
- ("selected", "selected_policies"),
1120
- ("rejected", "rejected_policies")):
1121
- for entry in (profile.get(field_name) or []):
1122
- events.append({
1123
- "persona_id": persona_id,
1124
- "name_display": name_display,
1125
- "event_type": evt_type,
1126
- "policy_slug": entry.get("policy_slug"),
1127
- "insurer": entry.get("insurer"),
1128
- "event_at": entry.get("event_at"),
1129
- "session_id": entry.get("session_id"),
1130
- "reason": entry.get("reason"),
1131
- # Outcome label is the raw event_type — operator decides
1132
- # what 'shown without follow-up' means in their context.
1133
- "outcome": evt_type,
1134
- })
1135
  events.sort(key=lambda e: (e["event_at"] or ""), reverse=True)
1136
  events = events[:10]
1137
  return {
 
280
 
281
 
282
  # ---------------------------------------------------------------------------
283
+ # /api/admin/profiles — live-session snapshot (ADR-043, 2026-05-27)
284
  # ---------------------------------------------------------------------------
285
 
286
  @router.get("/api/admin/profiles")
 
288
  request: Request,
289
  x_admin_password: Optional[str] = Header(default=None, alias="X-Admin-Password"),
290
  ):
291
+ """List every CURRENTLY LIVE in-memory session + lightweight summary.
292
+
293
+ Pre-ADR-043 this read from `40-data/profiles/<name>.json` and exposed a
294
+ cross-session audit of every named visitor. The on-disk store is gone;
295
+ operators get the live picture instead — every session that is in
296
+ `session_state._sessions` right now (i.e. hasn't been idle-evicted).
297
+ Returned shape stays close to the prior schema so the existing admin
298
+ UI keeps rendering without changes.
 
299
  """
300
  _check_admin(request, x_admin_password)
301
 
302
+ from backend import session_state as _ss
303
+ now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
304
+ rows = []
305
+ with _ss._lock:
306
+ for sid, s in list(_ss._sessions.items()):
307
+ p = s.profile
308
+ name = (getattr(p, "name", None) or "").strip()
309
+ filled = sum(
310
+ 1 for fld in (
311
+ "name", "age", "dependents", "income_band",
312
+ "location_tier", "primary_goal", "health_conditions",
313
+ )
314
+ if getattr(p, fld, None) not in (None, "", [])
315
+ )
316
+ rows.append({
317
+ "name_display": name or "(anonymous)",
318
+ "name_slug": name.lower().replace(" ", "-") if name else "",
319
+ "session_id": sid,
320
+ "last_seen": datetime.fromtimestamp(s.last_touched, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
321
+ "session_count": 1,
322
+ "profile_complete_fields": filled,
323
+ })
324
+ return {"profiles": rows, "total": len(rows), "snapshot_ts": now_iso}
325
 
326
 
327
  # ---------------------------------------------------------------------------
328
+ # /api/profile/select + /api/profile/reject IN-MEMORY shortlist tracker
329
+ # (ADR-043, 2026-05-27)
 
 
 
 
330
  #
331
+ # Pre-ADR-043 these appended events to the named-profile JSON file. They
332
+ # now mutate only the live SessionState. Closes the tab shortlist gone.
333
+ # The frontend's shortlist UI continues to work within one session.
334
  # ---------------------------------------------------------------------------
335
 
336
 
 
341
  reason: Optional[str] = None
342
 
343
 
344
+ _EVENT_TYPE_TO_FIELD = {
345
+ "selected": "selected_policies",
346
+ "rejected": "rejected_policies",
347
+ }
348
+
349
+
350
  def _do_record_policy_event(body: _PolicyEventBody, event_type: str) -> dict:
351
+ """Append a shortlist event to the LIVE in-memory profile."""
352
  if not body.session_id or not body.policy_slug or not body.insurer:
353
  raise HTTPException(
354
  status_code=400,
355
  detail="session_id, policy_slug, and insurer are required",
356
  )
357
+ if event_type not in _EVENT_TYPE_TO_FIELD:
358
+ raise HTTPException(status_code=400, detail="invalid event_type")
359
  from backend.session_state import get_session
 
360
 
361
  session = get_session(body.session_id)
362
+ p = session.profile
363
+ field_name = _EVENT_TYPE_TO_FIELD[event_type]
364
+ entries: list[dict] = list(getattr(p, field_name, None) or [])
365
+ # Dedup on policy_slugbump timestamp + reason on repeat clicks.
366
+ dedup_idx = next(
367
+ (i for i, e in enumerate(entries)
368
+ if (e or {}).get("policy_slug") == body.policy_slug),
369
+ None,
 
 
 
 
 
370
  )
371
+ now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
372
+ payload = {
373
+ "policy_slug": body.policy_slug,
374
+ "insurer": body.insurer,
375
+ "event_at": now_iso,
376
+ "session_id": body.session_id,
377
+ "reason": body.reason or ("user_clicked_select" if event_type == "selected" else "user_clicked_reject"),
378
+ }
379
+ if dedup_idx is not None:
380
+ entries[dedup_idx] = {**entries[dedup_idx], **payload}
381
+ else:
382
+ entries.append(payload)
383
+ setattr(p, field_name, entries)
384
  return {
385
  "ok": True,
386
  "event_type": event_type,
387
  "policy_slug": body.policy_slug,
388
+ "count": len(entries),
389
  }
390
 
391
 
 
1028
  request: Request,
1029
  x_admin_password: Optional[str] = Header(default=None, alias="X-Admin-Password"),
1030
  ):
1031
+ """Slot-capture completeness for LIVE in-memory sessions (newest first).
1032
 
1033
+ Pre-ADR-043 this walked `40-data/profiles/*.json` and showed every
1034
+ named visitor's slot-capture progress. The on-disk store is gone;
1035
+ operators now see the live picture only.
1036
  """
1037
  _check_admin(request, x_admin_password)
1038
+ from backend import session_state as _ss
 
 
1039
 
1040
  rows: list[dict] = []
1041
+ with _ss._lock:
1042
+ for sid, s in list(_ss._sessions.items()):
1043
+ p = s.profile
1044
+ profile_dict = {fld: getattr(p, fld, None) for fld in _PERSONA_DRIFT_SLOTS}
1045
+ asked = list(getattr(p, "asked", None) or [])
1046
+ captured = [s_ for s_ in _PERSONA_DRIFT_SLOTS if _slot_captured(profile_dict, s_, asked)]
1047
+ missing = [s_ for s_ in _PERSONA_DRIFT_SLOTS if s_ not in captured]
1048
+ rows.append({
1049
+ "persona_id": sid,
1050
+ "name_display": (getattr(p, "name", None) or "—"),
1051
+ "last_seen": datetime.fromtimestamp(s.last_touched, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
1052
+ "captured_slots": captured,
1053
+ "missing_slots": missing,
1054
+ "completeness_pct": round(100.0 * len(captured) / len(_PERSONA_DRIFT_SLOTS), 1),
1055
+ "session_count": 1,
1056
+ })
 
 
 
1057
  rows.sort(key=lambda r: (r["last_seen"] or ""), reverse=True)
1058
  rows = rows[:20]
1059
  return {
 
1064
  }
1065
 
1066
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1067
  # ---------------------------------------------------------------------------
1068
  # A5 — Audit fix #5: /api/admin/recommendation-history — last 10 policy
1069
  # recommendation events across all profiles, newest first.
 
1074
  request: Request,
1075
  x_admin_password: Optional[str] = Header(default=None, alias="X-Admin-Password"),
1076
  ):
1077
+ """Last 10 policy-event entries across LIVE in-memory sessions.
1078
+
1079
+ Pre-ADR-043 this walked `40-data/profiles/*.json` and surfaced every
1080
+ historical shown/selected/rejected event the bot had ever logged. The
1081
+ on-disk store is gone; this now reflects the current container's live
1082
+ sessions only historical events evict with the session.
 
 
1083
  """
1084
  _check_admin(request, x_admin_password)
1085
+ from backend import session_state as _ss
1086
  events: list[dict] = []
1087
+ with _ss._lock:
1088
+ for sid, s in list(_ss._sessions.items()):
1089
+ p = s.profile
1090
+ name_display = (getattr(p, "name", None) or "—")
1091
+ for evt_type, field_name in (("shown", "shown_policies"),
1092
+ ("selected", "selected_policies"),
1093
+ ("rejected", "rejected_policies")):
1094
+ for entry in (getattr(p, field_name, None) or []):
1095
+ events.append({
1096
+ "persona_id": sid,
1097
+ "name_display": name_display,
1098
+ "event_type": evt_type,
1099
+ "policy_slug": (entry or {}).get("policy_slug"),
1100
+ "insurer": (entry or {}).get("insurer"),
1101
+ "event_at": (entry or {}).get("event_at"),
1102
+ "session_id": (entry or {}).get("session_id"),
1103
+ "reason": (entry or {}).get("reason"),
1104
+ "outcome": evt_type,
1105
+ })
 
 
 
 
 
 
 
 
 
 
1106
  events.sort(key=lambda e: (e["event_at"] or ""), reverse=True)
1107
  events = events[:10]
1108
  return {
backend/brain_tools.py CHANGED
@@ -62,6 +62,7 @@ from __future__ import annotations
62
 
63
  import json as _json
64
  import logging
 
65
  from pathlib import Path
66
  from typing import Any, Optional
67
 
@@ -1619,45 +1620,41 @@ def mark_recommendation(
1619
  # No profile name → no JSON file to write to (anonymous session). No
1620
  # insurer resolution for a slug → skip that slug. All errors swallowed
1621
  # so a logging failure never breaks the tool reply back to Gemini.
 
 
 
 
 
 
1622
  try:
1623
  profile = getattr(session, "profile", None)
1624
- profile_name = getattr(profile, "name", None) if profile else None
1625
- if profile_name and cleaned:
1626
- from backend.profile_store import record_policy_event
1627
-
1628
  slug_to_insurer = dict(getattr(session, "slug_to_insurer", {}) or {})
1629
- seen_slugs: set[str] = set()
1630
  turn_idx = int(getattr(session, "turn_idx", 0) or 0)
1631
  session_id = getattr(session, "session_id", None)
 
1632
  for slug in cleaned:
1633
- if not slug or slug in seen_slugs:
1634
  continue
1635
  insurer = slug_to_insurer.get(slug)
1636
  if not insurer:
1637
- # Couldn't resolve insurer this turn — skip rather than
1638
- # write a malformed event.
1639
  continue
1640
- seen_slugs.add(slug)
1641
- try:
1642
- record_policy_event(
1643
- persona_id_or_name=profile_name,
1644
- profile=profile,
1645
- event_type="shown",
1646
- policy_slug=slug,
1647
- insurer=insurer,
1648
- session_id=session_id,
1649
- reason="shown_in_recommendation",
1650
- turn_idx=turn_idx,
1651
- )
1652
- except Exception as inner: # noqa: BLE001
1653
- _log.warning(
1654
- "X7 mark_recommendation record_policy_event "
1655
- "failed (slug=%s): %s: %s",
1656
- slug, type(inner).__name__, str(inner)[:200],
1657
- )
1658
  except Exception as e: # noqa: BLE001 — never break the tool reply
1659
  _log.warning(
1660
- "X7 mark_recommendation shown-event logging failed: %s: %s",
 
1661
  type(e).__name__, str(e)[:200],
1662
  )
1663
 
 
62
 
63
  import json as _json
64
  import logging
65
+ import time
66
  from pathlib import Path
67
  from typing import Any, Optional
68
 
 
1620
  # No profile name → no JSON file to write to (anonymous session). No
1621
  # insurer resolution for a slug → skip that slug. All errors swallowed
1622
  # so a logging failure never breaks the tool reply back to Gemini.
1623
+ # ADR-043 (2026-05-27) — record_policy_event used to write the shown
1624
+ # policy onto the named-profile JSON for cross-session "have I shown
1625
+ # this before" tracking. Cross-session persistence is gone; the
1626
+ # in-memory equivalent (avoid re-pitching within the same session)
1627
+ # is handled by session.last_recommendation_ids / shown_policies on
1628
+ # the live Profile dataclass.
1629
  try:
1630
  profile = getattr(session, "profile", None)
1631
+ if profile is not None and cleaned:
1632
+ shown = list(getattr(profile, "shown_policies", None) or [])
1633
+ existing_slugs = {(e or {}).get("policy_slug") for e in shown}
 
1634
  slug_to_insurer = dict(getattr(session, "slug_to_insurer", {}) or {})
 
1635
  turn_idx = int(getattr(session, "turn_idx", 0) or 0)
1636
  session_id = getattr(session, "session_id", None)
1637
+ now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
1638
  for slug in cleaned:
1639
+ if not slug or slug in existing_slugs:
1640
  continue
1641
  insurer = slug_to_insurer.get(slug)
1642
  if not insurer:
 
 
1643
  continue
1644
+ shown.append({
1645
+ "policy_slug": slug,
1646
+ "insurer": insurer,
1647
+ "event_at": now_iso,
1648
+ "session_id": session_id,
1649
+ "reason": "shown_in_recommendation",
1650
+ "turn_idx": turn_idx,
1651
+ })
1652
+ existing_slugs.add(slug)
1653
+ profile.shown_policies = shown
 
 
 
 
 
 
 
 
1654
  except Exception as e: # noqa: BLE001 — never break the tool reply
1655
  _log.warning(
1656
+ "mark_recommendation shown-event logging (in-memory) failed: "
1657
+ "%s: %s",
1658
  type(e).__name__, str(e)[:200],
1659
  )
1660
 
backend/main.py CHANGED
@@ -422,40 +422,11 @@ async def _quarantine_purge_loop() -> None:
422
 
423
  # Single source of truth for "is this profile ready to recommend against".
424
  # brain_tools._profile_complete uses the same _REQUIRED_FOR_READY tuple; we
425
- # mirror the call here instead of duplicating the slot list so a future
426
- # addition (e.g. risk_appetite) only needs to be applied in brain_tools.
427
- # Distinguishes "first-time capture on turn 1" from "stored profile
428
- # recalled on turn 1". Both end with a
429
- # non-empty Profile on the session; only the latter should flip
430
- # returning_user_recalled. Heuristic: if EVERY currently-filled slot was
431
- # written THIS turn (i.e. lives in `profile_updates`), the user just typed
432
- # their facts — NOT a recall. If at least one filled slot is NOT in
433
- # profile_updates, those slots came from the recall hydration.
434
- _FEATURE_B_SLOT_LIST: tuple[str, ...] = (
435
- "name", "age", "dependents", "location_tier",
436
- "income_band", "primary_goal", "health_conditions",
437
- )
438
-
439
-
440
- def _every_filled_slot_was_set_this_turn(profile, profile_updates: dict) -> bool:
441
- """Return True iff every populated slot on `profile` was also set in
442
- `profile_updates` this turn. Used by the recall detector to suppress
443
- the banner when the user just freshly introduced themselves.
444
- """
445
- if not isinstance(profile_updates, dict):
446
- profile_updates = {}
447
- pu = set(profile_updates.keys())
448
- any_filled = False
449
- for fld in _FEATURE_B_SLOT_LIST:
450
- v = getattr(profile, fld, None)
451
- if v in (None, "", []):
452
- continue
453
- any_filled = True
454
- if fld not in pu:
455
- return False
456
- # If nothing was filled at all, "every filled" is vacuously True →
457
- # detector should NOT flip returning_user_recalled.
458
- return True if any_filled else True
459
 
460
 
461
  def _compute_profile_complete(session_id: str) -> bool:
@@ -915,10 +886,6 @@ async def chat(req: ChatRequest, request: Request):
915
  if preferred_codec not in _allowed_codecs:
916
  preferred_codec = "audio/wav"
917
  t_chat0 = time.time()
918
- # Pre-turn snapshot for the returning-user-recall detector below.
919
- # Defaults match the "no session yet" case.
920
- _pre_turn_name: str = ""
921
- _pre_turn_idx: int = 0
922
  # Never let an inner TimeoutError / unhandled exception bubble out of
923
  # handle_turn as a 500. The whole call is wrapped in an outer 45s
924
  # budget so even a pathological hang inside handle_turn surfaces as a
@@ -934,13 +901,6 @@ async def chat(req: ChatRequest, request: Request):
934
  from backend.session_state import get_session
935
 
936
  _sb_session = get_session(session_id)
937
- # KI-Z7 — snapshot the pre-turn (name, turn_idx) so we can tell
938
- # AFTER handle_turn whether the turn-1 name heuristic actually
939
- # recalled a stored profile (and stamp ChatResponse accordingly).
940
- _pre_turn_name = (
941
- getattr(_sb_session.profile, "name", None) or ""
942
- ).strip()
943
- _pre_turn_idx = int(getattr(_sb_session, "turn_idx", 0) or 0)
944
  # Once a session has had ANY successful single_brain turn, it
945
  # must stay on single_brain for the rest of its lifetime.
946
  # Switching brains mid-stream would discard everything
@@ -1363,75 +1323,9 @@ async def chat(req: ChatRequest, request: Request):
1363
  except Exception: # noqa: BLE001 — log IO must never block a reply
1364
  pass
1365
 
1366
- # KI-Z7 (2026-05-15) Feature A. Auto-persist the live profile to disk
1367
- # + Chroma AFTER handle_turn returns. brain_tools.save_profile_field
1368
- # only mutates the in-memory Profile; this flushes the union to the
1369
- # canonical name-keyed JSON and refreshes the user's profile chunk so a
1370
- # NEW session can recover the full profile via the turn-1 name recall.
1371
- # Wrapped in try/except — persistence failure NEVER breaks the reply.
1372
  _returning_user_recalled = False
1373
- try:
1374
- from backend.session_state import get_session as _get_session_p
1375
- from backend.profile_persistence import auto_persist_session
1376
-
1377
- _persist_session = _get_session_p(session_id)
1378
- await auto_persist_session(_persist_session)
1379
-
1380
- # KI-Z7 — Feature B detection. The pre-turn snapshot (captured before
1381
- # single_brain.handle_turn ran) lets us tell whether the turn-1 name
1382
- # heuristic actually hydrated a stored profile. Conditions:
1383
- # (a) this WAS the first turn (pre_turn_idx == 0),
1384
- # (b) no name on the profile BEFORE the turn ("" → recalled),
1385
- # (c) a name AND at least one other slot are present AFTER the turn.
1386
- # Frontend uses this flag to render the "Welcome back" banner.
1387
- if USE_SINGLE_BRAIN:
1388
- try:
1389
- # KI-RECALL-FIX (2026-05-16) — PREFER the deterministic signal
1390
- # single_brain sets on the turn where the user explicitly
1391
- # affirmed a staged cross-session recall (the confirmation
1392
- # gate resolves on a LATER turn, so the old turn-1-only
1393
- # heuristic below could NEVER fire for the real flow — the
1394
- # banner was unreachable even once recall worked).
1395
- if getattr(turn, "returning_user_recalled", False):
1396
- _returning_user_recalled = True
1397
- else:
1398
- # Legacy heuristic — kept as a fallback for any
1399
- # non-confirmation recall path (e.g. a direct hydrate via
1400
- # the /api/profile/recall-by-name escape hatch) that
1401
- # populates the profile on the first turn without a
1402
- # confirm round-trip.
1403
- _post_name = (
1404
- getattr(_persist_session.profile, "name", None) or ""
1405
- ).strip()
1406
- _post_has_other_slots = any(
1407
- getattr(_persist_session.profile, fld, None) not in (None, "", [])
1408
- for fld in (
1409
- "age", "dependents", "location_tier",
1410
- "income_band", "primary_goal", "health_conditions",
1411
- )
1412
- )
1413
- if (
1414
- _pre_turn_idx == 0
1415
- and not _pre_turn_name
1416
- and _post_name
1417
- and _post_has_other_slots
1418
- # save_profile_field captures a NEW name on turn 1 too
1419
- # — only flag as returning when prior slots came from
1420
- # the recall path, NOT from in-this-turn extraction.
1421
- # If EVERY filled slot is in profile_updates, it's a
1422
- # first-time capture, NOT a recall.
1423
- and not _every_filled_slot_was_set_this_turn(
1424
- _persist_session.profile, turn.profile_updates,
1425
- )
1426
- ):
1427
- _returning_user_recalled = True
1428
- except Exception: # noqa: BLE001
1429
- pass
1430
- except Exception as _persist_err: # noqa: BLE001
1431
- logging.warning(
1432
- "auto_persist_session failed (session=%s): %s: %s",
1433
- session_id, type(_persist_err).__name__, _persist_err,
1434
- )
1435
 
1436
  # Bug B defense — CitationOut requires page_start/page_end as ints, but
1437
  # single_brain.TurnResult.citations dicts don't carry those fields (its
@@ -2178,15 +2072,13 @@ class SessionClearResponse(BaseModel):
2178
 
2179
  @app.post("/api/session/clear", response_model=SessionClearResponse)
2180
  async def session_clear(req: SessionClearRequest):
2181
- """KI-196 (ADR-041) — Clean Clear-chat semantic. Wipes the in-memory
2182
- session state for the supplied session_id and ALWAYS returns a freshly
2183
- minted UUID the frontend must adopt as its new session_id going forward.
2184
 
2185
- The on-disk profile JSON under `40-data/profiles/` is intentionally NOT
2186
- touched it remains durable user data keyed by persona_id / name slug.
2187
- The next time the user volunteers their name in conversation, the
2188
- confirmation-gated recall flow (see session_state's
2189
- `pending_profile_recall`) will ask before merging the prior captures.
2190
 
2191
  Body : {session_id: str}
2192
  Reply: {cleared: bool, new_session_id: str}
@@ -2237,7 +2129,6 @@ async def profile_update(req: ProfileUpdateRequest):
2237
  """
2238
  from backend.scorecard import profile_completeness as _completeness
2239
  from backend.session_state import get_session
2240
- from backend.profile_rag import upsert_profile_chunk
2241
 
2242
  sess = get_session(req.session_id)
2243
  # Update only fields the client explicitly sent (non-None) — keeps partial
@@ -2261,14 +2152,9 @@ async def profile_update(req: ProfileUpdateRequest):
2261
  if field_name not in sess.profile.asked:
2262
  sess.profile.asked.append(field_name)
2263
 
2264
- # KI-077if name is set, also persist to the named-profile store so a
2265
- # returning visitor's profile is recoverable across sessions.
2266
- if req.name:
2267
- try:
2268
- from backend.profile_store import save_profile
2269
- save_profile(req.name, sess.profile, session_id=req.session_id)
2270
- except Exception as e:
2271
- print(f"[profile_store] save failed for {req.name}: {type(e).__name__}: {e}")
2272
 
2273
  p = sess.profile
2274
  # KI-271 — SLOT_UNION-driven profile_dict (15 fields) so copay_pct +
@@ -2286,20 +2172,8 @@ async def profile_update(req: ProfileUpdateRequest):
2286
  collected = [k for k, v in profile_dict.items() if k in answered and v not in (None, "", [], False)]
2287
  missing = [k for k, v in profile_dict.items() if k not in answered or v in (None, "", [])]
2288
 
2289
- # Ingest the profile into the RAG store so the brain sees user context
2290
- # at retrieval time alongside policy + regulatory chunks. Fire-and-forget
2291
- # — a profile upsert failure shouldn't block the API response.
2292
- # KI-118 (2026-05-15) — gated on a known name; anonymous saves don't
2293
- # write to Chroma. The chunk is keyed by canonical name slug, not the
2294
- # session_id which is now opaque/in-memory.
2295
- try:
2296
- if p.name:
2297
- from backend.profile_store import _normalise_name
2298
- name_slug = _normalise_name(p.name)
2299
- if name_slug:
2300
- await upsert_profile_chunk(name_slug, profile_dict)
2301
- except Exception as e:
2302
- print(f"[profile_rag] upsert failed for {req.session_id}: {type(e).__name__}: {e}")
2303
 
2304
  return ProfileCompletenessResponse(
2305
  completeness=c,
@@ -4783,56 +4657,11 @@ async def predicted_premium_band(session_id: Optional[str] = None):
4783
  return PredictedPremiumBandResponse(**band)
4784
 
4785
 
4786
- # ---------------------------------------------------------------------------
4787
- # KI-Z7 (2026-05-15) Feature B. POST /api/profile/recall-by-name.
4788
- #
4789
- # The chat hot path runs the same recall heuristic server-side inside
4790
- # single_brain.handle_turn (turn-1 name sniff + try_recall_by_name) and
4791
- # stamps `returning_user_recalled` on the ChatResponse, so the frontend
4792
- # usually doesn't need this endpoint. It exists as an explicit, idempotent
4793
- # escape hatch — e.g. the user types their name into the profile builder
4794
- # AFTER turn 1 and we want to load their stored facts without sending a
4795
- # chat turn. The hydration is server-side: the in-memory session_state for
4796
- # `session_id` is mutated in place, so the next /api/chat turn already sees
4797
- # the recalled slots.
4798
- # ---------------------------------------------------------------------------
4799
- class RecallByNameRequest(BaseModel):
4800
- name: str = Field(..., description="User-provided name (display form OK).")
4801
- session_id: str = Field(..., description="Session id to hydrate on a hit.")
4802
-
4803
-
4804
- class RecallByNameResponse(BaseModel):
4805
- found: bool
4806
- # The endpoint does not hydrate the session off a bare name (weak/shared
4807
- # key → stranger-PII leak). When a stored profile matches, the response
4808
- # asks the UI to confirm identity FIRST. `profile`/`predicted_band` are
4809
- # never returned pre-confirmation.
4810
- requires_confirmation: bool = False
4811
- name: Optional[str] = None # stored display name, for the prompt
4812
- summary: Optional[dict] = None # non-PII identity hints, for the prompt
4813
- profile: Optional[dict] = None # always None until user confirms
4814
- predicted_band: Optional[dict] = None # always None until user confirms
4815
- session_id: str
4816
-
4817
-
4818
- @app.post("/api/profile/recall-by-name", response_model=RecallByNameResponse)
4819
- async def recall_profile_by_name(req: RecallByNameRequest):
4820
- """Stage a stored named-profile match for `session_id` (if any).
4821
-
4822
- A bare name is a weak, shared, guessable key, so this endpoint does not
4823
- auto-hydrate the session — a second user on a shared browser/IP, or
4824
- anyone stating a common first name, must not be silently served a
4825
- stranger's stored profile. When a match exists the response carries
4826
- `found=True, requires_confirmation=True, name, summary` so the UI
4827
- renders an explicit "are you <name>?" prompt. The stored fields are
4828
- applied to the session only after the user confirms (handled on the
4829
- chat affirmation path via session_state.apply_pending_recall). Returns
4830
- `found=False` when the slug doesn't resolve to a stored file.
4831
- """
4832
- from backend.profile_persistence import recall_by_name_payload
4833
-
4834
- payload = await recall_by_name_payload(req.name, req.session_id)
4835
- return RecallByNameResponse(**payload)
4836
 
4837
 
4838
  # ---- Static frontend (served alongside /api on the same port for HF Spaces) ----
 
422
 
423
  # Single source of truth for "is this profile ready to recommend against".
424
  # brain_tools._profile_complete uses the same _REQUIRED_FOR_READY tuple; we
425
+ # _FEATURE_B_SLOT_LIST + _every_filled_slot_was_set_this_turn were the
426
+ # heuristic that distinguished "first-time capture on turn 1" from
427
+ # "stored profile recalled on turn 1" for the returning-user banner.
428
+ # Removed in ADR-043 (2026-05-27) no cross-session recall, so there is
429
+ # no banner to flip.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
 
431
 
432
  def _compute_profile_complete(session_id: str) -> bool:
 
886
  if preferred_codec not in _allowed_codecs:
887
  preferred_codec = "audio/wav"
888
  t_chat0 = time.time()
 
 
 
 
889
  # Never let an inner TimeoutError / unhandled exception bubble out of
890
  # handle_turn as a 500. The whole call is wrapped in an outer 45s
891
  # budget so even a pathological hang inside handle_turn surfaces as a
 
901
  from backend.session_state import get_session
902
 
903
  _sb_session = get_session(session_id)
 
 
 
 
 
 
 
904
  # Once a session has had ANY successful single_brain turn, it
905
  # must stay on single_brain for the rest of its lifetime.
906
  # Switching brains mid-stream would discard everything
 
1323
  except Exception: # noqa: BLE001 — log IO must never block a reply
1324
  pass
1325
 
1326
+ # Cross-session profile persistence + returning-user detection removed
1327
+ # in ADR-043 (2026-05-27). Sessions are in-memory only.
 
 
 
 
1328
  _returning_user_recalled = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1329
 
1330
  # Bug B defense — CitationOut requires page_start/page_end as ints, but
1331
  # single_brain.TurnResult.citations dicts don't carry those fields (its
 
2072
 
2073
  @app.post("/api/session/clear", response_model=SessionClearResponse)
2074
  async def session_clear(req: SessionClearRequest):
2075
+ """Clean Clear-chat semantic. Wipes the in-memory session state for the
2076
+ supplied session_id and ALWAYS returns a freshly minted UUID the
2077
+ frontend must adopt as its new session_id going forward.
2078
 
2079
+ Post-ADR-043 (2026-05-27) there is nothing to preserve across sessions
2080
+ there is no on-disk profile to "leave intact". A clear is a complete
2081
+ forget.
 
 
2082
 
2083
  Body : {session_id: str}
2084
  Reply: {cleared: bool, new_session_id: str}
 
2129
  """
2130
  from backend.scorecard import profile_completeness as _completeness
2131
  from backend.session_state import get_session
 
2132
 
2133
  sess = get_session(req.session_id)
2134
  # Update only fields the client explicitly sent (non-None) — keeps partial
 
2152
  if field_name not in sess.profile.asked:
2153
  sess.profile.asked.append(field_name)
2154
 
2155
+ # ADR-043 (2026-05-27) cross-session persistence + profile_rag
2156
+ # upsert removed. The captured fields live only in the in-memory
2157
+ # SessionState for this session's lifetime (1 h idle TTL).
 
 
 
 
 
2158
 
2159
  p = sess.profile
2160
  # KI-271 — SLOT_UNION-driven profile_dict (15 fields) so copay_pct +
 
2172
  collected = [k for k, v in profile_dict.items() if k in answered and v not in (None, "", [], False)]
2173
  missing = [k for k, v in profile_dict.items() if k not in answered or v in (None, "", [])]
2174
 
2175
+ # profile_rag upsert removed in ADR-043 (2026-05-27). Captured fields
2176
+ # remain in the in-memory SessionState only.
 
 
 
 
 
 
 
 
 
 
 
 
2177
 
2178
  return ProfileCompletenessResponse(
2179
  completeness=c,
 
4657
  return PredictedPremiumBandResponse(**band)
4658
 
4659
 
4660
+ # /api/profile/recall-by-name was REMOVED in ADR-043 (2026-05-27).
4661
+ # Cross-session profile recall is gone sessions are in-memory only, so
4662
+ # there is nothing to "recall" off a bare name. The frontend api.ts caller
4663
+ # that wrapped this endpoint has also been removed. Old clients still
4664
+ # pinging the path get a 404, which is the correct degraded behaviour.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4665
 
4666
 
4667
  # ---- Static frontend (served alongside /api on the same port for HF Spaces) ----
backend/profile_persistence.py DELETED
@@ -1,339 +0,0 @@
1
- """Post-turn auto-persistence + returning-user recall helpers.
2
-
3
- Coupled features (KI-Z7, 2026-05-15):
4
-
5
- Feature A — auto_persist_session(session)
6
- Called from /api/chat AFTER single_brain.handle_turn returns and BEFORE
7
- the ChatResponse is built. Reads the union of all 13 slot_union fields
8
- from session.profile and pushes them to:
9
- - profile_store.save_profile(...) — canonical JSON on disk
10
- - profile_rag.upsert_profile_chunk(...) — Chroma vector chunk
11
- Gated on session.profile.name being non-empty (anonymous turns never
12
- persist — KI-118).
13
-
14
- Wrapped in try/except internally; persistence failure NEVER raises and
15
- NEVER affects the reply path.
16
-
17
- Feature B — extract_potential_name(text)
18
- Cheap regex heuristic that recovers a first name from a turn-1 user
19
- utterance ("Hi I'm Priya", "My name is Rajesh", "Anjali here"). Returns
20
- None when no clear name is present (e.g. "I'm 34 years old"). Used by
21
- single_brain.handle_turn so a returning user is recognised BEFORE
22
- Gemini's first tool-call iteration.
23
-
24
- Feature B — try_recall_by_name(session, name)
25
- PRIVACY FIX (2026-05-16, audit). Previously this hydrated every empty
26
- slot on session.profile and returned True so single_brain stamped
27
- `is_returning_user=True` / a "Welcome back" greeting — on a FRESH,
28
- no-cookie session, keyed only on the user-stated name. A second user
29
- on a shared browser/IP, or anyone stating a common first name, was
30
- served a stranger's captured profile. Now it delegates to
31
- session_state.rehydrate_by_name which STAGES the match on
32
- `session.pending_profile_recall` (no merge, no greeting) and ALWAYS
33
- returns False. The stored profile is applied ONLY after an explicit
34
- user confirmation via session_state.apply_pending_recall(session,
35
- confirmed=True). Same-session continuity is unaffected — slots captured
36
- within the live conversation never travel this path.
37
-
38
- Feature B — recall_by_name_payload(name, session_id)
39
- Builds the response payload for the POST /api/profile/recall-by-name
40
- endpoint. PRIVACY FIX (2026-05-16): this NO LONGER auto-hydrates the
41
- live session. A name match is STAGED; the payload reports
42
- {found, requires_confirmation, name, summary, session_id} so the UI
43
- renders an explicit "are you <name>?" confirm rather than silently
44
- adopting a stranger's stored profile. The stored fields are applied to
45
- the session only after the user confirms (the chat affirmation path
46
- calls session_state.apply_pending_recall).
47
-
48
- Design notes:
49
- - This module sits between profile_store + profile_rag and is the SINGLE
50
- write path the /api/chat hot loop reaches; brain_tools.save_profile_field
51
- still only mutates the in-memory Profile (B6 owns that file).
52
- - No async required for the JSON write; profile_rag.upsert_profile_chunk
53
- is async (embedder), so auto_persist_session is async too.
54
- - All callers MUST await it and swallow exceptions defensively. We do the
55
- defensive swallow internally as belt-and-suspenders so callers can
56
- simply `await auto_persist_session(session)` without try/except.
57
- """
58
-
59
- from __future__ import annotations
60
-
61
- import logging
62
- import re
63
- from typing import Any, Optional
64
-
65
- _log = logging.getLogger(__name__)
66
-
67
-
68
- # ---------------------------------------------------------------------------
69
- # Feature A — post-turn persistence
70
- # ---------------------------------------------------------------------------
71
-
72
-
73
- # The 13 fields that make up the "slot_union" — what we persist on every
74
- # auto_persist_session call. Kept in sync with the schemas in
75
- # needs_finder.Profile and brain_tools.save_profile_field's accepted fields.
76
- _UNION_FIELDS: tuple[str, ...] = (
77
- "name",
78
- "age",
79
- "dependents",
80
- "income_band",
81
- "existing_cover_inr",
82
- "primary_goal",
83
- "location_tier",
84
- "parents_to_insure",
85
- "parents_age_max",
86
- "parents_has_ped",
87
- "health_conditions",
88
- "budget_band",
89
- "desired_sum_insured_inr",
90
- )
91
-
92
-
93
- def _build_union_dict(profile) -> dict[str, Any]:
94
- """Build the persistence payload — dict of all 13 union fields."""
95
- return {f: getattr(profile, f, None) for f in _UNION_FIELDS}
96
-
97
-
98
- async def auto_persist_session(session) -> bool:
99
- """Persist the live session profile to disk + Chroma.
100
-
101
- No-op (returns False) when:
102
- - session is None
103
- - session.profile.name is empty / None
104
- - either underlying write raises (logged, then swallowed)
105
-
106
- Returns True iff BOTH save_profile() AND upsert_profile_chunk() ran
107
- without raising. Persistence failure NEVER bubbles out — the chat
108
- reply must not be blocked by a stuck disk or a Chroma hiccup.
109
- """
110
- if session is None:
111
- return False
112
- profile = getattr(session, "profile", None)
113
- if profile is None:
114
- return False
115
- name = (getattr(profile, "name", None) or "").strip()
116
- if not name:
117
- # Anonymous turn — never write to disk, never embed (KI-118).
118
- return False
119
-
120
- union = _build_union_dict(profile)
121
- saved_json = False
122
- saved_chunk = False
123
-
124
- # 1) Canonical JSON on disk.
125
- try:
126
- from backend.profile_store import save_profile
127
-
128
- saved_json = save_profile(
129
- name,
130
- profile,
131
- session_id=getattr(session, "session_id", None),
132
- )
133
- except Exception as e: # noqa: BLE001
134
- _log.warning(
135
- "auto_persist_session: save_profile failed (name=%r): %s: %s",
136
- name, type(e).__name__, str(e)[:200],
137
- )
138
-
139
- # 2) Vector chunk (gated on a derivable name_slug — same gate as the
140
- # POST /api/profile path).
141
- try:
142
- from backend.profile_store import _normalise_name
143
- from backend.profile_rag import upsert_profile_chunk
144
-
145
- name_slug = _normalise_name(name)
146
- if name_slug:
147
- await upsert_profile_chunk(name_slug, union)
148
- saved_chunk = True
149
- except Exception as e: # noqa: BLE001
150
- _log.warning(
151
- "auto_persist_session: upsert_profile_chunk failed (name=%r): %s: %s",
152
- name, type(e).__name__, str(e)[:200],
153
- )
154
-
155
- return saved_json and saved_chunk
156
-
157
-
158
- # ---------------------------------------------------------------------------
159
- # Feature B — turn-1 name heuristic + returning-user recall
160
- # ---------------------------------------------------------------------------
161
-
162
-
163
- # Match an explicit self-introduction. Three forms cover the common cases:
164
- # 1. "I'm <name>" / "I am <name>"
165
- # 2. "My name is <name>" / "this is <name>"
166
- # 3. "<name> here"
167
- # We deliberately require a verb-ish anchor — bare "Priya." with no
168
- # surrounding context is too noisy on turn 1 (it could be a question subject)
169
- # and the existing brain_tools.save_profile_field path will pick it up
170
- # safely if the user repeats it.
171
- _NAME_RE = re.compile(
172
- r"""
173
- (?:
174
- \b(?:i\s*am|i'?m|my\s+name\s+is|this\s+is|name'?s)\s+
175
- (?P<n1>[A-Z][a-zA-Z]{1,30}(?:\s+[A-Z][a-zA-Z]{1,30})?)
176
- \b
177
- |
178
- ^\s*(?P<n2>[A-Z][a-zA-Z]{1,30}(?:\s+[A-Z][a-zA-Z]{1,30})?)\s+here\b
179
- )
180
- """,
181
- re.VERBOSE | re.IGNORECASE,
182
- )
183
-
184
- # Words that look name-like to the regex (capitalised at sentence start)
185
- # but are NOT names. Filter post-match so "I'm 34" and "I am okay" never
186
- # resolve to "okay" / "34" as a name.
187
- _NAME_STOPWORDS: set[str] = {
188
- "ok", "okay", "fine", "good", "great", "well", "yes", "no", "yeah",
189
- "nope", "sure", "looking", "trying", "thinking", "interested",
190
- "here", "back", "ready", "done", "free", "busy", "tired", "young",
191
- "old", "married", "single", "alone", "happy", "sad",
192
- "from", "in", "at", "on", "with", "for", "to",
193
- "the", "a", "an",
194
- # Time + age
195
- "twenty", "thirty", "forty", "fifty", "sixty",
196
- }
197
-
198
-
199
- def extract_potential_name(text: str) -> Optional[str]:
200
- """Return a probable first-name capture from a turn-1 user utterance.
201
-
202
- Examples:
203
- "Hi I'm Priya" -> "Priya"
204
- "Hello, my name is Rajesh" -> "Rajesh"
205
- "Anjali here" -> "Anjali"
206
- "I'm 34 years old" -> None
207
- "I am okay, thanks" -> None
208
- "" -> None
209
-
210
- Caller should slug + try_recall_by_name; no DB or LLM cost.
211
- """
212
- if not text or not text.strip():
213
- return None
214
- # Normalize whitespace, keep original casing for the regex.
215
- s = text.strip()
216
- m = _NAME_RE.search(s)
217
- if not m:
218
- return None
219
- raw = (m.group("n1") or m.group("n2") or "").strip()
220
- if not raw:
221
- return None
222
- # Reject digit-laden captures ("I'm 34") and stop-words ("I'm okay").
223
- first_token = raw.split()[0]
224
- if first_token.lower() in _NAME_STOPWORDS:
225
- return None
226
- if not first_token.isalpha():
227
- return None
228
- if len(first_token) < 2:
229
- return None
230
- return raw
231
-
232
-
233
- def try_recall_by_name(session, name: str, *, user_text: str = "") -> bool:
234
- """Look up a stored profile by name and STAGE it for confirmation.
235
-
236
- PRIVACY FIX (2026-05-16). Wraps session_state.rehydrate_by_name, which
237
- now STAGES a name match on `session.pending_profile_recall` instead of
238
- auto-merging it into `session.profile`. Nothing is applied to the live
239
- session here.
240
-
241
- PRIVACY HARDENING v4 (2026-05-27, ADR-042 follow-up #1) — `user_text`
242
- is threaded through so the two-fact gate inside rehydrate_by_name can
243
- parse same-turn identity facts (age/dependents/location/income) to
244
- decide whether to stage / defer / fail-closed. Callers that don't have
245
- user_text on hand (rare) still get the prior-turn live-profile path.
246
-
247
- Returns:
248
- Always False — the stored profile is NEVER auto-applied. The
249
- single_brain caller therefore never stamps `is_returning_user` /
250
- a "Welcome back" greeting off a bare name on a fresh session.
251
- Whether a match was *staged* is observable on
252
- `session.pending_profile_recall`; whether the probe was *deferred*
253
- (no facts to disambiguate yet) is on `session.recall_match_deferred`.
254
- The brain surfaces an "are you <name>?" confirm and calls
255
- session_state.apply_pending_recall on the user's explicit answer.
256
- """
257
- if not name or not name.strip():
258
- return False
259
- try:
260
- from backend.session_state import rehydrate_by_name
261
-
262
- return bool(rehydrate_by_name(session, name, user_text=user_text))
263
- except Exception as e: # noqa: BLE001
264
- _log.warning(
265
- "try_recall_by_name failed (name=%r): %s: %s",
266
- name, type(e).__name__, str(e)[:200],
267
- )
268
- return False
269
-
270
-
271
- async def recall_by_name_payload(
272
- name: str,
273
- session_id: str,
274
- ) -> dict[str, Any]:
275
- """Build the response payload for POST /api/profile/recall-by-name.
276
-
277
- PRIVACY FIX (2026-05-16, audit). This NO LONGER hydrates the live
278
- session. A bare name is a weak, shared, guessable key; auto-applying a
279
- stored profile to a fresh, no-cookie session leaked stranger PII. A
280
- match is now STAGED on `session.pending_profile_recall`; the payload
281
- asks the UI to confirm identity before anything is applied.
282
-
283
- Returns:
284
- {
285
- "found": bool, # True iff a stored match exists
286
- "requires_confirmation": bool, # True when found (never auto-apply)
287
- "name": str | None, # stored display name (for the prompt)
288
- "summary": dict | None, # non-PII identity hints for the prompt
289
- "profile": None, # NEVER returned pre-confirmation
290
- "predicted_band": None, # NEVER returned pre-confirmation
291
- "session_id": str,
292
- }
293
-
294
- No side effect on `session.profile`. The staged match is applied only
295
- after the user explicitly confirms, via the chat affirmation path
296
- (session_state.apply_pending_recall).
297
- """
298
- out: dict[str, Any] = {
299
- "found": False,
300
- "requires_confirmation": False,
301
- "name": None,
302
- "summary": None,
303
- "profile": None,
304
- "predicted_band": None,
305
- "session_id": session_id,
306
- }
307
- if not name or not name.strip() or not session_id:
308
- return out
309
-
310
- try:
311
- from backend.session_state import get_session
312
-
313
- sess = get_session(session_id)
314
- except Exception as e: # noqa: BLE001
315
- _log.warning(
316
- "recall_by_name_payload: get_session failed (session_id=%r): %s: %s",
317
- session_id, type(e).__name__, str(e)[:200],
318
- )
319
- return out
320
-
321
- # Stages on sess.pending_profile_recall; ALWAYS returns False (no merge).
322
- try_recall_by_name(sess, name)
323
- pending = getattr(sess, "pending_profile_recall", None)
324
- if not pending:
325
- return out
326
-
327
- out["found"] = True
328
- out["requires_confirmation"] = True
329
- out["name"] = pending.get("name")
330
- out["summary"] = pending.get("summary")
331
- return out
332
-
333
-
334
- __all__ = [
335
- "auto_persist_session",
336
- "extract_potential_name",
337
- "try_recall_by_name",
338
- "recall_by_name_payload",
339
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/profile_rag.py DELETED
@@ -1,241 +0,0 @@
1
- """Customer-profile-as-RAG layer.
2
-
3
- Profile chunks are keyed by `name_slug` (the canonicalised user name),
4
- NOT by session_id. Only NAMED users ever get embedded; anonymous sessions
5
- never write to Chroma. Keying by name_slug avoids the corruption surface
6
- of session_id-keyed chunks (an anonymous row missing a session_id
7
- metadata field could poison session_id-scoped retrieval queries).
8
-
9
- At retrieval time, `rag/retrieve.py::retrieve(..., profile_name_slug=...)`
10
- boosts the user's profile chunk so the LLM sees the user's context inline
11
- with the retrieved policy/regulatory text — answers become personalised at
12
- the BRAIN level, not just at scorecard re-weighting.
13
-
14
- Public API:
15
- profile_to_chunk_text(profile_dict) -> str
16
- Render the structured profile as a single English paragraph.
17
- upsert_profile_chunk(name_slug, profile_dict, embedder) -> None
18
- Ingest / update the chunk for this named user in Chroma.
19
- remove_profile_chunk(name_slug) -> None
20
- Optional cleanup.
21
-
22
- Storage model — one chunk per name_slug. Replaced on each profile update.
23
- Profile chunks live in the SAME collection as policies so retrieval can
24
- naturally surface them when scoring policies for the user.
25
- """
26
-
27
- from __future__ import annotations
28
-
29
- import asyncio
30
- import logging
31
- from typing import Optional
32
-
33
- from backend.config import settings
34
-
35
- _log = logging.getLogger(__name__)
36
-
37
-
38
- def profile_to_chunk_text(profile: dict) -> str:
39
- """Render the profile dict as a natural-language paragraph for the LLM.
40
-
41
- Rendered as a coherent "USER CONTEXT" block so that when this chunk is
42
- retrieved alongside policy chunks, the LLM sees the user's facts inline.
43
- """
44
- parts: list[str] = ["USER CONTEXT — facts about the person asking this question:"]
45
-
46
- age = profile.get("age")
47
- if isinstance(age, int):
48
- parts.append(f"- Age: {age} years.")
49
-
50
- deps = profile.get("dependents")
51
- if isinstance(deps, str) and deps:
52
- parts.append(f"- Covering: {deps.replace('_', ' ').replace('+', ' + ')}.")
53
-
54
- parents_age = profile.get("parents_age_max")
55
- parents_ped = profile.get("parents_has_ped")
56
- if parents_age:
57
- parents_line = f"- Older parent's age: {parents_age}."
58
- if parents_ped is True:
59
- parents_line += " Parents have pre-existing conditions (diabetes / BP / heart etc.)."
60
- elif parents_ped is False:
61
- parents_line += " Parents are healthy with no flagged conditions."
62
- parts.append(parents_line)
63
-
64
- conditions = profile.get("health_conditions")
65
- if isinstance(conditions, list) and conditions:
66
- cstr = ", ".join(str(c) for c in conditions)
67
- parts.append(f"- User's own pre-existing conditions: {cstr}.")
68
- elif conditions == []:
69
- parts.append("- User has no pre-existing conditions disclosed.")
70
-
71
- existing = profile.get("existing_cover_inr")
72
- if existing == 0:
73
- parts.append("- First-time buyer; no existing health insurance.")
74
- elif isinstance(existing, int) and existing > 0:
75
- if existing >= 100000:
76
- parts.append(f"- Already has ₹{existing // 100000}L of existing health cover.")
77
- else:
78
- parts.append(f"- Already has ₹{existing} of existing health cover.")
79
-
80
- goal = profile.get("primary_goal")
81
- if isinstance(goal, str) and goal:
82
- goal_str = goal.replace("_", " ")
83
- parts.append(f"- Goal today: {goal_str}.")
84
-
85
- loc = profile.get("location_tier")
86
- if isinstance(loc, str) and loc:
87
- parts.append(f"- City tier: {loc}.")
88
-
89
- budget = profile.get("budget_band")
90
- if isinstance(budget, str) and budget:
91
- parts.append(f"- Annual premium budget: {budget.replace('_', '-').replace('-', ' - ')}.")
92
-
93
- income = profile.get("income_band")
94
- if isinstance(income, str) and income:
95
- parts.append(f"- Annual income band: {income}.")
96
-
97
- if len(parts) == 1:
98
- return "USER CONTEXT — no profile info collected yet."
99
- parts.append(
100
- "Use these facts when scoring or recommending. The user has consented to share them; "
101
- "honesty about conditions protects their later claim, so weight disclosed conditions "
102
- "explicitly in the recommendation rationale."
103
- )
104
- return "\n".join(parts)
105
-
106
-
107
- def _get_collection():
108
- """Lazy-import Chroma to keep startup time low when not needed."""
109
- import chromadb
110
- from chromadb.config import Settings as ChromaSettings
111
-
112
- client = chromadb.PersistentClient(
113
- path=str(settings.VECTORS_DIR),
114
- settings=ChromaSettings(anonymized_telemetry=False),
115
- )
116
- return client.get_or_create_collection(
117
- name="policies",
118
- metadata={"hnsw:space": "cosine"},
119
- )
120
-
121
-
122
- async def upsert_profile_chunk(name_slug: str, profile_dict: dict) -> None:
123
- """Embed the profile paragraph and store as a single chunk in Chroma.
124
-
125
- Keyed by `name_slug` (canonical user name), not `session_id`. Only
126
- NAMED users ever get embedded — anonymous chats never write to Chroma.
127
-
128
- Idempotent — calling this on every profile update is safe; existing
129
- chunks for the same name_slug get replaced.
130
- """
131
- from backend.providers.local_embeddings import LocalEmbeddings
132
-
133
- text = profile_to_chunk_text(profile_dict)
134
- if not text or len(text) < 30:
135
- return
136
-
137
- # Input guard 1: name_slug must be a non-empty str. An empty key would
138
- # upsert under id "profile_" and collide across anonymous sessions.
139
- # Callers gate on `session.profile.name` before calling, so anonymous
140
- # users do not reach this function; this guard is belt-and-braces.
141
- if not isinstance(name_slug, str) or not name_slug.strip():
142
- _log.warning(
143
- "profile_rag.upsert_profile_chunk: refusing to write — name_slug "
144
- "must be a non-empty str, got %r. Profile not persisted.",
145
- name_slug,
146
- )
147
- return
148
-
149
- embedder = LocalEmbeddings()
150
- [vec] = await embedder.embed([text], input_type="document")
151
-
152
- # Input guard 2: embedding must be a list of finite floats whose length
153
- # matches the embedder's declared dimension. An empty / None / mis-shaped
154
- # embedding added to Chroma would silently corrupt HNSW (dangling
155
- # pointer or shape mismatch). The corpus uses 384-dim
156
- # BAAI/bge-small-en-v1.5; any other shape is rejected.
157
- expected_dim = getattr(embedder, "dimension", None) or 384
158
- if (
159
- not isinstance(vec, (list, tuple))
160
- or len(vec) != expected_dim
161
- or any((v is None) for v in vec)
162
- ):
163
- _log.warning(
164
- "profile_rag.upsert_profile_chunk: refusing to write — embedding "
165
- "shape invalid for name_slug=%s (expected %d-dim list of floats, "
166
- "got type=%s len=%s). Profile not persisted.",
167
- name_slug, expected_dim, type(vec).__name__,
168
- (len(vec) if hasattr(vec, "__len__") else "?"),
169
- )
170
- return
171
-
172
- coll = _get_collection()
173
- chunk_id = f"profile_{name_slug}"
174
-
175
- # Replace any existing chunk for this name
176
- try:
177
- coll.delete(where={"policy_id": chunk_id})
178
- except Exception as e:
179
- # Non-fatal: chunk may not exist yet. Log so a silent failure of
180
- # the profile store is observable.
181
- _log.debug(
182
- "profile_rag.upsert_profile_chunk: delete(where=policy_id=%s) "
183
- "non-fatal failure: %s: %s",
184
- chunk_id, type(e).__name__, str(e)[:200],
185
- )
186
-
187
- # Wrap coll.add() in try/except so upsert is non-fatal: a transient
188
- # Chroma sqlite lock during HNSW compaction (add() interleaving with
189
- # the retrieve path's get()) must not break the chat reply. The next
190
- # upsert (next profile field change) retries the write.
191
- try:
192
- coll.add(
193
- ids=[chunk_id],
194
- documents=[text],
195
- embeddings=[vec],
196
- metadatas=[{
197
- "policy_id": chunk_id,
198
- "insurer_slug": "profile",
199
- "policy_name": f"User profile ({name_slug[:16]})",
200
- "doc_type": "profile",
201
- # Stamp name_slug; the retrieve path filters profile
202
- # chunks via this field.
203
- "name_slug": name_slug,
204
- "source_url": "",
205
- "page_start": 0,
206
- "page_end": 0,
207
- "chunk_idx": 0,
208
- "local_path": "in-memory named-profile chunk",
209
- }],
210
- )
211
- except Exception as e:
212
- _log.warning(
213
- "profile_rag.upsert_profile_chunk: add(id=%s) failed: %s: %s — "
214
- "user reply will proceed without per-user profile context; "
215
- "next profile change will retry.",
216
- chunk_id, type(e).__name__, str(e)[:200],
217
- )
218
-
219
-
220
- def remove_profile_chunk(name_slug: str) -> None:
221
- """Optional cleanup. Keyed by name_slug."""
222
- if not name_slug:
223
- return
224
- try:
225
- coll = _get_collection()
226
- coll.delete(where={"policy_id": f"profile_{name_slug}"})
227
- except Exception:
228
- pass
229
-
230
-
231
- def upsert_profile_chunk_sync(name_slug: str, profile_dict: dict) -> None:
232
- """Sync wrapper for callers that aren't async — schedules + waits."""
233
- try:
234
- loop = asyncio.get_event_loop()
235
- if loop.is_running():
236
- # Already inside an async context — schedule on the loop
237
- asyncio.ensure_future(upsert_profile_chunk(name_slug, profile_dict))
238
- return
239
- except RuntimeError:
240
- pass
241
- asyncio.run(upsert_profile_chunk(name_slug, profile_dict))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/profile_store.py DELETED
@@ -1,394 +0,0 @@
1
- """Persistent name-keyed profile store.
2
-
3
- KI-040 (2026-05-14). Lets returning visitors say their name and have the
4
- bot recognise them + auto-load their stored profile, so they don't have
5
- to walk the 9-slot fact-find again.
6
-
7
- Architectural answer to the "embed or JSON?" question:
8
-
9
- • JSON (this module) — the canonical store, keyed by normalised name.
10
- O(1) lookup, deterministic, human-readable, manually editable.
11
- • Chroma vector chunk (existing backend/profile_rag.py) — re-embedded
12
- when the profile changes, so retrieval-time the brain sees the
13
- user's profile alongside policy chunks for the "what's best for me?"
14
- style questions. Embedding cost = once per update, not per query.
15
-
16
- Both layers stay in sync: when `save_profile()` is called here, the
17
- save path also fires `profile_rag.upsert_profile_chunk()` so the Chroma
18
- side reflects the new state.
19
-
20
- Files live under `40-data/profiles/<normalised-name>.json`. Names are
21
- normalised to lowercase + alpha-only for the filename so "Rohit" and
22
- "rohit." both resolve to the same profile. The original (capitalised)
23
- display name is preserved inside the JSON.
24
- """
25
-
26
- from __future__ import annotations
27
-
28
- import hashlib
29
- import json
30
- import logging
31
- import re
32
- import time
33
- from dataclasses import asdict
34
- from pathlib import Path
35
- from typing import Literal, Optional
36
-
37
- from backend.config import settings
38
- from backend.needs_finder import Profile
39
-
40
- _PROFILES_DIR = settings.DATA_DIR / "profiles"
41
-
42
-
43
- def _normalise_name(name: str) -> str:
44
- """Lowercase + strip to alphanumerics. 'Rohit Sharma' → 'rohit-sharma'."""
45
- if not name:
46
- return ""
47
- cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", name.strip().lower()).strip("-")
48
- return cleaned[:60] # cap filename length
49
-
50
-
51
- # Identity-defining fields used to disambiguate two users with the same
52
- # display name. Order matters for hash stability.
53
- _PERSONA_ID_FIELDS: tuple[str, ...] = (
54
- "age", "dependents", "income_band", "location_tier", "parents_age_max",
55
- )
56
-
57
-
58
- def compute_persona_id(profile: Profile) -> str:
59
- """Return a 12-char hash blending the user's normalised name with their
60
- identity-defining profile fields. Two users named 'Rohit' but with
61
- different age/dependents/location resolve to different persona IDs.
62
-
63
- Returns '' if there's not enough signal (no name AND no identity
64
- fields). Caller falls back to name-only slug in that case.
65
-
66
- KI-062 (2026-05-15).
67
- """
68
- parts = [_normalise_name(profile.name or "")]
69
- for f in _PERSONA_ID_FIELDS:
70
- v = getattr(profile, f, None)
71
- parts.append("" if v in (None, "", []) else str(v).strip().lower())
72
- if not any(parts):
73
- return ""
74
- blob = "|".join(parts).encode("utf-8")
75
- return hashlib.sha1(blob).hexdigest()[:12]
76
-
77
-
78
- def _path_for(name: str, *, persona_id: Optional[str] = None) -> Optional[Path]:
79
- """Resolve the JSON file path. Prefers persona_id (KI-062) when given,
80
- falling back to the name slug for legacy lookups."""
81
- if persona_id:
82
- return _PROFILES_DIR / f"{persona_id}.json"
83
- slug = _normalise_name(name)
84
- if not slug:
85
- return None
86
- return _PROFILES_DIR / f"{slug}.json"
87
-
88
-
89
- def is_valid_name(text: str) -> bool:
90
- """Heuristic name validation. Rejects empty, too long, mostly-non-alpha."""
91
- if not text:
92
- return False
93
- s = text.strip()
94
- if not (1 <= len(s) <= 50):
95
- return False
96
- # At least 60% alphabetic
97
- alpha = sum(1 for c in s if c.isalpha())
98
- return alpha / max(1, len(s)) >= 0.5
99
-
100
-
101
- def _load_from_path(p: Path) -> Optional[Profile]:
102
- """Read a profile file path → Profile. Drops persisted fields that no
103
- longer exist on the Profile dataclass (schema-drift safety)."""
104
- try:
105
- raw = json.loads(p.read_text())
106
- except Exception as e:
107
- logging.warning("profile_store load failed path=%s: %s", p, e)
108
- return None
109
- prof_dict = raw.get("profile") or {}
110
- valid_fields = set(Profile.__dataclass_fields__.keys())
111
- prof_dict = {k: v for k, v in prof_dict.items() if k in valid_fields}
112
- try:
113
- return Profile(**prof_dict)
114
- except Exception as e:
115
- logging.warning("profile_store reconstruct failed path=%s: %s", p, e)
116
- return None
117
-
118
-
119
- def load_profile(name: str, *, persona_id: Optional[str] = None) -> Optional[Profile]:
120
- """Return the stored Profile for `name` (and optional `persona_id`).
121
-
122
- Lookup order (KI-062, 2026-05-15; PRIVACY-HARDENED 2026-05-16):
123
- 1. If `persona_id` given, try that exact file.
124
- 2. Try the name-slug file (legacy + first-visit path before
125
- identity fields are known).
126
-
127
- PRIVACY FIX (2026-05-16, audit). A former step 3 scanned the WHOLE
128
- profiles directory and returned any persona-id-keyed file whose stored
129
- `name_display` matched the requested name slug. That was a pure
130
- cross-identity leak: a fresh, no-cookie visitor stating a common first
131
- name ("I'm Rahul") pulled a *stranger's* persona-id profile (different
132
- age / city / dependents) — exactly the audit's "Welcome back, Rahul"
133
- finding. The directory scan is removed entirely.
134
-
135
- KI-RECALL-FIX (2026-05-16). The privacy fix above, on its own, made
136
- cross-session recall structurally DEAD: `save_profile` graduates to a
137
- persona-id-keyed file the moment the user gives a name + ANY identity
138
- fact (which the fact-find always asks immediately), so the bare
139
- name-slug file `<slug>.json` never existed and step 2 always missed.
140
- Five real "Rohit" profiles existed on disk, all persona-id-keyed, none
141
- recoverable by the chat-path bare-name lookup → the "Welcome back"
142
- banner was unreachable for every returning user.
143
-
144
- The fix keeps the privacy boundary where it belongs (the explicit
145
- "are you the same <name>?" confirm gate in single_brain /
146
- session_state.apply_pending_recall) and restores recall by making
147
- `save_profile` ALWAYS also write a `<slug>.json` recall pointer that
148
- carries the user's most-recently-seen profile under that name. So:
149
-
150
- • step 2 (slug file) resolves a real returning user's OWN most-recent
151
- profile — enough to STAGE the confirm prompt. Nothing is merged
152
- into the live session until the user explicitly says "yes that's
153
- me" (session_state.apply_pending_recall), so a stranger stating a
154
- common name is asked to confirm and, on anything other than a
155
- clear yes, gets NOTHING (fail-closed).
156
- • the cross-identity DIRECTORY SCAN stays removed — we never pick an
157
- arbitrary persona-id file by matching display names. We only ever
158
- read the deterministic slug file (the user's own pointer) or the
159
- caller's own persona_id.
160
- """
161
- # 1. Direct persona-id hit (the caller's OWN id, never inferred here).
162
- if persona_id:
163
- p = _path_for(name, persona_id=persona_id)
164
- if p and p.exists():
165
- return _load_from_path(p)
166
- # 2. Name-slug recall pointer (this user's own most-recent profile under
167
- # this name — written on EVERY save by save_profile). Safe to return
168
- # for *staging*: the session layer gates the actual merge behind an
169
- # explicit user confirmation, so a same-name stranger leaks nothing.
170
- p = _path_for(name)
171
- if p and p.exists():
172
- return _load_from_path(p)
173
- # No cross-identity display-name directory scan (would be a leak vector).
174
- return None
175
-
176
-
177
- def _atomic_write_json(p: Path, payload: dict) -> None:
178
- """Write `payload` to `p` atomically (tmp + replace)."""
179
- tmp = p.with_suffix(".json.tmp")
180
- tmp.write_text(json.dumps(payload, indent=2, default=str))
181
- tmp.replace(p)
182
-
183
-
184
- def save_profile(name: str, profile: Profile, *, session_id: Optional[str] = None) -> bool:
185
- """Persist `profile`.
186
-
187
- KI-062 (2026-05-15): the CANONICAL, disambiguated copy is keyed by
188
- `compute_persona_id(profile)` when there's enough signal so two users
189
- named 'Rohit' with different age/location don't overwrite each other.
190
-
191
- KI-RECALL-FIX (2026-05-16): we ALSO always write a `<slug>.json` recall
192
- POINTER carrying the most-recently-seen profile under this name. Without
193
- it, cross-session recall was structurally dead — `load_profile(name)` on
194
- the chat path has no persona_id, the persona-id file is unreachable by
195
- bare name, the privacy fix removed the directory scan, and the bare-slug
196
- file was being *deleted* on graduation. The pointer is the deterministic
197
- entry point a real returning user (typing their own name) is resolved by;
198
- the actual merge into a live session stays gated behind the explicit
199
- "are you the same <name>?" confirmation (session_state.apply_pending_recall),
200
- so a same-name stranger still leaks nothing.
201
-
202
- When no persona_id can be derived (name only, no identity facts yet) the
203
- slug file IS the canonical file and we write it once.
204
- """
205
- slug = _normalise_name(name)
206
- if not slug:
207
- return False
208
- persona_id = compute_persona_id(profile)
209
- canonical = _path_for(name, persona_id=persona_id) if persona_id else _path_for(name)
210
- slug_path = _path_for(name)
211
- if not canonical or not slug_path:
212
- return False
213
- try:
214
- _PROFILES_DIR.mkdir(parents=True, exist_ok=True)
215
- existing: dict = {}
216
- if canonical.exists():
217
- try:
218
- existing = json.loads(canonical.read_text())
219
- except Exception:
220
- existing = {}
221
- # KI-062 / KI-RECALL-FIX — when graduating from a slug-keyed file to a
222
- # persona-id-keyed file, carry the slug file's session history forward
223
- # so it isn't orphaned. We DO NOT delete the slug file anymore — it is
224
- # rewritten below as the recall pointer.
225
- if persona_id and slug_path.resolve() != canonical.resolve() and slug_path.exists():
226
- try:
227
- leg_raw = json.loads(slug_path.read_text())
228
- existing.setdefault("sessions", [])
229
- for s in list(leg_raw.get("sessions") or []):
230
- if s not in existing["sessions"]:
231
- existing["sessions"].append(s)
232
- if not existing.get("first_seen"):
233
- existing["first_seen"] = leg_raw.get("first_seen")
234
- except Exception:
235
- pass
236
- now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
237
- sessions = list(existing.get("sessions") or [])
238
- if session_id and session_id not in sessions:
239
- sessions.append(session_id)
240
- sessions = sessions[-20:] # keep last 20 only
241
- payload = {
242
- "name_display": (profile.name or name).strip(),
243
- "name_slug": slug,
244
- "persona_id": persona_id, # KI-062
245
- "profile": asdict(profile),
246
- "first_seen": existing.get("first_seen") or now_iso,
247
- "last_seen": now_iso,
248
- "sessions": sessions,
249
- }
250
- # 1) Canonical (persona-id-keyed when disambiguated, else slug).
251
- _atomic_write_json(canonical, payload)
252
- # 2) Recall pointer — always keep <slug>.json pointing at this user's
253
- # MOST-RECENT profile under this name. When canonical IS the slug
254
- # file (no persona_id) step 1 already wrote it; skip the dup write.
255
- if slug_path.resolve() != canonical.resolve():
256
- pointer = dict(payload)
257
- # Breadcrumb so the admin/audit view can see this is a recall
258
- # pointer that resolves to a persona-id-keyed canonical file.
259
- pointer["recall_pointer"] = True
260
- pointer["points_to_persona_id"] = persona_id
261
- _atomic_write_json(slug_path, pointer)
262
- return True
263
- except Exception as e:
264
- logging.warning("profile_store save failed name=%s: %s", name, e)
265
- return False
266
-
267
-
268
- # ---------------------------------------------------------------------------
269
- # Per-user policy interaction tracking.
270
- #
271
- # Three event types are tracked on the Profile:
272
- # shown — auto-logged by the brain when a policy is cited in a
273
- # recommendation / comparison turn that passed faithfulness.
274
- # selected — user clicked "save / shortlist" on a policy card (frontend
275
- # POSTs to /api/profile/select).
276
- # rejected — user clicked "not for me" (frontend POSTs to /api/profile/reject).
277
- #
278
- # Each entry persists across sessions on the JSON profile, so a returning
279
- # visitor sees their shortlist and the bot can avoid re-pitching rejected
280
- # policies.
281
- # ---------------------------------------------------------------------------
282
-
283
- _EVENT_TYPE_TO_FIELD = {
284
- "shown": "shown_policies",
285
- "selected": "selected_policies",
286
- "rejected": "rejected_policies",
287
- }
288
-
289
-
290
- def record_policy_event(
291
- persona_id_or_name: str,
292
- profile: Profile,
293
- event_type: Literal["shown", "selected", "rejected"],
294
- policy_slug: str,
295
- insurer: str,
296
- session_id: Optional[str] = None,
297
- reason: Optional[str] = None,
298
- turn_idx: Optional[int] = None,
299
- ) -> bool:
300
- """Append a single policy-interaction event to the profile and persist.
301
-
302
- Dedup: if the SAME `policy_slug` already exists in the matching list for
303
- this event_type, the existing entry is updated in place (event_at +
304
- session_id refreshed) rather than appending a duplicate. This keeps the
305
- list bounded and chronologically meaningful — repeated shows of the same
306
- policy collapse to the most recent timestamp.
307
-
308
- Returns True on successful save, False on any failure (missing fields,
309
- invalid event type, save error).
310
- """
311
- if event_type not in _EVENT_TYPE_TO_FIELD:
312
- return False
313
- if not policy_slug or not insurer:
314
- return False
315
- field_name = _EVENT_TYPE_TO_FIELD[event_type]
316
- entries: list[dict] = list(getattr(profile, field_name, None) or [])
317
- now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
318
- default_reason = {
319
- "shown": "shown_in_recommendation",
320
- "selected": "user_clicked_select",
321
- "rejected": "user_clicked_reject",
322
- }[event_type]
323
- payload = {
324
- "policy_slug": policy_slug,
325
- "insurer": insurer,
326
- "event_at": now_iso,
327
- "session_id": session_id,
328
- "reason": reason or default_reason,
329
- }
330
- # X7 — stamp the conversation_turn index when the caller knows it.
331
- # Admin Recommendation History reads `conversation_turn` from the event
332
- # and falls back to "—" when the field is missing/None. Optional so
333
- # legacy callers (frontend /api/profile/select & /reject) stay valid.
334
- if turn_idx is not None:
335
- payload["turn_idx"] = int(turn_idx)
336
- # Dedup on policy_slug within this event-type list. Bump timestamp +
337
- # session_id; preserve original reason unless caller passed a new one.
338
- dedup_idx = next(
339
- (i for i, e in enumerate(entries) if e.get("policy_slug") == policy_slug),
340
- None,
341
- )
342
- if dedup_idx is not None:
343
- existing = dict(entries[dedup_idx])
344
- existing["event_at"] = now_iso
345
- if session_id:
346
- existing["session_id"] = session_id
347
- if reason:
348
- existing["reason"] = reason
349
- if turn_idx is not None:
350
- existing["turn_idx"] = int(turn_idx)
351
- entries[dedup_idx] = existing
352
- else:
353
- entries.append(payload)
354
- setattr(profile, field_name, entries)
355
- # Persist through the existing save path so persona-id resolution + Chroma
356
- # sync (if any) stay consistent.
357
- save_name = profile.name or persona_id_or_name
358
- if not save_name:
359
- return False
360
- return save_profile(save_name, profile, session_id=session_id)
361
-
362
-
363
- def get_shortlist(profile: Profile) -> list[dict]:
364
- """Return the user's selected (shortlisted) policies.
365
-
366
- Thin convenience wrapper used by the admin panel + welcome-back greeting
367
- so callers don't have to remember the field name.
368
- """
369
- return list(getattr(profile, "selected_policies", None) or [])
370
-
371
-
372
- def list_profiles() -> list[dict]:
373
- """Return summary of all stored profiles — used by the admin Profile +
374
- Visitor Log view. One entry per file."""
375
- if not _PROFILES_DIR.exists():
376
- return []
377
- out: list[dict] = []
378
- for p in sorted(_PROFILES_DIR.glob("*.json")):
379
- try:
380
- raw = json.loads(p.read_text())
381
- out.append({
382
- "name_display": raw.get("name_display"),
383
- "name_slug": raw.get("name_slug"),
384
- "first_seen": raw.get("first_seen"),
385
- "last_seen": raw.get("last_seen"),
386
- "session_count": len(raw.get("sessions") or []),
387
- "profile_complete_fields": sum(
388
- 1 for v in (raw.get("profile") or {}).values()
389
- if v not in (None, "", [], 0)
390
- ),
391
- })
392
- except Exception:
393
- continue
394
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/session_state.py CHANGED
@@ -5,200 +5,42 @@ intent from scratch. That broke fact-find: after the bot asked "what's
5
  your age?", the user's "39 years old" wasn't matched by intent_classifier
6
  and got routed to RAG retrieval (which then refused). This module fixes that.
7
 
8
- Persistence model (KI-118, 2026-05-15):
9
- - In-memory dict ONLY. No disk persistence.
10
  - Sessions are evicted from memory after `_TTL_SECONDS = 60 * 60` idle.
11
- - Cross-session memory is name-based: when the user provides a name,
12
- `rehydrate_by_name(session, name)` pulls the named profile from
13
- `backend.profile_store.load_profile(name)` (canonical JSON at
14
- `40-data/profiles/<persona_id>.json`).
15
- - Anonymous sessions live only in-memory and never leave a trace on disk.
16
-
17
- Rationale: insurance shoppers don't multi-session within a browsing window.
18
- Cross-session memory is name-based. The previous disk-write side
19
- (`40-data/sessions/<session_id>.json`) was the root of the Chroma
20
- corruption fought 2026-05-14/15 (profile_anonymous dangling row).
 
 
21
 
22
  Public API:
23
  get_session(session_id) -> SessionState
24
- rehydrate_by_name(session, name) -> bool # KI-118 cross-session re-entry
25
- SessionState.profile, .asked, .awaiting (question id pending answer)
26
  SessionState.set_awaiting(qid)
27
- SessionState.record_answer(qid, raw_answer) → also clears awaiting
 
 
 
28
  """
29
 
30
  from __future__ import annotations
31
 
32
  import logging
33
- import re
34
  import time
35
  from dataclasses import dataclass, field
36
  from threading import Lock
37
- from typing import Optional
38
-
39
- from typing import Any, Dict
40
 
41
  from backend.needs_finder import Profile, record_answer
42
 
43
-
44
- # 2026-05-27 — best-effort identity-fact extractors for the same-turn
45
- # match-before-merge guard inside `apply_pending_recall` AND for the
46
- # two-fact gate inside `rehydrate_by_name`. The LLM is the canonical
47
- # extractor (save_profile_field), but it runs AFTER both call sites in
48
- # the turn pipeline — so a "Yes I'm 35" / "Hi I'm Rohit, I'm in
49
- # Mumbai" reply would otherwise either merge the wrong profile or fail
50
- # to stage a legitimate same-turn recall. Mis-parses return None and
51
- # cost nothing.
52
- #
53
- # Coverage:
54
- # age — _extract_age_from_text
55
- # dependents — _extract_dependents_from_text (delegates to
56
- # brain_tools._normalize_dependents_inline so the
57
- # canonical bucket set is honored)
58
- # location_tier — _extract_location_tier_from_text (Indian-city
59
- # name → metro / tier1 / tier2 / tier3 lookup)
60
- # income_band — _extract_income_band_from_text (delegates to
61
- # needs_finder._parse_income_band)
62
-
63
- _AGE_HINT_RE = re.compile(
64
- r"\b(?:age\s*[:=]?\s*|i'?m\s+|i\s+am\s+|aged\s+|aged\s*[:=]?\s*)?(\d{2})"
65
- r"\s*(?:years?\s*old|yrs?|y/?o)?\b",
66
- re.IGNORECASE,
67
- )
68
-
69
-
70
- def _extract_age_from_text(text: str) -> Optional[int]:
71
- """Best-effort age extraction from a free-form user reply.
72
- Returns int in [18, 99] or None. Picks the FIRST plausible match —
73
- in practice the user states their age once, near the start.
74
- """
75
- if not text:
76
- return None
77
- for m in _AGE_HINT_RE.finditer(text):
78
- try:
79
- v = int(m.group(1))
80
- if 18 <= v <= 99:
81
- return v
82
- except Exception:
83
- continue
84
- return None
85
-
86
-
87
- def _extract_dependents_from_text(text: str) -> Optional[str]:
88
- """Best-effort dependents bucket from free-form text.
89
- Delegates to brain_tools._normalize_dependents_inline so the
90
- canonical bucket set ("self", "self+spouse", "self+spouse+kids",
91
- "self+parents", "self+spouse+parents", "self+spouse+kids+parents",
92
- "self+kids") is honored. Returns None on no match.
93
- """
94
- if not text:
95
- return None
96
- try:
97
- # Lazy import to avoid load-order issues at module init.
98
- from backend.brain_tools import _normalize_dependents_inline
99
- except Exception:
100
- return None
101
- try:
102
- return _normalize_dependents_inline(text)
103
- except Exception:
104
- return None
105
-
106
-
107
- # Indian-city → tier lookup. Tier-1 list per the IRDAI / RBI city
108
- # classification (Mumbai/Delhi/Bangalore/Chennai/Kolkata/Hyderabad =
109
- # Tier-1 "metro" in insurance vernacular). Bot's pricing uses "metro"
110
- # as the high-tier loading bucket — keep that as the alias here so a
111
- # user typing "Mumbai" maps to the same bucket the LLM would store.
112
- _LOCATION_TIER_MAP: dict[str, str] = {
113
- # Metro / Tier-1
114
- "mumbai": "metro", "bombay": "metro",
115
- "delhi": "metro", "new delhi": "metro",
116
- "bangalore": "metro", "bengaluru": "metro",
117
- "kolkata": "metro", "calcutta": "metro",
118
- "chennai": "metro", "madras": "metro",
119
- "hyderabad": "metro",
120
- "metro": "metro", "tier 1": "tier1", "tier1": "tier1", "tier-1": "tier1",
121
- # Tier-2
122
- "pune": "tier2", "ahmedabad": "tier2", "jaipur": "tier2",
123
- "lucknow": "tier2", "kanpur": "tier2", "nagpur": "tier2",
124
- "indore": "tier2", "thane": "tier2", "bhopal": "tier2",
125
- "visakhapatnam": "tier2", "vizag": "tier2",
126
- "patna": "tier2", "vadodara": "tier2", "baroda": "tier2",
127
- "ghaziabad": "tier2", "ludhiana": "tier2", "agra": "tier2",
128
- "nashik": "tier2", "faridabad": "tier2", "meerut": "tier2",
129
- "rajkot": "tier2", "varanasi": "tier2", "srinagar": "tier2",
130
- "aurangabad": "tier2", "amritsar": "tier2", "navi mumbai": "tier2",
131
- "allahabad": "tier2", "prayagraj": "tier2", "ranchi": "tier2",
132
- "howrah": "tier2", "coimbatore": "tier2", "jabalpur": "tier2",
133
- "gwalior": "tier2", "vijayawada": "tier2", "jodhpur": "tier2",
134
- "raipur": "tier2", "kota": "tier2", "chandigarh": "tier2",
135
- "guwahati": "tier2", "solapur": "tier2", "hubli": "tier2",
136
- "mysore": "tier2", "mysuru": "tier2",
137
- "tier 2": "tier2", "tier2": "tier2", "tier-2": "tier2",
138
- # Tier-3 buckets (rest)
139
- "tier 3": "tier3", "tier3": "tier3", "tier-3": "tier3",
140
- "small town": "tier3", "village": "tier3", "rural": "tier3",
141
- }
142
-
143
-
144
- def _extract_location_tier_from_text(text: str) -> Optional[str]:
145
- """Best-effort location_tier from a free-form user reply.
146
- Returns "metro" / "tier1" / "tier2" / "tier3" or None.
147
- Matches whole tokens / phrases against _LOCATION_TIER_MAP.
148
- """
149
- if not text:
150
- return None
151
- s = text.lower()
152
- # Longest-key-first so "navi mumbai" beats "mumbai".
153
- for key in sorted(_LOCATION_TIER_MAP.keys(), key=len, reverse=True):
154
- # Word-boundary match for single-word keys; substring for
155
- # multi-word keys (which already have natural boundaries).
156
- if " " in key:
157
- if key in s:
158
- return _LOCATION_TIER_MAP[key]
159
- else:
160
- if re.search(rf"\b{re.escape(key)}\b", s):
161
- return _LOCATION_TIER_MAP[key]
162
- return None
163
-
164
-
165
- def _extract_income_band_from_text(text: str) -> Optional[str]:
166
- """Best-effort income_band from free-form text.
167
- Delegates to needs_finder._parse_income_band so the canonical
168
- bucket set (under_5L / 5L-10L / 10L-25L / 25L+) is honored.
169
- """
170
- if not text:
171
- return None
172
- try:
173
- from backend.needs_finder import _parse_income_band
174
- except Exception:
175
- return None
176
- try:
177
- return _parse_income_band(text)
178
- except Exception:
179
- return None
180
-
181
-
182
- def _parse_user_text_facts(user_text: str) -> Dict[str, Any]:
183
- """Run all four identity-fact extractors against user_text. Returns
184
- a dict of only the fields that successfully parsed. Used by both
185
- the two-fact recall gate (rehydrate_by_name) and the same-turn
186
- match-before-merge guard (apply_pending_recall).
187
- """
188
- if not user_text:
189
- return {}
190
- out: Dict[str, Any] = {}
191
- for fld, fn in (
192
- ("age", _extract_age_from_text),
193
- ("dependents", _extract_dependents_from_text),
194
- ("location_tier", _extract_location_tier_from_text),
195
- ("income_band", _extract_income_band_from_text),
196
- ):
197
- v = fn(user_text)
198
- if v not in (None, "", []):
199
- out[fld] = v
200
- return out
201
-
202
  _log = logging.getLogger(__name__)
203
 
204
 
@@ -209,35 +51,20 @@ class SessionState:
209
  awaiting_question_id: Optional[str] = None # if set, next user message answers this
210
  free_form_session: bool = False # user explicitly opted out of fact-find
211
  last_touched: float = field(default_factory=time.time)
212
- # KI-196 (ADR-041) — confirmation-gated profile recall. When a fresh
213
- # session captures a name that matches an on-disk profile, the recall
214
- # is staged here (NOT auto-merged) and surfaced to the sales_brain as a
215
- # one-shot "welcome back" prompt. Affirm → merge stored fields into
216
- # `profile`. Negate → discard. Shape:
217
- # {
218
- # "name": "Rohit Sarma",
219
- # "summary": {age, dependents, location_tier, primary_goal, ...},
220
- # "captured_this_turn": {<field>: <value>, ...}, # don't re-extract
221
- # "staged_at": <epoch-seconds>,
222
- # }
223
- pending_profile_recall: Optional[Dict[str, Any]] = None
224
  # KI-224 — most-recent recommendation policy_ids the brain cited on the
225
- # last user-visible recommendation/comparison turn. Populated by the
226
- # orchestrator after a clean closer reply. Lets the NEXT turn route
227
  # follow-ups like "tell me more about #2" without re-retrieving from
228
  # scratch. Empty list = no active shortlist on this session.
229
  last_recommendation_ids: list = field(default_factory=list)
230
  # X7 (admin Recommendation History — conversation_turn column).
231
- # Monotonically incremented at the START of every orchestrator.handle_turn
232
- # and single_brain.handle_turn call so the policy-event writer can stamp
233
- # `turn_idx` on each event dict. Frontend renders this as the
234
- # "Conversation turn" column in the admin Recommendation History panel
235
- # (previously showed "—" because no caller populated the field).
236
  turn_idx: int = 0
237
  # Set True after the first successful single_brain turn; a later
238
  # SingleBrainError on the same session then emits a graceful retry
239
  # prompt instead of switching handlers, so the session stays on
240
- # single_brain.
241
  single_brain_sticky: bool = False
242
  # Post-recap pricing & family-history bundle re-ask gate
243
  # (brain_tools.retrieve_policies):
@@ -248,23 +75,6 @@ class SessionState:
248
  # explicitly declines the pricing inputs; bypasses the re-ask.
249
  pricing_bundle_reasked: bool = False
250
  pricing_bundle_skipped: bool = False
251
- # Bug #25 (2026-05-19) — one-shot guard for returning-user recall.
252
- # The old wiring only probed on turn 1, but the fact-find asks the
253
- # name in the bot's FIRST reply, so the name lands on turn >=2 and
254
- # recall never fired. The probe now runs whenever the name is first
255
- # known (any turn); this flag stops it re-staging every subsequent
256
- # turn and stops a declined recall from being re-offered.
257
- recall_probe_done: bool = False
258
- # ADR-042 follow-up #1 (2026-05-27) — two-fact recall gate. When
259
- # rehydrate_by_name finds a stored profile under the captured name
260
- # but the live session has NO identity-fact match (and user_text
261
- # carried no parseable fact either), it sets this flag instead of
262
- # staging. The caller (single_brain.py) sees this and DOES NOT set
263
- # recall_probe_done=True, so the probe retries on subsequent turns
264
- # as more facts come in via save_profile_field. Reset to False by
265
- # the caller every retry. Prevents the slug-collision Welcome-Back
266
- # leak from ever firing on a bare-name intro.
267
- recall_match_deferred: bool = False
268
 
269
  def _flush(self) -> None:
270
  """No-op. Session state lives only in the in-memory dict; the
@@ -299,326 +109,22 @@ _TTL_SECONDS = 60 * 60 # 1h idle → evict from in-memory cache
299
 
300
 
301
  def get_session(session_id: str) -> SessionState:
 
 
 
 
302
  with _lock:
303
  now = time.time()
304
- # Evict idle entries from the hot cache
305
  to_kill = [k for k, v in _sessions.items() if now - v.last_touched > _TTL_SECONDS]
306
  for k in to_kill:
307
  del _sessions[k]
308
  if session_id in _sessions:
309
  return _sessions[session_id]
310
- # KI-118 — no disk lookup; fresh sessions start blank. Cross-session
311
- # rehydration happens via rehydrate_by_name() when the user provides
312
- # their name to the fact_find brain.
313
  _sessions[session_id] = SessionState(session_id=session_id)
314
  return _sessions[session_id]
315
 
316
 
317
- # Identity-summary fields surfaced in the "are you <name>?" confirm prompt.
318
- # Enough to let the real owner recognise their own profile, but it is NOT
319
- # applied to the live session until the user explicitly confirms.
320
- _RECALL_SUMMARY_FIELDS: tuple[str, ...] = (
321
- "age", "dependents", "income_band", "location_tier",
322
- "primary_goal", "parents_age_max",
323
- )
324
-
325
-
326
- def rehydrate_by_name(
327
- session: SessionState,
328
- name: str,
329
- *,
330
- user_text: str = "",
331
- ) -> bool:
332
- """Cross-session re-entry point — STAGE a name match for confirmation.
333
-
334
- PRIVACY FIX (2026-05-16, audit). Previously this AUTO-MERGED the stored
335
- named profile into the live session on the very first turn. Because the
336
- lookup key was the user-stated NAME (not the session / no cookie), a
337
- second real user on a shared browser/IP — or anyone who simply states a
338
- common first name — was silently served a stranger's captured profile
339
- and greeted "Welcome back, <name>!". A fresh, no-cookie session must
340
- NEVER inherit another session's profile from a weak/shared key.
341
-
342
- PRIVACY HARDENING v4 (2026-05-27, ADR-042 follow-up #1) — TWO-FACT
343
- GATE. Even with the explicit confirm gate in apply_pending_recall, a
344
- bare-name "Hi I'm Rohit" would still LEAK staged attrs in the
345
- Welcome Back prompt (the brain's recall_block was redacted in v1/v2,
346
- but the very *existence* of a Welcome Back prompt telegraphs that
347
- SOMEONE under this name has used the bot before). The two-fact gate
348
- blocks the staging entirely unless at least ONE identity fact —
349
- drawn from live `session.profile` (prior-turn captures) OR parsed
350
- from `user_text` (same-turn, via _parse_user_text_facts) — MATCHES
351
- the stored profile. If a parsed fact CONTRADICTS, staging is also
352
- refused (no leak to a different person sharing the name slug). If
353
- no identity facts are available yet (bare name), staging is
354
- DEFERRED — `session.recall_match_deferred=True` signals the caller
355
- to retry on a later turn as more facts come in.
356
-
357
- Safe design (KI-196 / ADR-041, specced via `pending_profile_recall` but
358
- previously never wired): a name match is STAGED on
359
- `session.pending_profile_recall`, NOT merged. `session.profile` is left
360
- untouched, so `is_returning_user` / RULE-4 "Welcome back" does NOT fire
361
- on a fresh session. The brain asks the user to confirm ("are you
362
- <name>?"); only an explicit affirmation calls `apply_pending_recall(
363
- session, confirmed=True)` to merge the stored fields. An explicit deny
364
- discards the staged profile.
365
-
366
- Returns:
367
- False — always. The stored profile is NEVER auto-applied here, so
368
- callers must treat False as "do not flag a returning user
369
- / do not greet Welcome back". Whether a match was *staged*
370
- is observable via `session.pending_profile_recall`; the
371
- deferred-retry signal is `session.recall_match_deferred`.
372
-
373
- Failures are logged but never raise — a fresh chat must always proceed.
374
- """
375
- if not name or not name.strip():
376
- return False
377
- try:
378
- from backend.profile_store import load_profile
379
- stored = load_profile(name)
380
- if stored is None:
381
- # Bug #25 (2026-05-19): a multi-token capture ("Rohit Sar")
382
- # slugs to "rohit-sar" and misses the stored first-name file
383
- # ("rohit.json"). Fall back to the first name token. Still
384
- # privacy-safe — this only STAGES a match; the user must
385
- # explicitly confirm the identity summary before any merge.
386
- _stripped = (name or "").strip()
387
- _first = _stripped.split()[0] if _stripped else ""
388
- if _first and _first.lower() != _stripped.lower():
389
- stored = load_profile(_first)
390
- if stored is None:
391
- # No stored profile under this name. Done — no recall
392
- # opportunity, no deferral needed.
393
- session.recall_match_deferred = False
394
- return False
395
-
396
- # ─── TWO-FACT GATE (v4, 2026-05-27) ───────────────────────────
397
- # Require at least ONE non-name identity fact to match the
398
- # stored profile before staging. Sources for the fact:
399
- # (1) session.profile — already-captured live facts (any
400
- # prior turn of this same session)
401
- # (2) user_text — same-turn parse via _parse_user_text_facts
402
- # Any CONTRADICTION fails closed (no stage). No fact available
403
- # ⇒ defer (set session.recall_match_deferred=True so the
404
- # single_brain caller does NOT mark recall_probe_done, and the
405
- # probe retries next turn as more facts come in).
406
- same_turn_facts = _parse_user_text_facts(user_text)
407
- matched_fact = False
408
- contradicted_fact = False
409
- for fld in ("age", "dependents", "location_tier", "income_band"):
410
- stored_v = getattr(stored, fld, None)
411
- if stored_v in (None, "", []):
412
- continue
413
- # Source 1: prior-turn live capture
414
- live_v = getattr(session.profile, fld, None)
415
- if live_v not in (None, "", []):
416
- if fld == "age":
417
- try:
418
- if int(live_v) == int(stored_v):
419
- matched_fact = True
420
- else:
421
- contradicted_fact = True
422
- except Exception:
423
- pass
424
- else:
425
- if str(live_v).strip().lower() == str(stored_v).strip().lower():
426
- matched_fact = True
427
- else:
428
- contradicted_fact = True
429
- continue
430
- # Source 2: same-turn parse from user_text
431
- user_v = same_turn_facts.get(fld)
432
- if user_v not in (None, "", []):
433
- if fld == "age":
434
- try:
435
- if int(user_v) == int(stored_v):
436
- matched_fact = True
437
- else:
438
- contradicted_fact = True
439
- except Exception:
440
- pass
441
- else:
442
- if str(user_v).strip().lower() == str(stored_v).strip().lower():
443
- matched_fact = True
444
- else:
445
- contradicted_fact = True
446
-
447
- if contradicted_fact:
448
- # Any contradicting identity fact ⇒ fail-closed. No stage.
449
- # Mark probe done so we don't try again — the stored
450
- # profile is for a DIFFERENT person sharing this name slug.
451
- session.recall_match_deferred = False
452
- _log.info(
453
- "rehydrate_by_name: identity-fact contradiction for "
454
- "name=%r — fail-closed, no stage", name,
455
- )
456
- return False
457
- if not matched_fact:
458
- # No fact to confirm a match yet. Defer staging until a
459
- # later turn has captured a fact via save_profile_field.
460
- session.recall_match_deferred = True
461
- session.last_touched = time.time()
462
- return False
463
-
464
- # ─── At this point: stored exists AND ≥1 fact matches. Stage. ─
465
-
466
- # Build a non-PII-leaking identity summary so the brain can ask
467
- # "are you <name>?" without putting anything on the live profile.
468
- summary: Dict[str, Any] = {}
469
- for fld in _RECALL_SUMMARY_FIELDS:
470
- v = getattr(stored, fld, None)
471
- if v not in (None, "", []):
472
- summary[fld] = v
473
-
474
- # Snapshot the full stored union so apply_pending_recall can merge
475
- # WITHOUT a second disk read (and without the staged copy being
476
- # mutated by anything between stage and confirm).
477
- staged_fields: Dict[str, Any] = {}
478
- for fld in Profile.__dataclass_fields__.keys():
479
- v = getattr(stored, fld, None)
480
- if v not in (None, "", []):
481
- staged_fields[fld] = v
482
-
483
- session.pending_profile_recall = {
484
- "name": (getattr(stored, "name", None) or name).strip(),
485
- "summary": summary,
486
- "stored_fields": staged_fields,
487
- "staged_at": time.time(),
488
- }
489
- session.recall_match_deferred = False
490
- session.last_touched = time.time()
491
- # Deliberately False: nothing merged, no Welcome-back greeting.
492
- return False
493
- except Exception as e:
494
- _log.warning(
495
- "rehydrate_by_name failed (name=%r): %s: %s",
496
- name, type(e).__name__, str(e)[:200],
497
- )
498
- return False
499
-
500
-
501
- def apply_pending_recall(
502
- session: SessionState,
503
- *,
504
- confirmed: bool,
505
- user_text: str = "",
506
- ) -> bool:
507
- """Resolve a staged cross-session profile recall.
508
-
509
- PRIVACY FIX (2026-05-16). The ONLY path that merges a stored, name-keyed
510
- profile into a live session. Called after the user explicitly answers
511
- the "are you <name>?" confirm prompt.
512
-
513
- PRIVACY HARDENING (2026-05-27). The brain prompt no longer discloses
514
- staged attrs (single_brain.py recall_block). To compensate for the
515
- accidental / mistaken "yes" path, we now run a **match-before-merge**
516
- contradiction check: if the live profile has captured any identity
517
- fact in this conversation that CONTRADICTS the staged recall (e.g.
518
- user said age=29 but staged.age=34), we discard the staged recall
519
- entirely — no partial merge. Empty live slots (no contradiction
520
- possible) are still filled from staged on confirmed=True.
521
-
522
- confirmed=True — the user affirmed it's them. Stored fields fill any
523
- EMPTY slot on the live profile — UNLESS any live slot
524
- contradicts the staged value (then the whole staged
525
- recall is dropped). Returns True iff a profile was
526
- applied.
527
- confirmed=False — the user denied / it isn't them. The staged recall is
528
- discarded. The live session stays blank. Returns False.
529
-
530
- Idempotent: clears `pending_profile_recall` either way. A no-op (returns
531
- False) when there is nothing staged.
532
- """
533
- pending = getattr(session, "pending_profile_recall", None)
534
- if not pending:
535
- return False
536
- # Resolve the staging regardless of outcome.
537
- session.pending_profile_recall = None
538
- # Bug #25 (2026-05-19) — a confirmed OR denied recall is final for
539
- # this session; never auto-re-stage / re-offer it on a later turn.
540
- session.recall_probe_done = True
541
- if not confirmed:
542
- return False
543
- stored_fields = pending.get("stored_fields") or {}
544
- if not stored_fields:
545
- return False
546
- # PRIVACY HARDENING (2026-05-27) — match-before-merge guard.
547
- # Fields chosen here are decision-critical identity facts where a
548
- # mismatch unambiguously means "different person" (vs. e.g.
549
- # health_conditions which evolves between visits).
550
- _GUARD_FIELDS = (
551
- "age", "dependents", "income_band", "location_tier",
552
- "primary_goal", "parents_age_max",
553
- )
554
- for fld in _GUARD_FIELDS:
555
- live_v = getattr(session.profile, fld, None)
556
- staged_v = stored_fields.get(fld)
557
- if live_v in (None, "", []) or staged_v in (None, "", []):
558
- continue
559
- # Normalise to compare. Int and string forms of age both common.
560
- if str(live_v).strip().lower() != str(staged_v).strip().lower():
561
- _log.info(
562
- "apply_pending_recall: prior-turn contradiction on %s "
563
- "(live=%r, staged=%r) — discarding staged recall, "
564
- "no merge",
565
- fld, live_v, staged_v,
566
- )
567
- return False
568
- # PRIVACY HARDENING v2 (2026-05-27) — same-turn contradiction guard.
569
- # _affirm_or_deny + apply_pending_recall fire BEFORE the LLM iteration
570
- # that runs save_profile_field, so a user reply like "Yes I'm 35"
571
- # against a staged Rohit at age=29 would otherwise slip through the
572
- # prior-turn guard (live.<fld>=None at this point) and merge the
573
- # wrong profile.
574
- #
575
- # PRIVACY HARDENING v3 (2026-05-27, ADR-042 follow-up #2): extended
576
- # from age-only to all four decision-critical identity facts
577
- # (age / dependents / location_tier / income_band) via
578
- # _parse_user_text_facts. Mis-parses return None and cost nothing.
579
- if user_text:
580
- same_turn_facts = _parse_user_text_facts(user_text)
581
- for fld, user_v in same_turn_facts.items():
582
- staged_v = stored_fields.get(fld)
583
- if staged_v in (None, "", []):
584
- continue
585
- # Normalise. Age is the only numeric; the rest are strings.
586
- if fld == "age":
587
- try:
588
- staged_norm = int(staged_v)
589
- user_norm = int(user_v)
590
- except Exception:
591
- continue
592
- if staged_norm != user_norm:
593
- _log.info(
594
- "apply_pending_recall: same-turn age contradiction "
595
- "(user_text age=%d, staged.age=%s) — discarding "
596
- "staged recall, no merge",
597
- user_norm, staged_v,
598
- )
599
- return False
600
- else:
601
- if str(user_v).strip().lower() != str(staged_v).strip().lower():
602
- _log.info(
603
- "apply_pending_recall: same-turn %s contradiction "
604
- "(user_text=%r, staged=%r) — discarding staged "
605
- "recall, no merge",
606
- fld, user_v, staged_v,
607
- )
608
- return False
609
- for fld, new in stored_fields.items():
610
- try:
611
- if fld not in Profile.__dataclass_fields__:
612
- continue
613
- cur = getattr(session.profile, fld, None)
614
- if cur in (None, "", []) and new not in (None, "", []):
615
- setattr(session.profile, fld, new)
616
- except Exception:
617
- continue
618
- session.last_touched = time.time()
619
- return True
620
-
621
-
622
  def set_free_form(session_id: str, free_form: bool = True) -> None:
623
  s = get_session(session_id)
624
  s.free_form_session = free_form
@@ -630,9 +136,9 @@ def reset_session(session_id: str) -> bool:
630
  """Delete a session — evict from in-memory cache.
631
  Returns True if anything was actually deleted.
632
 
633
- KI-020 (2026-05-14) — backs the user-facing "Clear chat / start fresh" toggle.
634
- KI-118 (2026-05-15) no disk file to remove anymore; in-memory eviction
635
- is the only side effect. Returns True iff the session id was live.
636
  """
637
  with _lock:
638
  if session_id in _sessions:
@@ -642,17 +148,13 @@ def reset_session(session_id: str) -> bool:
642
 
643
 
644
  def clear_session(session_id: str) -> bool:
645
- """KI-196 (ADR-041) — Wipe in-memory state for one session_id WITHOUT
646
- touching any on-disk profile JSON under `40-data/profiles/`.
647
-
648
- Semantically identical to `reset_session` today (both just evict the
649
- in-memory entry; the disk profile has always been independent and lives
650
- by persona_id / name slug, not session_id). Kept as a distinct symbol so
651
- the call-site intent at `POST /api/session/clear` is self-documenting and
652
- so future divergence (e.g. partial-state wipes) doesn't require touching
653
- the legacy KI-020 caller.
654
 
655
- Returns True iff a live in-memory session was evicted.
 
 
 
 
656
  """
657
  with _lock:
658
  if session_id in _sessions:
@@ -662,8 +164,9 @@ def clear_session(session_id: str) -> bool:
662
 
663
 
664
  def purge_old_files() -> int:
665
- """KI-118 (2026-05-15) — no-op. Disk persistence was removed; there are
666
- no files to purge. Kept as a stub so any existing scheduled-task caller
667
- (cron / startup hook) doesn't crash on attribute miss.
 
668
  """
669
  return 0
 
5
  your age?", the user's "39 years old" wasn't matched by intent_classifier
6
  and got routed to RAG retrieval (which then refused). This module fixes that.
7
 
8
+ Persistence model (ADR-043, 2026-05-27 — REMOVAL of cross-session recall):
9
+ - In-memory dict ONLY. No disk persistence anywhere.
10
  - Sessions are evicted from memory after `_TTL_SECONDS = 60 * 60` idle.
11
+ - There is no cross-session memory. Closing the tab (or letting the
12
+ session go idle for an hour) discards the profile permanently.
13
+ - The previous cross-session recall design (ADR-041 + ADR-042 with
14
+ name-slug pointers under `40-data/profiles/` plus a confirmation
15
+ gate, redacted prompts, match-before-merge guards, two-fact gate
16
+ and same-turn extractors) was removed. The complexity tax was high
17
+ relative to the use case (insurance is a rare-purchase, return
18
+ sessions are uncommon), the privacy surface — name-only key with
19
+ slug-pointer collisions across distinct users required four
20
+ sequential hardening passes to keep contained, and the recall path
21
+ became a recurring bug source. Minimum-data-retention now matches
22
+ the simpler "stateless advisor" mental model.
23
 
24
  Public API:
25
  get_session(session_id) -> SessionState
26
+ SessionState.profile, .awaiting (question id pending answer)
 
27
  SessionState.set_awaiting(qid)
28
+ SessionState.record_user_answer(raw_answer) → also clears awaiting
29
+ SessionState.update_profile_field(name, value)
30
+ reset_session(session_id) / clear_session(session_id) — evict in-memory
31
+ set_free_form(session_id, free_form) — bypass fact-find for this session
32
  """
33
 
34
  from __future__ import annotations
35
 
36
  import logging
 
37
  import time
38
  from dataclasses import dataclass, field
39
  from threading import Lock
40
+ from typing import Any, Dict, Optional
 
 
41
 
42
  from backend.needs_finder import Profile, record_answer
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  _log = logging.getLogger(__name__)
45
 
46
 
 
51
  awaiting_question_id: Optional[str] = None # if set, next user message answers this
52
  free_form_session: bool = False # user explicitly opted out of fact-find
53
  last_touched: float = field(default_factory=time.time)
 
 
 
 
 
 
 
 
 
 
 
 
54
  # KI-224 — most-recent recommendation policy_ids the brain cited on the
55
+ # last user-visible recommendation/comparison turn. Populated by
56
+ # single_brain after a clean closer reply. Lets the NEXT turn route
57
  # follow-ups like "tell me more about #2" without re-retrieving from
58
  # scratch. Empty list = no active shortlist on this session.
59
  last_recommendation_ids: list = field(default_factory=list)
60
  # X7 (admin Recommendation History — conversation_turn column).
61
+ # Monotonically incremented at the START of every single_brain.handle_turn
62
+ # call so the policy-event writer can stamp `turn_idx` on each event dict.
 
 
 
63
  turn_idx: int = 0
64
  # Set True after the first successful single_brain turn; a later
65
  # SingleBrainError on the same session then emits a graceful retry
66
  # prompt instead of switching handlers, so the session stays on
67
+ # single_brain (see ADR-042 retry policy in single_brain._gemini_call).
68
  single_brain_sticky: bool = False
69
  # Post-recap pricing & family-history bundle re-ask gate
70
  # (brain_tools.retrieve_policies):
 
75
  # explicitly declines the pricing inputs; bypasses the re-ask.
76
  pricing_bundle_reasked: bool = False
77
  pricing_bundle_skipped: bool = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  def _flush(self) -> None:
80
  """No-op. Session state lives only in the in-memory dict; the
 
109
 
110
 
111
  def get_session(session_id: str) -> SessionState:
112
+ """Return the live in-memory SessionState for `session_id`, creating
113
+ a fresh blank one on miss. Idle sessions older than _TTL_SECONDS are
114
+ evicted lazily on every call. Disk is never consulted — see ADR-043.
115
+ """
116
  with _lock:
117
  now = time.time()
118
+ # Evict idle entries from the hot cache.
119
  to_kill = [k for k, v in _sessions.items() if now - v.last_touched > _TTL_SECONDS]
120
  for k in to_kill:
121
  del _sessions[k]
122
  if session_id in _sessions:
123
  return _sessions[session_id]
 
 
 
124
  _sessions[session_id] = SessionState(session_id=session_id)
125
  return _sessions[session_id]
126
 
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  def set_free_form(session_id: str, free_form: bool = True) -> None:
129
  s = get_session(session_id)
130
  s.free_form_session = free_form
 
136
  """Delete a session — evict from in-memory cache.
137
  Returns True if anything was actually deleted.
138
 
139
+ KI-020 (2026-05-14) — backs the user-facing "Clear chat / start fresh"
140
+ toggle. KI-118 (2026-05-15) removed disk persistence; in-memory
141
+ eviction is the only side effect.
142
  """
143
  with _lock:
144
  if session_id in _sessions:
 
148
 
149
 
150
  def clear_session(session_id: str) -> bool:
151
+ """Wipe in-memory state for one session_id.
 
 
 
 
 
 
 
 
152
 
153
+ Semantically identical to `reset_session` (both just evict the
154
+ in-memory entry). Kept as a distinct symbol so the call-site intent
155
+ at `POST /api/session/clear` is self-documenting and so future
156
+ divergence (e.g. partial-state wipes) doesn't require touching the
157
+ legacy KI-020 caller.
158
  """
159
  with _lock:
160
  if session_id in _sessions:
 
164
 
165
 
166
  def purge_old_files() -> int:
167
+ """No-op. Disk persistence was removed in KI-118 (2026-05-15) and
168
+ cross-session profile recall was removed in ADR-043 (2026-05-27).
169
+ Kept as a stub so any existing scheduled-task caller (cron / startup
170
+ hook) doesn't crash on attribute miss.
171
  """
172
  return 0
backend/single_brain.py CHANGED
@@ -737,63 +737,15 @@ def _build_contents(
737
  return out
738
 
739
 
740
- # WHOLE-WORD tokens (matched against the tokenised message, NOT as
741
- # substrings "no" must not match "k(no)ws", "ya" must not match "Ma(ya)").
742
- _RECALL_DENY_TOKENS = {
743
- "no", "nope", "nah", "naah", "nahi", "wrong", "never", "neither",
744
- "nopes", "nahin",
745
- }
746
- _RECALL_AFFIRM_TOKENS = {
747
- "yes", "yeah", "yep", "yup", "ya", "yaa", "yaah", "yess", "haan",
748
- "han", "haa", "correct", "right", "sahi", "bilkul", "sure", "indeed",
749
- "absolutely", "exactly", "true", "yup",
750
- }
751
- # Multi-word phrases — safe to match as substrings.
752
- _RECALL_DENY_PHRASES = (
753
- "not me", "isn't me", "isnt me", "not the same", "start fresh",
754
- "start over", "different person", "new user", "someone else",
755
- "not rohit", "first time", "never been", "fresh start", "not him",
756
- "not her", "i'm new", "im new", "not that person", "don't know",
757
- "dont know", "different one",
758
- )
759
- _RECALL_AFFIRM_PHRASES = (
760
- "that's me", "thats me", "that is me", "it's me", "its me", "i am",
761
- "pick up", "go ahead", "that's right", "thats right", "that's correct",
762
- "thats correct", "yes please", "continue where", "same person",
763
- "carry on", "of course",
764
- )
765
- _RECALL_TOKEN_RE = __import__("re").compile(r"[a-z']+")
766
-
767
-
768
- def _affirm_or_deny(text: str):
769
- """Conservative yes/no for the returning-user confirm gate.
770
-
771
- Returns True (affirm), False (deny), or None (ambiguous → re-ask).
772
- Deny wins ties: privacy is fail-closed — an ambiguous "no, but…" must
773
- NEVER merge a stranger's stored profile (ADR-041 / KI-196). Short
774
- tokens are matched whole-word (tokenised), not as substrings, so
775
- "who knows" / "i don't know" / "now" are NOT read as "no".
776
- """
777
- t = (text or "").strip().lower()
778
- if not t:
779
- return None
780
- toks = set(_RECALL_TOKEN_RE.findall(t))
781
- deny = bool(toks & _RECALL_DENY_TOKENS) or any(
782
- p in t for p in _RECALL_DENY_PHRASES
783
- )
784
- if deny:
785
- return False
786
- affirm = bool(toks & _RECALL_AFFIRM_TOKENS) or any(
787
- p in t for p in _RECALL_AFFIRM_PHRASES
788
- )
789
- if affirm:
790
- return True
791
- return None
792
 
793
 
794
  def _system_instruction(
795
  profile, is_returning_user: bool = False, shortlist_block: str = "",
796
- pending_recall: "Optional[dict]" = None, recall_applied: bool = False,
797
  reconstruct_from_history: bool = False,
798
  ) -> dict:
799
  """Bake the profile snapshot into the system prompt so each turn the
@@ -831,72 +783,13 @@ def _system_instruction(
831
  "say 'Welcome back'):\n"
832
  + json.dumps(snapshot, ensure_ascii=False, sort_keys=True)
833
  )
834
- recall_block = ""
835
- if pending_recall:
836
- _nm = (pending_recall.get("name") or "there").strip()
837
- # PRIVACY HARDENING (2026-05-27). Previously this prompt echoed the
838
- # staged profile's age / dependents / location_tier / primary_goal
839
- # into the user-visible "are you the same <name> (age 34, metro,
840
- # kids, first_buy)?" question. That disclosed a prior visitor's
841
- # identity facts to ANY stranger who happened to share the name
842
- # slug — the recall key is name-only, so two different Rohits
843
- # collide on rohit.json. The leak is independent of the merge
844
- # gate (apply_pending_recall) because the disclosure happened in
845
- # the prompt itself before any user answer.
846
- #
847
- # New design: do NOT disclose stored attrs. Ask the user to
848
- # RE-STATE one identifying fact (their age). On the next turn,
849
- # session_state.apply_pending_recall runs a match-before-merge
850
- # contradiction check against whatever the user just saved via
851
- # save_profile_field — a wrong "yes" plus a contradicting age
852
- # discards the staged recall, so a stranger can't inherit prior
853
- # attrs even by mis-clicking "yes".
854
- recall_block = (
855
- "\n\n═══════════════════════════════════\n"
856
- "RETURNING-USER CHECK — HIGHEST PRIORITY THIS TURN "
857
- "(overrides RULE 1 / fact-find for this one turn)\n"
858
- "═══════════════════════════════════\n"
859
- f"A stored profile may exist under the name the user just "
860
- f"gave (\"{_nm}\"). DO NOT disclose any stored attribute "
861
- "(age, dependents, location, goal, conditions, etc.) in your "
862
- "reply — disclosing them to an unconfirmed identity is a "
863
- "privacy leak. Stay neutral.\n"
864
- "Your ENTIRE reply this turn MUST be ONLY the confirmation "
865
- "question below. Do NOT call any tool, do NOT save_profile_field, "
866
- "do NOT run the 7-question fact-find, do NOT recommend:\n"
867
- f" \"Welcome back — have we spoken before? If yes, please "
868
- f"share your age so I can pull up the right profile. If not, "
869
- f"no problem — just say so and we'll start fresh.\"\n"
870
- "Then wait for their answer on the NEXT turn. If they share "
871
- "an age (or other fact), save_profile_field captures it and "
872
- "the system runs a match-before-merge check against the "
873
- "staged recall — you never merge anything yourself."
874
- )
875
- restored_block = ""
876
- if recall_applied:
877
- _rs = json.dumps(snapshot, ensure_ascii=False, sort_keys=True)
878
- restored_block = (
879
- "\n\n═══════════════════════════════════\n"
880
- "RETURNING USER CONFIRMED — PROFILE RESTORED "
881
- "(HIGHEST PRIORITY THIS TURN)\n"
882
- "═══════════════════════════════════\n"
883
- "The user just confirmed they are the SAME returning person. "
884
- "Their saved profile is RESTORED and FINAL for every slot "
885
- "present here:\n" + _rs + "\n"
886
- "Do NOT re-ask, re-confirm, re-verify or 'just double-check' "
887
- "ANY slot present above — name, age, dependents, city/location, "
888
- "income band, primary goal, health / pre-existing conditions, "
889
- "sum insured, existing cover, budget. Re-asking a RESTORED slot "
890
- "is a hard error: the entire point of recall is that the user "
891
- "does NOT repeat themselves.\n"
892
- "Your reply this turn: (1) ONE warm 'welcome back' line, then "
893
- "(2) resume exactly where a returning user continues — if the "
894
- "RULE 2.5 pricing inputs (sum insured / premium budget / co-pay "
895
- "/ smoker / family medical history) are NOT yet captured, ask "
896
- "ONLY those via the single RULE 2.5 prompt; otherwise go "
897
- "straight to retrieve_policies + recommendations. Ask ONLY for "
898
- "a slot that is genuinely ABSENT above — never one present."
899
- )
900
  reconstruct_block = ""
901
  if reconstruct_from_history:
902
  reconstruct_block = (
@@ -920,8 +813,8 @@ def _system_instruction(
920
  "recommendations. The user must never perceive any loss."
921
  )
922
  text = (
923
- SYSTEM_PROMPT + extra + recall_block + reconstruct_block
924
- + restored_block + (shortlist_block or "")
925
  )
926
  return {"parts": [{"text": text}]}
927
 
@@ -1935,99 +1828,14 @@ async def handle_turn(
1935
  model = _resolve_model()
1936
  language = _detect_language(user_text)
1937
 
1938
- # KI-255 — detect "returning user" so RULE 4 (Welcome Back greeting)
1939
- # only fires when the profile was actually loaded from a prior session.
1940
- # Signal: session.turn_idx == 1 (first turn of this session_id) AND the
1941
- # profile already has a captured slot (hydrated from prior persistence).
1942
- # turn_idx > 1 ⇒ slots filled by save_profile_field within THIS
1943
- # conversation — not a returning user.
1944
  _current_turn = int(getattr(session, "turn_idx", 1) or 1)
1945
 
1946
- # ── Returning-user recall (ADR-041 / KI-196), wired into single_brain
1947
- # 2026-05-19. Previously ORPHANED by the orchestrator→single-LLM
1948
- # rewrite: extract_potential_name / try_recall_by_name /
1949
- # apply_pending_recall existed and were unit-tested, but NOTHING on the
1950
- # live path called them so a same-name revisit ("Hi, I'm Rohit") was
1951
- # never recognised and the "are you the same Rohit?" prompt never fired.
1952
- # Privacy-safe by construction: a name match is only STAGED on
1953
- # session.pending_profile_recall (never auto-merged); only an explicit
1954
- # "yes" merges the stored slots, an explicit "no" discards, anything
1955
- # ambiguous leaves it staged so the LLM re-asks the confirm once.
1956
- _did_recall_this_turn = False
1957
- try:
1958
- from backend.profile_persistence import (
1959
- extract_potential_name,
1960
- try_recall_by_name,
1961
- )
1962
- from backend.session_state import apply_pending_recall
1963
-
1964
- _pending_recall = getattr(session, "pending_profile_recall", None)
1965
- if _pending_recall:
1966
- _ans = _affirm_or_deny(user_text)
1967
- if _ans is True:
1968
- # 2026-05-27 — user_text plumbed in so the same-turn age
1969
- # contradiction guard inside apply_pending_recall can
1970
- # discard a wrong-person "Yes I'm 35" reply before merging.
1971
- _did_recall_this_turn = bool(
1972
- apply_pending_recall(
1973
- session, confirmed=True, user_text=user_text,
1974
- )
1975
- )
1976
- _pending_recall = None
1977
- elif _ans is False:
1978
- apply_pending_recall(
1979
- session, confirmed=False, user_text=user_text,
1980
- )
1981
- _pending_recall = None
1982
- # ambiguous → leave staged; the confirm block is re-injected
1983
- # below and the LLM re-asks the "are you <name>?" question.
1984
- elif not getattr(session, "recall_probe_done", False):
1985
- # Bug #25 (2026-05-19) — recall must fire whenever the user's
1986
- # NAME first becomes known, NOT only on turn 1. The fact-find
1987
- # asks for the name in the bot's FIRST reply, so the user
1988
- # supplies it on turn >=2; the old `_current_turn == 1` gate
1989
- # skipped recall for the normal flow entirely, so a returning
1990
- # user was never recognised. The LLM reliably persists the
1991
- # name via save_profile_field, so `session.profile.name`
1992
- # (captured on a prior turn) is the robust trigger; we ALSO
1993
- # keep the free-text sniff for an explicit "I'm X" stated on
1994
- # the very first message (before save_profile_field has run).
1995
- _nm = (
1996
- (getattr(session.profile, "name", None) or "").strip()
1997
- or (extract_potential_name(user_text or "") or "")
1998
- )
1999
- if _nm:
2000
- # Stages session.pending_profile_recall iff a stored
2001
- # profile for this name exists AND the two-fact gate
2002
- # (ADR-042 follow-up #1) is satisfied — i.e., at least
2003
- # one identity fact (age / dependents / location /
2004
- # income) MATCHES between stored and the live/parsed
2005
- # user state. No match yet ⇒ session.recall_match_
2006
- # deferred=True and we DON'T flip recall_probe_done so
2007
- # the probe retries next turn when more facts come in.
2008
- # Still privacy-safe: STAGE only; explicit confirm merges.
2009
- try_recall_by_name(session, _nm, user_text=user_text)
2010
- _deferred = bool(
2011
- getattr(session, "recall_match_deferred", False)
2012
- )
2013
- if not _deferred:
2014
- # Probe was conclusive (staged / contradicted / no
2015
- # stored profile under this name). Mark done — name
2016
- # won't change, no point re-probing.
2017
- session.recall_probe_done = True
2018
- # Else: leave recall_probe_done as-is (False) so the
2019
- # next turn's gate re-enters this branch after the LLM
2020
- # has captured another identity fact via
2021
- # save_profile_field.
2022
- _pending_recall = getattr(
2023
- session, "pending_profile_recall", None
2024
- )
2025
- except Exception as _re: # noqa: BLE001 — recall must never break a turn
2026
- _log.warning(
2027
- "returning-user recall wiring failed: %s: %s",
2028
- type(_re).__name__, str(_re)[:200],
2029
- )
2030
- _pending_recall = getattr(session, "pending_profile_recall", None)
2031
 
2032
  _has_prior_profile = any(
2033
  getattr(session.profile, fld, None) not in (None, "", [])
@@ -2036,7 +1844,6 @@ async def handle_turn(
2036
  "income_band", "primary_goal", "health_conditions",
2037
  )
2038
  )
2039
- is_returning_user = (_current_turn == 1) and _has_prior_profile
2040
 
2041
  # Bug #26 (2026-05-19) — mid-conversation profile loss. Sessions are
2042
  # in-memory only (session_state._TTL_SECONDS = 1h; KI-118 removed disk
@@ -2049,10 +1856,12 @@ async def handle_turn(
2049
  # already-stated facts from chat_history instead of resetting. Guard:
2050
  # >=2 history messages ⇒ this is NOT the genuine first turn, so a
2051
  # blank profile means state was lost, not "fresh user".
 
 
 
 
2052
  _reconstruct_from_history = (
2053
  (not _has_prior_profile)
2054
- and not is_returning_user
2055
- and not _pending_recall
2056
  and bool(chat_history)
2057
  and len([m for m in (chat_history or [])
2058
  if (m or {}).get("role") == "user"]) >= 1
@@ -2102,8 +1911,6 @@ async def handle_turn(
2102
  session.profile,
2103
  is_returning_user=is_returning_user,
2104
  shortlist_block=_shortlist_block,
2105
- pending_recall=_pending_recall,
2106
- recall_applied=_did_recall_this_turn,
2107
  reconstruct_from_history=_reconstruct_from_history,
2108
  )
2109
 
@@ -2583,26 +2390,10 @@ async def handle_turn(
2583
  "the insurer before relying on it."
2584
  )
2585
 
2586
- # Bug #25 WRITE-SIDE (2026-05-19) — persist the named profile so a
2587
- # returning user can actually be recalled. profile_persistence.
2588
- # auto_persist_session was ORPHANED by the orchestrator→single-LLM
2589
- # rewrite (same class as the recall LOOKUP helpers + get_policy_facts
2590
- # + silent-TTS): nothing on the chat path ever called it, so a user
2591
- # who completed fact-find PURELY BY CHAT was never saved to the named
2592
- # store and recall had nothing to find — save_profile() only ran from
2593
- # the POST /api/profile builder UI. Persisting at end-of-turn (after
2594
- # all save_profile_field tool calls + recall handling have run) closes
2595
- # the loop. No-ops without a profile.name; swallows all errors so a
2596
- # stuck disk / Chroma hiccup can never block the chat reply.
2597
- try:
2598
- from backend.profile_persistence import auto_persist_session
2599
-
2600
- await auto_persist_session(session)
2601
- except Exception as _pe: # noqa: BLE001 — persistence must NEVER break a turn
2602
- _log.warning(
2603
- "auto_persist_session wiring failed: %s: %s",
2604
- type(_pe).__name__, str(_pe)[:200],
2605
- )
2606
 
2607
  return TurnResult(
2608
  reply_text=reply_text,
@@ -2618,7 +2409,7 @@ async def handle_turn(
2618
  blocked=False,
2619
  profile_updates=profile_updates,
2620
  followup_policy_id=followup_policy_id,
2621
- returning_user_recalled=_did_recall_this_turn,
2622
  )
2623
 
2624
 
 
737
  return out
738
 
739
 
740
+ # Returning-user recall machinery was removed in ADR-043 (2026-05-27).
741
+ # Sessions are in-memory only; closing the tab discards the profile.
742
+ # Bug #26 STATE-RECOVERY-from-chat_history (an in-session container-restart
743
+ # resilience path) is the only profile-rebuild mechanism that remains — it
744
+ # is NOT cross-session and never reads disk.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
745
 
746
 
747
  def _system_instruction(
748
  profile, is_returning_user: bool = False, shortlist_block: str = "",
 
749
  reconstruct_from_history: bool = False,
750
  ) -> dict:
751
  """Bake the profile snapshot into the system prompt so each turn the
 
783
  "say 'Welcome back'):\n"
784
  + json.dumps(snapshot, ensure_ascii=False, sort_keys=True)
785
  )
786
+ # Cross-session "Welcome Back" / "Profile Restored" blocks were
787
+ # removed in ADR-043 (2026-05-27). Sessions are in-memory only —
788
+ # closing the tab discards the profile. Only the in-session
789
+ # STATE-RECOVERY-MODE block below survives, because it never
790
+ # touches disk (it rebuilds the live profile from the chat_history
791
+ # the browser still carries when the server's session memory was
792
+ # evicted mid-conversation).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
793
  reconstruct_block = ""
794
  if reconstruct_from_history:
795
  reconstruct_block = (
 
813
  "recommendations. The user must never perceive any loss."
814
  )
815
  text = (
816
+ SYSTEM_PROMPT + extra + reconstruct_block
817
+ + (shortlist_block or "")
818
  )
819
  return {"parts": [{"text": text}]}
820
 
 
1828
  model = _resolve_model()
1829
  language = _detect_language(user_text)
1830
 
 
 
 
 
 
 
1831
  _current_turn = int(getattr(session, "turn_idx", 1) or 1)
1832
 
1833
+ # Cross-session returning-user recall was REMOVED in ADR-043
1834
+ # (2026-05-27). Sessions are in-memory only — closing the tab
1835
+ # discards the profile, no on-disk lookup happens. `is_returning_user`
1836
+ # remains a tracked flag but it is always False now (kept so
1837
+ # downstream code that may inspect it doesn't break).
1838
+ is_returning_user = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1839
 
1840
  _has_prior_profile = any(
1841
  getattr(session.profile, fld, None) not in (None, "", [])
 
1844
  "income_band", "primary_goal", "health_conditions",
1845
  )
1846
  )
 
1847
 
1848
  # Bug #26 (2026-05-19) — mid-conversation profile loss. Sessions are
1849
  # in-memory only (session_state._TTL_SECONDS = 1h; KI-118 removed disk
 
1856
  # already-stated facts from chat_history instead of resetting. Guard:
1857
  # >=2 history messages ⇒ this is NOT the genuine first turn, so a
1858
  # blank profile means state was lost, not "fresh user".
1859
+ #
1860
+ # NOTE: this is an IN-SESSION recovery path. It rebuilds the live
1861
+ # profile from the chat_history the BROWSER still carries — it never
1862
+ # reads from disk. Compatible with ADR-043's no-cross-session model.
1863
  _reconstruct_from_history = (
1864
  (not _has_prior_profile)
 
 
1865
  and bool(chat_history)
1866
  and len([m for m in (chat_history or [])
1867
  if (m or {}).get("role") == "user"]) >= 1
 
1911
  session.profile,
1912
  is_returning_user=is_returning_user,
1913
  shortlist_block=_shortlist_block,
 
 
1914
  reconstruct_from_history=_reconstruct_from_history,
1915
  )
1916
 
 
2390
  "the insurer before relying on it."
2391
  )
2392
 
2393
+ # End-of-turn disk persistence removed in ADR-043 (2026-05-27). The
2394
+ # profile lives only in the in-memory SessionState for the duration
2395
+ # of this session (1 h idle TTL), then evicts. No on-disk JSON, no
2396
+ # Chroma profile chunk, no cross-session recall.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2397
 
2398
  return TurnResult(
2399
  reply_text=reply_text,
 
2409
  blocked=False,
2410
  profile_updates=profile_updates,
2411
  followup_policy_id=followup_policy_id,
2412
+ returning_user_recalled=False,
2413
  )
2414
 
2415
 
frontend/src/lib/api.ts CHANGED
@@ -565,32 +565,9 @@ export async function getPredictedPremiumBand(
565
  return resp.json();
566
  }
567
 
568
- // KI-Z7 (2026-05-15) — Feature B. POST /api/profile/recall-by-name.
569
- // Asks the backend to look up a stored named-profile and hydrate the live
570
- // session_id with it. The chat path runs the same recall server-side on
571
- // turn 1, so this client helper is only needed for non-chat triggers (e.g.
572
- // the user types their name into the profile builder AFTER turn 1).
573
- export type RecallByNameResponse = {
574
- found: boolean;
575
- profile: Record<string, unknown> | null;
576
- predicted_band:
577
- | { min_inr: number; median_inr: number; max_inr: number; sample_size: number; assumed: boolean }
578
- | null;
579
- session_id: string;
580
- };
581
-
582
- export async function postProfileRecallByName(args: {
583
- name: string;
584
- session_id: string;
585
- }): Promise<RecallByNameResponse> {
586
- const resp = await fetch(`${BACKEND_URL}/api/profile/recall-by-name`, {
587
- method: "POST",
588
- headers: { "Content-Type": "application/json" },
589
- body: JSON.stringify({ name: args.name, session_id: args.session_id }),
590
- });
591
- if (!resp.ok) throw new Error(`profile recall failed: ${resp.status}`);
592
- return resp.json();
593
- }
594
 
595
  export async function postProfileUpdate(req: UserProfile & { session_id: string }): Promise<ProfileCompletenessResponse> {
596
  const resp = await fetch(`${BACKEND_URL}/api/profile`, {
 
565
  return resp.json();
566
  }
567
 
568
+ // /api/profile/recall-by-name + postProfileRecallByName were REMOVED in
569
+ // ADR-043 (2026-05-27). Cross-session profile recall no longer exists —
570
+ // closing the tab discards the session profile entirely.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571
 
572
  export async function postProfileUpdate(req: UserProfile & { session_id: string }): Promise<ProfileCompletenessResponse> {
573
  const resp = await fetch(`${BACKEND_URL}/api/profile`, {
tests/test_bug2526_recall_and_reconstruct.py DELETED
@@ -1,224 +0,0 @@
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
- # Bug-#45 test hygiene: handle_turn now auto-persists ANY
105
- # captured name, so the hard-coded "unknown name" used by
106
- # test_unknown_name_no_false_recall_later_turn leaks a
107
- # zzqxnobody*.json. Clean it (and this fixture's own files)
108
- # so a re-run starts from a true no-stored-profile state.
109
- if fp.stem.startswith("zzqxnobody"):
110
- fp.unlink(missing_ok=True)
111
- continue
112
- try:
113
- d = json.loads(fp.read_text())
114
- except Exception:
115
- continue
116
- if d.get("name_slug") == self.slug or \
117
- (d.get("profile") or {}).get("name") == self.first:
118
- fp.unlink(missing_ok=True)
119
- except Exception:
120
- pass
121
-
122
-
123
- class TestBug25RecallAfterTurn1(_FirstNameStoredFixture):
124
- def test_name_captured_on_later_turn_stages_recall(self):
125
- """The exact #25 flow: turn 1 has no name; the LLM captures the
126
- name (save_profile_field) so by a later turn session.profile.name
127
- is a TWO-token string. Old code: `elif _current_turn == 1`
128
- skipped the probe AND the slug missed the stored file → recall
129
- NEVER fired. Fixed: staged + confirm prompt injected."""
130
- sess = SessionState(session_id=f"b25_{uuid.uuid4().hex[:8]}")
131
- # Turn 1 — user states intent only, NO name.
132
- _run(single_brain.handle_turn(sess, "I want a health policy"))
133
- self.assertIsNone(getattr(sess, "pending_profile_recall", None),
134
- "no name yet → must not stage on turn 1")
135
- # The LLM captured the name via save_profile_field on turn 2;
136
- # replicate that exact server state (two-token name).
137
- sess.profile.name = self.full
138
- # A normal later fact-find turn (turn 3) — bare answer, NOT a
139
- # name preamble, NOT turn 1: the precise pre-fix dead zone.
140
- _run(single_brain.handle_turn(sess, "no pre-existing conditions"))
141
- pr = getattr(sess, "pending_profile_recall", None)
142
- self.assertTrue(
143
- pr, "#25: recall not staged when name known after turn 1")
144
- self.assertEqual(pr["name"], self.first,
145
- "#25: first-name slug fallback did not resolve "
146
- "the stored profile")
147
- self.assertIn("RETURNING-USER CHECK", self.sys_prompts[-1],
148
- "#25: confirm block not injected")
149
- self.assertTrue(getattr(sess, "recall_probe_done", False),
150
- "#25: one-shot guard not set")
151
-
152
- def test_explicit_yes_then_merges(self):
153
- sess = SessionState(session_id=f"b25y_{uuid.uuid4().hex[:8]}")
154
- sess.profile.name = self.full
155
- _run(single_brain.handle_turn(sess, "just me"))
156
- self.assertTrue(sess.pending_profile_recall)
157
- r2 = _run(single_brain.handle_turn(sess, "yes, that's me"))
158
- self.assertTrue(r2.returning_user_recalled)
159
- self.assertEqual(sess.profile.age, 41,
160
- "stored profile not merged on explicit yes")
161
-
162
- def test_declined_recall_not_reoffered(self):
163
- sess = SessionState(session_id=f"b25n_{uuid.uuid4().hex[:8]}")
164
- sess.profile.name = self.full
165
- _run(single_brain.handle_turn(sess, "just me"))
166
- self.assertTrue(sess.pending_profile_recall)
167
- apply_pending_recall(sess, confirmed=False)
168
- self.assertTrue(sess.recall_probe_done)
169
- # A later turn must NOT re-stage the declined recall.
170
- _run(single_brain.handle_turn(sess, "income 25L+"))
171
- self.assertIsNone(getattr(sess, "pending_profile_recall", None),
172
- "#25: declined recall was re-offered")
173
-
174
- def test_unknown_name_no_false_recall_later_turn(self):
175
- sess = SessionState(session_id=f"b25u_{uuid.uuid4().hex[:8]}")
176
- sess.profile.name = "Zzqxnobodyhasthis Lastname"
177
- _run(single_brain.handle_turn(sess, "no pre-existing conditions"))
178
- self.assertIsNone(getattr(sess, "pending_profile_recall", None))
179
- self.assertNotIn("RETURNING-USER CHECK", self.sys_prompts[-1])
180
-
181
-
182
- class TestBug26ReconstructFromHistory(_FirstNameStoredFixture):
183
- def _hist(self):
184
- return [
185
- {"role": "user", "content": "I want a health policy"},
186
- {"role": "assistant", "content": "Sure — your name and age?"},
187
- {"role": "user", "content": "Rohit, 29, Bangalore"},
188
- {"role": "assistant", "content": "Thanks Rohit. Income band?"},
189
- ]
190
-
191
- def test_blank_session_with_history_triggers_reconstruction(self):
192
- """Evicted/blank session BUT the client still carries the
193
- conversation → STATE-RECOVERY MODE, not "What's your name?"."""
194
- sess = SessionState(session_id=f"b26_{uuid.uuid4().hex[:8]}")
195
- _run(single_brain.handle_turn(
196
- sess, "income 25L+", chat_history=self._hist()))
197
- self.assertTrue(
198
- self.si_kwargs[-1].get("reconstruct_from_history"),
199
- "#26: reconstruction not triggered for blank+history")
200
- self.assertIn("STATE-RECOVERY MODE", self.sys_prompts[-1])
201
- self.assertNotIn("lost some of your profile",
202
- self.sys_prompts[-1].lower())
203
-
204
- def test_genuine_first_turn_no_reconstruction(self):
205
- sess = SessionState(session_id=f"b26f_{uuid.uuid4().hex[:8]}")
206
- _run(single_brain.handle_turn(sess, "I want a health policy"))
207
- self.assertFalse(
208
- self.si_kwargs[-1].get("reconstruct_from_history"),
209
- "#26: false recovery on a genuine first turn")
210
- self.assertNotIn("STATE-RECOVERY MODE", self.sys_prompts[-1])
211
-
212
- def test_populated_session_no_reconstruction(self):
213
- sess = SessionState(session_id=f"b26p_{uuid.uuid4().hex[:8]}")
214
- sess.profile.name = "Asha"
215
- sess.profile.age = 30
216
- _run(single_brain.handle_turn(
217
- sess, "income 25L+", chat_history=self._hist()))
218
- self.assertFalse(
219
- self.si_kwargs[-1].get("reconstruct_from_history"),
220
- "#26: reconstruction wrongly fired on a populated session")
221
-
222
-
223
- if __name__ == "__main__":
224
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_bug45_chat_profile_persistence.py DELETED
@@ -1,129 +0,0 @@
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()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_profile_rag_isolation.py DELETED
@@ -1,665 +0,0 @@
1
- """Regression tests for KI-102 / KI-112 / KI-117 / KI-118 — profile-RAG safety.
2
-
3
- KI-118 (2026-05-15) rewrites the threat model. Profile chunks are NO LONGER
4
- keyed by session_id; they are keyed by `name_slug` (canonical user name)
5
- and only NAMED users ever get embedded. Anonymous chats never write to
6
- Chroma — the corruption surface that the session_id keying introduced
7
- (the legacy `profile_anonymous` dangling row that poisoned every query
8
- with a `where` clause referencing session_id) is now structurally
9
- unreachable.
10
-
11
- The remaining safety guarantees these tests pin:
12
-
13
- KI-102 / KI-118 — main retrieval cosine pass MUST NOT return profile
14
- chunks (`where={'doc_type': {'$ne': 'profile'}}`).
15
- Profile chunks are exclusively surfaced via the
16
- explicit per-name lookup
17
- `collection.get(ids=[f'profile_{name_slug}'])`.
18
-
19
- KI-118.a — upsert_profile_chunk stamps `name_slug` into Chroma metadata.
20
- KI-118.b — retrieve()'s per-name lookup gates on metadata.name_slug ==
21
- caller's slug (triple-check), so cross-name leakage is blocked.
22
-
23
- KI-112 — input guards: empty/None name_slug refused; mis-shaped embeddings
24
- refused. These remain in place.
25
-
26
- KI-107 — retrieve() with a missing/non-existent profile must NEVER raise.
27
-
28
- Run:
29
- cd /Users/rohitsar/Developer/Insurance\\ Sales\\ Bot
30
- PYTHONPATH=$PWD .venv/bin/python -m pytest tests/test_profile_rag_isolation.py -v
31
- """
32
-
33
- from __future__ import annotations
34
-
35
- import asyncio
36
- import sys
37
- import unittest
38
- import uuid
39
- from pathlib import Path
40
- from unittest import mock
41
-
42
- _REPO_ROOT = Path(__file__).resolve().parent.parent
43
- if str(_REPO_ROOT) not in sys.path:
44
- sys.path.insert(0, str(_REPO_ROOT))
45
-
46
-
47
- # ---------------------------------------------------------------------------
48
- # In-memory Chroma + stub embedder. We avoid touching the real
49
- # settings.VECTORS_DIR so tests don't pollute the prod collection.
50
- # ---------------------------------------------------------------------------
51
-
52
-
53
- def _make_ephemeral_collection():
54
- """Return a fresh in-memory Chroma collection named 'policies'."""
55
- import chromadb
56
- from chromadb.config import Settings as ChromaSettings
57
- client = chromadb.EphemeralClient(
58
- settings=ChromaSettings(anonymized_telemetry=False),
59
- )
60
- # Use a unique name per call so parallel tests don't share state.
61
- return client.get_or_create_collection(
62
- name=f"policies_{uuid.uuid4().hex[:8]}",
63
- metadata={"hnsw:space": "cosine"},
64
- )
65
-
66
-
67
- class _StubEmbedder:
68
- """Deterministic 8-dim embedder so semantically-similar text gets
69
- semantically-similar vectors. Two profile chunks (one per named user)
70
- will end up with near-identical embeddings, which is exactly what
71
- triggers the pre-fix leak in the wild."""
72
-
73
- async def embed(self, texts, input_type="document"):
74
- # Hash-based but stable: every "USER CONTEXT" doc maps near the same
75
- # region of the unit sphere; that's the realistic case where two
76
- # users' profile chunks both look like profile chunks to cosine.
77
- vecs = []
78
- for t in texts:
79
- base = [0.0] * 8
80
- if "USER CONTEXT" in t or "profile" in t.lower():
81
- # All profile-flavoured text lands near vector [1, 0, ...]
82
- base = [1.0, 0.05, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
83
- elif "age" in t.lower() or "dependents" in t.lower():
84
- base = [0.9, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
85
- else:
86
- # Generic policy text is far from the profile cluster
87
- base = [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]
88
- vecs.append(base)
89
- return vecs
90
-
91
-
92
- # ---------------------------------------------------------------------------
93
- # Test cases — KI-118 threat model. Profile chunks keyed by name_slug.
94
- # ---------------------------------------------------------------------------
95
-
96
-
97
- class TestProfileIsolation(unittest.TestCase):
98
- """KI-118 — anonymous sessions never write to Chroma; named user A's
99
- profile must NEVER surface in named user B's retrieve."""
100
-
101
- def setUp(self):
102
- self.coll = _make_ephemeral_collection()
103
- self.name_a = f"alice_{uuid.uuid4().hex[:4]}"
104
- self.name_b = f"bob_{uuid.uuid4().hex[:4]}"
105
-
106
- def _seed_profile(self, name_slug: str, text: str) -> None:
107
- """Write a profile chunk for `name_slug` directly to the test
108
- collection, mirroring what upsert_profile_chunk does in prod."""
109
- vec = asyncio.run(_StubEmbedder().embed([text]))[0]
110
- chunk_id = f"profile_{name_slug}"
111
- self.coll.add(
112
- ids=[chunk_id],
113
- documents=[text],
114
- embeddings=[vec],
115
- metadatas=[{
116
- "policy_id": chunk_id,
117
- "insurer_slug": "profile",
118
- "policy_name": f"User profile ({name_slug[:16]})",
119
- "doc_type": "profile",
120
- "name_slug": name_slug, # KI-118.a — stamped at write time
121
- "source_url": "",
122
- "page_start": 0,
123
- "page_end": 0,
124
- "chunk_idx": 0,
125
- "local_path": "in-memory test profile",
126
- }],
127
- )
128
-
129
- def _seed_policy(self, policy_id: str, text: str) -> None:
130
- """Seed a generic non-profile chunk so the main retrieval pass
131
- isn't empty (otherwise we're not actually testing the filter)."""
132
- vec = asyncio.run(_StubEmbedder().embed([text]))[0]
133
- self.coll.add(
134
- ids=[policy_id],
135
- documents=[text],
136
- embeddings=[vec],
137
- metadatas=[{
138
- "policy_id": policy_id,
139
- "insurer_slug": "test-insurer",
140
- "policy_name": "Test Policy",
141
- "doc_type": "policy",
142
- "source_url": "",
143
- "page_start": 1,
144
- "page_end": 1,
145
- "chunk_idx": 0,
146
- }],
147
- )
148
-
149
- def _run_retrieve(self, query: str, profile_name_slug: str, top_k: int = 5):
150
- """Invoke rag.retrieve.retrieve() with the test collection +
151
- stub embedder patched in."""
152
- from rag import retrieve as retrieve_mod
153
- # Clear the in-process cache so each test sees a fresh execution
154
- retrieve_mod._RETRIEVAL_CACHE.clear()
155
- with mock.patch.object(retrieve_mod, "get_collection", return_value=self.coll):
156
- return asyncio.run(retrieve_mod.retrieve(
157
- query=query,
158
- top_k=top_k,
159
- embedder=_StubEmbedder(),
160
- profile_name_slug=profile_name_slug,
161
- ))
162
-
163
- # -----------------------------------------------------------------
164
- # CASE 1 — pre-fix leak repro: named user A's profile must NOT show
165
- # up in named user B's retrieved context.
166
- # -----------------------------------------------------------------
167
- def test_name_a_profile_never_leaks_into_name_b(self):
168
- self._seed_profile(
169
- self.name_a,
170
- "USER CONTEXT — facts about the person asking this question:\n"
171
- "- Age: 45 years.\n- User's own pre-existing conditions: diabetes, hypertension.",
172
- )
173
- self._seed_profile(
174
- self.name_b,
175
- "USER CONTEXT — facts about the person asking this question:\n"
176
- "- Age: 28 years.\n- First-time buyer; no existing health insurance.",
177
- )
178
- # Add a generic policy chunk so there's something to retrieve.
179
- self._seed_policy("hdfc_ergo_optima_secure_v1", "Standard health policy text about waiting periods.")
180
-
181
- # User B asks a profile-flavoured query
182
- chunks = self._run_retrieve(
183
- query="what plan suits my age and dependents",
184
- profile_name_slug=self.name_b,
185
- )
186
-
187
- leaked = [c for c in chunks if c.policy_id == f"profile_{self.name_a}"]
188
- self.assertEqual(
189
- leaked, [],
190
- f"PRIVACY LEAK: user A's profile chunk surfaced in user B's "
191
- f"retrieval. Found: {[c.policy_id for c in chunks]}",
192
- )
193
-
194
- # -----------------------------------------------------------------
195
- # CASE 2 — named user B's OWN profile must still surface (positive path).
196
- # -----------------------------------------------------------------
197
- def test_name_b_own_profile_is_surfaced(self):
198
- self._seed_profile(
199
- self.name_b,
200
- "USER CONTEXT — facts about the person asking this question:\n"
201
- "- Age: 28 years.",
202
- )
203
- self._seed_policy("test_policy_1", "Generic policy text.")
204
-
205
- chunks = self._run_retrieve(
206
- query="recommend a plan for me",
207
- profile_name_slug=self.name_b,
208
- )
209
- own = [c for c in chunks if c.policy_id == f"profile_{self.name_b}"]
210
- self.assertEqual(
211
- len(own), 1,
212
- f"User B should see its OWN profile chunk. Got: {[c.policy_id for c in chunks]}",
213
- )
214
-
215
- # -----------------------------------------------------------------
216
- # CASE 3 — multiple foreign profiles + one own profile. Only the
217
- # current user's chunk may be present.
218
- # -----------------------------------------------------------------
219
- def test_three_foreign_profiles_none_leak(self):
220
- for name in ["alice_1", "carol_2", "dave_3"]:
221
- self._seed_profile(
222
- name,
223
- f"USER CONTEXT — facts about the person asking this question:\n"
224
- f"- Age: {30 + len(name)} years.\n- Health conditions: PII for {name}.",
225
- )
226
- self._seed_profile(
227
- self.name_b,
228
- "USER CONTEXT — facts about the person asking this question:\n- Age: 28 years.",
229
- )
230
- self._seed_policy("test_policy_2", "Generic policy text.")
231
-
232
- chunks = self._run_retrieve(
233
- query="my age health conditions dependents",
234
- profile_name_slug=self.name_b,
235
- top_k=10,
236
- )
237
- profile_pids = [c.policy_id for c in chunks if c.doc_type == "profile"]
238
- # Only ONE profile chunk may appear, and it must be name_b's
239
- self.assertEqual(
240
- profile_pids, [f"profile_{self.name_b}"],
241
- f"Foreign profile leaked. profile chunks in result: {profile_pids}",
242
- )
243
-
244
- # -----------------------------------------------------------------
245
- # CASE 4 — legacy chunk without name_slug metadata is refused even
246
- # if its id happens to match (defence-in-depth from KI-118.b).
247
- # -----------------------------------------------------------------
248
- def test_legacy_chunk_without_name_slug_metadata_is_refused(self):
249
- # Write a chunk under id 'profile_<name_b>' but with NO
250
- # name_slug field (simulating a pre-KI-118 legacy row).
251
- vec = asyncio.run(_StubEmbedder().embed(["USER CONTEXT — legacy"]))[0]
252
- chunk_id = f"profile_{self.name_b}"
253
- self.coll.add(
254
- ids=[chunk_id],
255
- documents=["USER CONTEXT — legacy row from before KI-118 deploy"],
256
- embeddings=[vec],
257
- metadatas=[{
258
- "policy_id": chunk_id,
259
- "insurer_slug": "profile",
260
- "policy_name": "legacy profile",
261
- "doc_type": "profile",
262
- # No 'name_slug' — simulating pre-fix state
263
- "source_url": "",
264
- "page_start": 0,
265
- "page_end": 0,
266
- "chunk_idx": 0,
267
- }],
268
- )
269
- self._seed_policy("test_policy_3", "Generic policy text.")
270
-
271
- chunks = self._run_retrieve(
272
- query="anything",
273
- profile_name_slug=self.name_b,
274
- )
275
- # Legacy chunk must be refused — the triple-check at retrieve's
276
- # per-name lookup gates on metadata.name_slug match.
277
- legacy_hits = [c for c in chunks if c.policy_id == chunk_id]
278
- self.assertEqual(
279
- legacy_hits, [],
280
- "Legacy profile chunk without name_slug metadata must be refused. "
281
- f"Got: {[c.policy_id for c in chunks]}",
282
- )
283
-
284
- # -----------------------------------------------------------------
285
- # CASE 5 — KI-118 core invariant: anonymous calls (no profile_name_slug)
286
- # produce NO profile chunks at all, even if the collection contains
287
- # foreign profile rows that match the query.
288
- # -----------------------------------------------------------------
289
- def test_anonymous_retrieve_never_surfaces_any_profile_chunk(self):
290
- # Seed two named-user profiles
291
- self._seed_profile(self.name_a, "USER CONTEXT — Age: 45 years.")
292
- self._seed_profile(self.name_b, "USER CONTEXT — Age: 28 years.")
293
- self._seed_policy("test_policy_anon", "Generic policy text.")
294
-
295
- # Anonymous call — no profile_name_slug
296
- from rag import retrieve as retrieve_mod
297
- retrieve_mod._RETRIEVAL_CACHE.clear()
298
- with mock.patch.object(retrieve_mod, "get_collection", return_value=self.coll):
299
- chunks = asyncio.run(retrieve_mod.retrieve(
300
- query="my age health conditions",
301
- top_k=5,
302
- embedder=_StubEmbedder(),
303
- profile_name_slug=None,
304
- ))
305
- profile_chunks = [c for c in chunks if c.doc_type == "profile"]
306
- self.assertEqual(
307
- profile_chunks, [],
308
- "PRIVACY LEAK: anonymous retrieve surfaced a profile chunk. "
309
- f"Got: {[c.policy_id for c in chunks]}",
310
- )
311
-
312
-
313
- # ---------------------------------------------------------------------------
314
- # KI-107 (2026-05-15) — graceful handling of Chroma get(ids=[missing]).
315
- # C5 port-in persona saw 3× HTTP 500 "Error executing plan: Internal error:
316
- # Error finding id". After KI-102 added the per-session profile-chunk
317
- # lookup, retrieve() runs collection.get(ids=[f"profile_{slug}"]) on EVERY
318
- # named query — and for new users (no profile saved yet) and certain
319
- # Chroma sqlite states, that call can raise. These tests pin the contract:
320
- # retrieve() with a never-existed name_slug must NEVER raise and must
321
- # NEVER return a profile chunk.
322
- # ---------------------------------------------------------------------------
323
-
324
-
325
- class TestRetrieveSurvivesMissingProfileId(unittest.TestCase):
326
- """KI-107 — retrieve(profile_name_slug=...) must be exception-safe across:
327
- (1) first-time named users with no profile chunk yet,
328
- (2) Chroma.get raising on the per-name lookup,
329
- (3) Chroma.get returning empty lists for missing ids."""
330
-
331
- def setUp(self):
332
- self.coll = _make_ephemeral_collection()
333
-
334
- def _seed_one_policy(self):
335
- """Seed a single policy chunk so the main cosine pass returns something."""
336
- vec = asyncio.run(_StubEmbedder().embed(["Standard policy text."]))[0]
337
- self.coll.add(
338
- ids=["policy_seed_1"],
339
- documents=["Standard policy text about waiting periods."],
340
- embeddings=[vec],
341
- metadatas=[{
342
- "policy_id": "policy_seed_1",
343
- "insurer_slug": "test-insurer",
344
- "policy_name": "Test Policy",
345
- "doc_type": "policy",
346
- "source_url": "",
347
- "page_start": 1,
348
- "page_end": 1,
349
- "chunk_idx": 0,
350
- }],
351
- )
352
-
353
- def _run_retrieve(self, query: str, profile_name_slug: str, top_k: int = 5):
354
- from rag import retrieve as retrieve_mod
355
- retrieve_mod._RETRIEVAL_CACHE.clear()
356
- with mock.patch.object(retrieve_mod, "get_collection", return_value=self.coll):
357
- return asyncio.run(retrieve_mod.retrieve(
358
- query=query,
359
- top_k=top_k,
360
- embedder=_StubEmbedder(),
361
- profile_name_slug=profile_name_slug,
362
- ))
363
-
364
- def test_retrieve_with_never_existed_name_does_not_raise(self):
365
- """First-time named user, no profile saved yet — must return policy
366
- chunks without raising. Pre-KI-107 this surfaced as HTTP 500 "Error
367
- finding id" because get(ids=[missing]) raised + bare except: pass
368
- let downstream code index into a None result."""
369
- self._seed_one_policy()
370
-
371
- # Should not raise
372
- chunks = self._run_retrieve(
373
- query="what is the waiting period for cataract",
374
- profile_name_slug="never_existed_name_xyz",
375
- )
376
-
377
- # No profile chunk should appear (none was ever written)
378
- profile_chunks = [c for c in chunks if c.doc_type == "profile"]
379
- self.assertEqual(
380
- profile_chunks, [],
381
- f"never-existed name should not produce profile chunks. "
382
- f"Got: {[(c.chunk_id, c.doc_type) for c in chunks]}",
383
- )
384
- # But main cosine retrieval must still work
385
- self.assertGreater(
386
- len(chunks), 0,
387
- "main cosine pass should still return the seeded policy chunk.",
388
- )
389
-
390
- def test_retrieve_handles_chroma_get_raising_on_per_name_lookup(self):
391
- """Simulate the worst case: Chroma raises on the per-name profile
392
- lookup (e.g. transient sqlite lock during compaction). retrieve()
393
- must still return main cosine results, not 500."""
394
- self._seed_one_policy()
395
-
396
- # Wrap the real collection so .get() raises but .query() works
397
- real_coll = self.coll
398
-
399
- class _RaisingGetWrapper:
400
- def __init__(self, inner):
401
- self._inner = inner
402
-
403
- def query(self, *args, **kwargs):
404
- return self._inner.query(*args, **kwargs)
405
-
406
- def get(self, *args, **kwargs):
407
- raise RuntimeError("Error finding id: simulated chroma failure")
408
-
409
- wrapped = _RaisingGetWrapper(real_coll)
410
-
411
- from rag import retrieve as retrieve_mod
412
- retrieve_mod._RETRIEVAL_CACHE.clear()
413
- with mock.patch.object(retrieve_mod, "get_collection", return_value=wrapped):
414
- # Must NOT raise — _safe_collection_get swallows + logs
415
- chunks = asyncio.run(retrieve_mod.retrieve(
416
- query="what is the waiting period",
417
- top_k=5,
418
- embedder=_StubEmbedder(),
419
- profile_name_slug="some_name_slug",
420
- ))
421
-
422
- # Main cosine still works → at least the seeded policy chunk returns
423
- self.assertGreater(
424
- len(chunks), 0,
425
- "retrieve() should fall back to main cosine results when "
426
- "the per-name profile lookup raises.",
427
- )
428
- # No profile chunk surfaced
429
- self.assertEqual(
430
- [c for c in chunks if c.doc_type == "profile"], [],
431
- "raising get() must NOT produce a profile chunk in the result.",
432
- )
433
-
434
- def test_safe_collection_get_returns_none_on_exception(self):
435
- """Unit-test the _safe_collection_get helper directly."""
436
- from rag.retrieve import _safe_collection_get
437
-
438
- class _Raiser:
439
- def get(self, **kw):
440
- raise RuntimeError("Error finding id")
441
-
442
- result = _safe_collection_get(_Raiser(), ids=["x"], include=["documents"])
443
- self.assertIsNone(
444
- result,
445
- "_safe_collection_get must return None on exception, not re-raise.",
446
- )
447
-
448
- def test_safe_collection_get_returns_empty_dict_on_miss(self):
449
- """When ids miss but Chroma returns empty lists (the normal case),
450
- _safe_collection_get returns the raw dict — caller decides what to
451
- do with empty lists (the truthiness check filters them out)."""
452
- from rag.retrieve import _safe_collection_get
453
-
454
- result = _safe_collection_get(
455
- self.coll,
456
- ids=["definitely_does_not_exist"],
457
- include=["documents", "metadatas"],
458
- )
459
- # Should return a dict (not None), with empty ids list
460
- self.assertIsNotNone(result, "missing-id get must not return None")
461
- self.assertEqual(result.get("ids"), [], "missing id should yield empty ids list")
462
-
463
-
464
- # ---------------------------------------------------------------------------
465
- # Standalone upsert metadata test — no Chroma client; just verify the
466
- # upsert builds metadata containing name_slug.
467
- # ---------------------------------------------------------------------------
468
-
469
-
470
- class TestUpsertStampsNameSlug(unittest.TestCase):
471
- """KI-118.a — upsert_profile_chunk MUST write name_slug into the
472
- chunk's Chroma metadata. Without it, the retrieve filter can't
473
- distinguish user A's profile from user B's."""
474
-
475
- def test_upsert_writes_name_slug_to_metadata(self):
476
- from backend import profile_rag
477
-
478
- captured: dict = {}
479
-
480
- class _FakeColl:
481
- def add(self, ids, documents, embeddings, metadatas):
482
- captured["ids"] = ids
483
- captured["metadatas"] = metadatas
484
-
485
- def delete(self, where=None):
486
- captured["deleted_where"] = where
487
-
488
- class _FakeEmbedder:
489
- # KI-112 (2026-05-15) — the upsert path now validates that the
490
- # embedding length matches embedder.dimension. Set both to 384 so
491
- # the realistic-shape vector passes the shape check; the test's
492
- # subject under scrutiny is the metadata stamping, not the shape
493
- # guard (those have dedicated cases below).
494
- dimension = 384
495
-
496
- async def embed(self, texts, input_type="document"):
497
- return [[0.1] * 384 for _ in texts]
498
-
499
- fake_coll = _FakeColl()
500
- slug = f"alice_{uuid.uuid4().hex[:6]}"
501
- profile = {
502
- "age": 32,
503
- "dependents": "self_spouse",
504
- "health_conditions": [],
505
- "existing_cover_inr": 500000,
506
- }
507
-
508
- with mock.patch.object(profile_rag, "_get_collection", return_value=fake_coll), \
509
- mock.patch("backend.providers.local_embeddings.LocalEmbeddings", _FakeEmbedder):
510
- asyncio.run(profile_rag.upsert_profile_chunk(slug, profile))
511
-
512
- self.assertIn("metadatas", captured, "upsert never called coll.add")
513
- meta = captured["metadatas"][0]
514
- self.assertEqual(
515
- meta.get("name_slug"), slug,
516
- f"profile chunk metadata missing name_slug. Got: {meta}",
517
- )
518
- self.assertEqual(meta.get("doc_type"), "profile")
519
- self.assertEqual(captured["ids"], [f"profile_{slug}"])
520
-
521
-
522
- # ---------------------------------------------------------------------------
523
- # KI-112 (2026-05-15) — input validation hardening (still applies post-KI-118).
524
- #
525
- # Root cause of the historical HNSW corruption: KI-102's initial deploy wrote
526
- # a profile chunk under id "profile_anonymous" with NO session_id metadata.
527
- # That legacy chunk poisoned every subsequent collection.query() that
528
- # referenced session_id or doc_type$ne in the where clause — Chroma's plan
529
- # executor raised "Error finding id" against the dangling row's HNSW pointer.
530
- #
531
- # KI-118 moved the key from session_id to name_slug; the guards are the same.
532
- # These tests pin the contract: bad inputs MUST be rejected at write time,
533
- # not silently corrupt the index for future users.
534
- # ---------------------------------------------------------------------------
535
-
536
-
537
- class TestUpsertRejectsBadInputs(unittest.TestCase):
538
- """KI-112 / KI-118 — bad name_slug or embedding shape must NOT reach Chroma."""
539
-
540
- def test_upsert_rejects_empty_name_slug(self):
541
- from backend import profile_rag
542
-
543
- captured: dict = {"add_called": False}
544
-
545
- class _FakeColl:
546
- def add(self, ids, documents, embeddings, metadatas):
547
- captured["add_called"] = True
548
-
549
- def delete(self, where=None):
550
- captured["delete_called"] = True
551
-
552
- class _FakeEmbedder:
553
- dimension = 384
554
-
555
- async def embed(self, texts, input_type="document"):
556
- return [[0.1] * 384 for _ in texts]
557
-
558
- for bad_slug in ["", " ", None]:
559
- with mock.patch.object(profile_rag, "_get_collection", return_value=_FakeColl()), \
560
- mock.patch("backend.providers.local_embeddings.LocalEmbeddings", _FakeEmbedder):
561
- captured["add_called"] = False
562
- asyncio.run(profile_rag.upsert_profile_chunk(bad_slug, {"age": 30}))
563
- self.assertFalse(
564
- captured["add_called"],
565
- f"upsert MUST refuse name_slug={bad_slug!r} — bad write would "
566
- "corrupt the policies collection.",
567
- )
568
-
569
- def test_upsert_rejects_mismatched_embedding_dim(self):
570
- """If the embedder somehow returns the wrong dim (model drift,
571
- misconfig), upsert must NOT write it to Chroma."""
572
- from backend import profile_rag
573
-
574
- captured: dict = {"add_called": False}
575
-
576
- class _FakeColl:
577
- def add(self, ids, documents, embeddings, metadatas):
578
- captured["add_called"] = True
579
-
580
- def delete(self, where=None):
581
- pass
582
-
583
- class _BadDimEmbedder:
584
- dimension = 384
585
-
586
- async def embed(self, texts, input_type="document"):
587
- # Wrong dim — 8-dim stub like other tests, but profile_rag
588
- # expects 384.
589
- return [[0.1] * 8 for _ in texts]
590
-
591
- with mock.patch.object(profile_rag, "_get_collection", return_value=_FakeColl()), \
592
- mock.patch("backend.providers.local_embeddings.LocalEmbeddings", _BadDimEmbedder):
593
- asyncio.run(profile_rag.upsert_profile_chunk(
594
- "valid_slug_xyz", {"age": 30, "dependents": "self"},
595
- ))
596
-
597
- self.assertFalse(
598
- captured["add_called"],
599
- "upsert MUST refuse a mis-shaped embedding to prevent HNSW "
600
- "corruption from a model drift event.",
601
- )
602
-
603
- def test_upsert_rejects_none_in_embedding(self):
604
- from backend import profile_rag
605
-
606
- captured: dict = {"add_called": False}
607
-
608
- class _FakeColl:
609
- def add(self, ids, documents, embeddings, metadatas):
610
- captured["add_called"] = True
611
-
612
- def delete(self, where=None):
613
- pass
614
-
615
- class _NoneVecEmbedder:
616
- dimension = 384
617
-
618
- async def embed(self, texts, input_type="document"):
619
- vec = [0.1] * 384
620
- vec[42] = None # one None value
621
- return [vec for _ in texts]
622
-
623
- with mock.patch.object(profile_rag, "_get_collection", return_value=_FakeColl()), \
624
- mock.patch("backend.providers.local_embeddings.LocalEmbeddings", _NoneVecEmbedder):
625
- asyncio.run(profile_rag.upsert_profile_chunk(
626
- "valid_slug_xyz", {"age": 30, "dependents": "self"},
627
- ))
628
-
629
- self.assertFalse(
630
- captured["add_called"],
631
- "upsert MUST refuse a vector containing None values.",
632
- )
633
-
634
- def test_upsert_accepts_correct_shape(self):
635
- """Positive path — a well-formed 384-dim list must be persisted."""
636
- from backend import profile_rag
637
-
638
- captured: dict = {}
639
-
640
- class _FakeColl:
641
- def add(self, ids, documents, embeddings, metadatas):
642
- captured["ids"] = ids
643
- captured["embeddings"] = embeddings
644
-
645
- def delete(self, where=None):
646
- pass
647
-
648
- class _GoodEmbedder:
649
- dimension = 384
650
-
651
- async def embed(self, texts, input_type="document"):
652
- return [[0.1] * 384 for _ in texts]
653
-
654
- with mock.patch.object(profile_rag, "_get_collection", return_value=_FakeColl()), \
655
- mock.patch("backend.providers.local_embeddings.LocalEmbeddings", _GoodEmbedder):
656
- asyncio.run(profile_rag.upsert_profile_chunk(
657
- "good_slug", {"age": 30, "dependents": "self"},
658
- ))
659
-
660
- self.assertEqual(captured.get("ids"), ["profile_good_slug"])
661
- self.assertEqual(len(captured["embeddings"][0]), 384)
662
-
663
-
664
- if __name__ == "__main__":
665
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_profile_recall_session_isolation.py DELETED
@@ -1,289 +0,0 @@
1
- """Regression tests — fresh no-cookie session must NOT inherit a stranger's
2
- profile via a weak/shared name key (privacy audit, 2026-05-16).
3
-
4
- AUDIT FINDING
5
- -------------
6
- A live test opened a FRESH browser context with zero cookies and was still
7
- greeted "Welcome back, Rahul! … Based on our last conversation…" — the
8
- backend restored a prior on-disk profile into a brand-new session.
9
-
10
- ROOT CAUSE
11
- ----------
12
- The turn-1 recall path was keyed on the user-STATED NAME, not the session:
13
-
14
- single_brain.handle_turn (turn 1)
15
- -> profile_persistence.try_recall_by_name(session, name)
16
- -> session_state.rehydrate_by_name(session, name)
17
- -> profile_store.load_profile(name) # <-- name-slug keyed file
18
- + a directory scan that returned ANY persona-id file whose stored
19
- display-name matched the slug.
20
-
21
- So a second real user on a shared browser/IP — or anyone who simply states
22
- a common first name ("I'm Rahul") — was AUTO-MERGED a stranger's captured
23
- profile and greeted "Welcome back" with no confirmation.
24
-
25
- INTENDED SAFE DESIGN (KI-196 / ADR-041, specced but never wired)
26
- ----------------------------------------------------------------
27
- `SessionState.pending_profile_recall` — a name match is STAGED, not merged.
28
- The profile is only applied after an EXPLICIT user affirmation. A fresh
29
- session is NEVER silently greeted with a stored stranger profile.
30
-
31
- These tests pin:
32
-
33
- 1. try_recall_by_name does NOT auto-merge a stored profile into a fresh
34
- session; it stages it on `session.pending_profile_recall`.
35
- 2. After staging, `session.profile` still has NO recalled fields → the
36
- "Welcome back" / is_returning_user signal stays False.
37
- 3. An explicit AFFIRM (apply_pending_recall) merges the staged profile —
38
- legitimate returning-user continuity is preserved.
39
- 4. An explicit DENY discards the staged profile and leaves the session
40
- blank.
41
- 5. Same-session continuity (slots captured within THIS conversation)
42
- is untouched — only cross-session NAME recall is gated.
43
- 6. load_profile no longer leaks a stranger's persona-id file via the
44
- display-name directory scan (cross-identity leak vector removed).
45
-
46
- Run:
47
- cd /Users/rohitsar/Developer/Insurance\\ Sales\\ Bot
48
- PYTHONPATH=$PWD .venv/bin/python -m pytest \\
49
- tests/test_profile_recall_session_isolation.py -v
50
- """
51
-
52
- from __future__ import annotations
53
-
54
- import json
55
- import sys
56
- import tempfile
57
- import unittest
58
- import uuid
59
- from pathlib import Path
60
- from unittest import mock
61
-
62
- _REPO_ROOT = Path(__file__).resolve().parent.parent
63
- if str(_REPO_ROOT) not in sys.path:
64
- sys.path.insert(0, str(_REPO_ROOT))
65
-
66
- from backend.needs_finder import Profile # noqa: E402
67
-
68
-
69
- def _write_stored_profile(profiles_dir: Path, slug: str, prof_fields: dict,
70
- *, display: str, persona_id: str | None = None):
71
- """Write a profile JSON the way profile_store.save_profile would."""
72
- profiles_dir.mkdir(parents=True, exist_ok=True)
73
- fname = f"{persona_id}.json" if persona_id else f"{slug}.json"
74
- payload = {
75
- "name_display": display,
76
- "name_slug": slug,
77
- "persona_id": persona_id,
78
- "profile": {**{f.name: getattr(Profile(), f.name)
79
- for f in Profile.__dataclass_fields__.values()},
80
- **prof_fields},
81
- "first_seen": "2026-05-15T00:00:00Z",
82
- "last_seen": "2026-05-15T00:00:00Z",
83
- "sessions": ["stranger_sess_1"],
84
- }
85
- (profiles_dir / fname).write_text(json.dumps(payload, indent=2, default=str))
86
-
87
-
88
- class _ProfilesDirMixin(unittest.TestCase):
89
- """Point profile_store at an isolated temp profiles dir for the test."""
90
-
91
- def setUp(self):
92
- self._tmp = tempfile.TemporaryDirectory()
93
- self._profiles_dir = Path(self._tmp.name) / "profiles"
94
- self._profiles_dir.mkdir(parents=True, exist_ok=True)
95
- # profile_store reads module-level _PROFILES_DIR for every path op.
96
- import backend.profile_store as ps
97
- self._ps = ps
98
- self._patch = mock.patch.object(ps, "_PROFILES_DIR", self._profiles_dir)
99
- self._patch.start()
100
-
101
- def tearDown(self):
102
- self._patch.stop()
103
- self._tmp.cleanup()
104
-
105
-
106
- class TestFreshSessionDoesNotInheritStrangerProfile(_ProfilesDirMixin):
107
- """Core privacy guarantee."""
108
-
109
- def _fresh_session(self):
110
- from backend.session_state import SessionState
111
- return SessionState(session_id=f"fresh_{uuid.uuid4().hex[:8]}")
112
-
113
- def test_try_recall_by_name_does_not_auto_merge(self):
114
- """A stranger named 'Rahul' has a stored profile. A FRESH session
115
- states 'Rahul' on turn 1. The stored profile must NOT be merged
116
- into session.profile, and must NOT flag a returning user."""
117
- _write_stored_profile(
118
- self._profiles_dir, "rahul",
119
- {"name": "Rahul", "age": 41, "dependents": "self+spouse+kids",
120
- "location_tier": "metro", "income_band": "10L-25L"},
121
- display="Rahul",
122
- )
123
-
124
- from backend.profile_persistence import try_recall_by_name
125
-
126
- sess = self._fresh_session()
127
- result = try_recall_by_name(sess, "Rahul")
128
-
129
- # NOT auto-applied → caller must not treat as returning user.
130
- self.assertFalse(
131
- result,
132
- "REGRESSION (privacy): try_recall_by_name auto-merged a stored "
133
- "stranger profile into a fresh session. It must stage, not merge.",
134
- )
135
- # session.profile must still be blank — no leaked PII.
136
- self.assertIsNone(sess.profile.age)
137
- self.assertIsNone(sess.profile.dependents)
138
- self.assertIsNone(sess.profile.location_tier)
139
- self.assertIn(
140
- sess.profile.name, (None, ""),
141
- "REGRESSION (privacy): stranger name leaked onto fresh session.",
142
- )
143
-
144
- def test_match_is_staged_for_confirmation(self):
145
- """The match IS detected — it's staged on pending_profile_recall so
146
- the brain can ASK 'are you Rahul?' rather than silently greet."""
147
- _write_stored_profile(
148
- self._profiles_dir, "rahul",
149
- {"name": "Rahul", "age": 41, "location_tier": "metro"},
150
- display="Rahul",
151
- )
152
- from backend.profile_persistence import try_recall_by_name
153
-
154
- sess = self._fresh_session()
155
- try_recall_by_name(sess, "Rahul")
156
-
157
- self.assertIsNotNone(
158
- sess.pending_profile_recall,
159
- "Expected the name match to be STAGED on pending_profile_recall.",
160
- )
161
- self.assertEqual(sess.pending_profile_recall["name"], "Rahul")
162
- # Staged summary carries identity hints for the confirm prompt, but
163
- # they are NOT on the live profile.
164
- self.assertIn("summary", sess.pending_profile_recall)
165
- self.assertIsNone(sess.profile.age)
166
-
167
- def test_no_match_stages_nothing(self):
168
- """Distinct identity with no stored file → no staging, no leak
169
- (mirrors the Zenobia control from the audit)."""
170
- from backend.profile_persistence import try_recall_by_name
171
-
172
- sess = self._fresh_session()
173
- result = try_recall_by_name(sess, "Zenobia")
174
- self.assertFalse(result)
175
- self.assertIsNone(sess.pending_profile_recall)
176
- self.assertIsNone(sess.profile.age)
177
-
178
-
179
- class TestExplicitConfirmationFlow(_ProfilesDirMixin):
180
- """Legitimate returning-user continuity must still work — on opt-in."""
181
-
182
- def test_affirm_applies_staged_profile(self):
183
- _write_stored_profile(
184
- self._profiles_dir, "priya",
185
- {"name": "Priya", "age": 33, "dependents": "self+spouse",
186
- "location_tier": "tier1"},
187
- display="Priya",
188
- )
189
- from backend.profile_persistence import try_recall_by_name
190
- from backend.session_state import SessionState, apply_pending_recall
191
-
192
- sess = SessionState(session_id="ret_user_1")
193
- try_recall_by_name(sess, "Priya")
194
- self.assertIsNotNone(sess.pending_profile_recall)
195
-
196
- # User explicitly confirms "yes, that's me".
197
- applied = apply_pending_recall(sess, confirmed=True)
198
- self.assertTrue(applied)
199
- self.assertEqual(sess.profile.name, "Priya")
200
- self.assertEqual(sess.profile.age, 33)
201
- self.assertEqual(sess.profile.dependents, "self+spouse")
202
- self.assertEqual(sess.profile.location_tier, "tier1")
203
- # Staging cleared after apply.
204
- self.assertIsNone(sess.pending_profile_recall)
205
-
206
- def test_deny_discards_staged_profile(self):
207
- _write_stored_profile(
208
- self._profiles_dir, "rahul",
209
- {"name": "Rahul", "age": 41, "location_tier": "metro"},
210
- display="Rahul",
211
- )
212
- from backend.profile_persistence import try_recall_by_name
213
- from backend.session_state import SessionState, apply_pending_recall
214
-
215
- sess = SessionState(session_id="not_rahul_1")
216
- try_recall_by_name(sess, "Rahul")
217
- self.assertIsNotNone(sess.pending_profile_recall)
218
-
219
- # User says "no, I'm not Rahul" (or just continues with new facts).
220
- applied = apply_pending_recall(sess, confirmed=False)
221
- self.assertFalse(applied)
222
- self.assertIsNone(sess.pending_profile_recall)
223
- self.assertIsNone(sess.profile.age)
224
- self.assertIn(sess.profile.name, (None, ""))
225
-
226
-
227
- class TestSameSessionContinuityPreserved(_ProfilesDirMixin):
228
- """Slots captured within THIS conversation must never be gated/lost."""
229
-
230
- def test_in_conversation_capture_untouched(self):
231
- from backend.session_state import get_session, reset_session
232
-
233
- sid = f"same_sess_{uuid.uuid4().hex[:8]}"
234
- reset_session(sid)
235
- sess = get_session(sid)
236
- # Simulate brain_tools.save_profile_field within this conversation.
237
- sess.profile.name = "Anjali"
238
- sess.profile.age = 29
239
- sess.profile.location_tier = "tier2"
240
-
241
- # Same-session continuity is in-memory and must persist verbatim.
242
- again = get_session(sid)
243
- self.assertIs(again, sess)
244
- self.assertEqual(again.profile.name, "Anjali")
245
- self.assertEqual(again.profile.age, 29)
246
- self.assertEqual(again.profile.location_tier, "tier2")
247
- self.assertIsNone(again.pending_profile_recall)
248
- reset_session(sid)
249
-
250
-
251
- class TestLoadProfileNoCrossIdentityScan(_ProfilesDirMixin):
252
- """The display-name directory scan in load_profile was a pure leak
253
- vector — a fresh visitor stating a common first name pulled a random
254
- persona-id file. It must no longer cross identities."""
255
-
256
- def test_persona_id_file_not_returned_by_bare_name(self):
257
- # Stranger stored under a persona-id filename (different age/city),
258
- # NOT under the bare name slug.
259
- _write_stored_profile(
260
- self._profiles_dir, "rohit",
261
- {"name": "Rohit", "age": 52, "location_tier": "tier3"},
262
- display="Rohit", persona_id="deadbeef1234",
263
- )
264
- # No rohit.json (bare slug) exists.
265
- self.assertFalse((self._profiles_dir / "rohit.json").exists())
266
-
267
- loaded = self._ps.load_profile("Rohit") # no persona_id supplied
268
- self.assertIsNone(
269
- loaded,
270
- "REGRESSION (privacy): load_profile returned a stranger's "
271
- "persona-id-keyed profile via the display-name directory scan. "
272
- "Bare-name lookup must NOT cross persona identities.",
273
- )
274
-
275
- def test_exact_slug_file_still_loads(self):
276
- """Legitimate same-name slug file (the user's own) still resolves —
277
- we only removed the cross-persona scan, not the direct slug path."""
278
- _write_stored_profile(
279
- self._profiles_dir, "meera",
280
- {"name": "Meera", "age": 38},
281
- display="Meera",
282
- )
283
- loaded = self._ps.load_profile("Meera")
284
- self.assertIsNotNone(loaded)
285
- self.assertEqual(loaded.age, 38)
286
-
287
-
288
- if __name__ == "__main__":
289
- unittest.main(verbosity=2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_returning_user_recall_singlebrain.py DELETED
@@ -1,202 +0,0 @@
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
- # The apply turn MUST carry the PROFILE RESTORED directive so the
176
- # model does not re-ask the just-recalled slots (the live bug:
177
- # recall merged but turn-2 still asked income + pre-existing).
178
- self.assertIn("RETURNING USER CONFIRMED — PROFILE RESTORED",
179
- self.sys_prompts[-1])
180
- self.assertIn("Re-asking a RESTORED slot is a hard error",
181
- self.sys_prompts[-1])
182
-
183
- def test_no_keeps_session_blank(self):
184
- sess = SessionState(session_id=f"hti_{uuid.uuid4().hex[:8]}")
185
- _run(single_brain.handle_turn(sess, f"Hello, I am {self.name}"))
186
- self.assertTrue(sess.pending_profile_recall)
187
- r2 = _run(single_brain.handle_turn(sess, "no, I'm a new user"))
188
- self.assertFalse(r2.returning_user_recalled)
189
- self.assertIn(getattr(sess.profile, "age", None), (None, "", 0))
190
- self.assertIsNone(sess.pending_profile_recall)
191
-
192
- def test_unknown_name_normal_flow(self):
193
- sess = SessionState(session_id=f"hti_{uuid.uuid4().hex[:8]}")
194
- r1 = _run(single_brain.handle_turn(
195
- sess, "Hi, I'm Zzqxnobodyhasthisname"))
196
- self.assertIsNone(getattr(sess, "pending_profile_recall", None))
197
- self.assertFalse(r1.returning_user_recalled)
198
- self.assertNotIn("RETURNING-USER CHECK", self.sys_prompts[-1])
199
-
200
-
201
- if __name__ == "__main__":
202
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_session_no_disk_persistence.py CHANGED
@@ -9,10 +9,10 @@ side was:
9
  2. A privacy and operational liability (orphan files accumulate, schema
10
  drift hits old files, cross-session memory leak surface).
11
 
12
- KI-118 rip-out: session_state is in-memory only. Cross-session memory is
13
- strictly name-based (returning user provides their name fact_find brain
14
- captures it `rehydrate_by_name` pulls the named profile from
15
- `40-data/profiles/`).
16
 
17
  This test pins the contract: a session lifecycle (get, mutate, set_awaiting,
18
  record_answer, update_profile_field, reset) MUST NOT create ANY file under
 
9
  2. A privacy and operational liability (orphan files accumulate, schema
10
  drift hits old files, cross-session memory leak surface).
11
 
12
+ KI-118 rip-out: session_state is in-memory only. ADR-043 (2026-05-27)
13
+ went further and removed the cross-session name-keyed recall too, so
14
+ there is now no on-disk profile store of any kind — `40-data/profiles/`
15
+ is gone.
16
 
17
  This test pins the contract: a session lifecycle (get, mutate, set_awaiting,
18
  record_answer, update_profile_field, reset) MUST NOT create ANY file under