Spaces:
Sleeping
feat!: remove cross-session profile recall (ADR-043) — net −3700 LOC
Browse filesSessions 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 +0 -38
- 40-data/profiles/7f227bef08d1.json +0 -35
- 40-data/profiles/93b74ab9ae9f.json +0 -41
- 40-data/profiles/9b1da84bb052.json +0 -44
- 40-data/profiles/9f23a75fc19e.json +0 -35
- 40-data/profiles/README.md +0 -67
- 40-data/profiles/asha.json +0 -83
- 40-data/profiles/c7b4ef2af903.json +0 -46
- 40-data/profiles/ca28dcb8ed8b.json +0 -81
- 40-data/profiles/dd8cd6c71443.json +0 -34
- 40-data/profiles/f31d3896ff05.json +0 -36
- 40-data/profiles/rohit.json +0 -43
- 70-docs/60-decisions/ADR-041-session-profile-lifecycle.md +3 -2
- 70-docs/60-decisions/ADR-042-privacy-hardening-and-sticky-retry.md +2 -3
- 70-docs/60-decisions/ADR-043-remove-cross-session-recall.md +78 -0
- CLAUDE.md +7 -7
- README.md +87 -107
- backend/admin.py +115 -144
- backend/brain_tools.py +24 -27
- backend/main.py +23 -194
- backend/profile_persistence.py +0 -339
- backend/profile_rag.py +0 -241
- backend/profile_store.py +0 -394
- backend/session_state.py +43 -540
- backend/single_brain.py +29 -238
- frontend/src/lib/api.ts +3 -26
- tests/test_bug2526_recall_and_reconstruct.py +0 -224
- tests/test_bug45_chat_profile_persistence.py +0 -129
- tests/test_profile_rag_isolation.py +0 -665
- tests/test_profile_recall_session_isolation.py +0 -289
- tests/test_returning_user_recall_singlebrain.py +0 -202
- tests/test_session_no_disk_persistence.py +4 -4
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,9 +1,10 @@
|
|
| 1 |
# ADR-041 — Session & profile lifecycle (KI-196)
|
| 2 |
|
| 3 |
-
**Status:**
|
|
|
|
| 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 |
|
|
@@ -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:**
|
| 5 |
-
**
|
| 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 |
|
|
@@ -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.
|
|
@@ -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
|
| 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-
|
| 72 |
|
| 73 |
-
- **
|
| 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 |
-
- **
|
| 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
|
| 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
|
| 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 |
|
|
@@ -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"] -->
|
| 129 |
-
|
| 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 "not stated in the document" 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
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
| 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
|
| 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
|
| 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
|
| 287 |
-
P1["update_session_profile"]
|
| 288 |
-
P2["
|
| 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/<name>.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 -.->|"
|
| 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
|
| 335 |
-
linkStyle 19,20,21,22,23,24,25
|
| 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
|
| 350 |
-
- **4. Data layer.**
|
| 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
|
| 466 |
|
| 467 |
```mermaid
|
| 468 |
flowchart TB
|
| 469 |
-
A["user answers (chat or profile builder)"] --> SPF["save_profile_field →
|
| 470 |
-
SPF -->
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 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.**
|
| 488 |
-
|
| 489 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
|
| 491 |
**How it flows:**
|
| 492 |
|
| 493 |
-
- **Capture.** Every answer (chat or profile-builder) is
|
| 494 |
-
`save_profile_field`
|
| 495 |
-
|
| 496 |
-
|
| 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 |
-
**
|
| 518 |
-
|
| 519 |
-
`
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
*
|
| 523 |
-
|
| 524 |
-
`
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
-
|
| 548 |
|
| 549 |
-
All
|
| 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
|
| 828 |
|
| 829 |
```mermaid
|
| 830 |
sequenceDiagram
|
| 831 |
autonumber
|
| 832 |
participant U as User
|
|
|
|
| 833 |
participant B as 3b. LLM Brain
|
| 834 |
-
participant
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 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.**
|
|
|
|
|
|
|
|
|
|
|
|
|
| 852 |
|
| 853 |
**How it flows:**
|
| 854 |
|
| 855 |
-
- **
|
| 856 |
-
- **
|
| 857 |
-
- **
|
| 858 |
-
|
| 859 |
-
|
|
|
|
| 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 |
-
|
|
| 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
|
| 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 "not stated in the document" 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_*,
|
|
@@ -280,7 +280,7 @@ async def admin_usage(
|
|
| 280 |
|
| 281 |
|
| 282 |
# ---------------------------------------------------------------------------
|
| 283 |
-
# /api/admin/profiles —
|
| 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
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
}
|
| 300 |
"""
|
| 301 |
_check_admin(request, x_admin_password)
|
| 302 |
|
| 303 |
-
from backend import
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
|
| 311 |
|
| 312 |
# ---------------------------------------------------------------------------
|
| 313 |
-
#
|
| 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 |
-
#
|
| 321 |
-
#
|
|
|
|
| 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 |
-
"""
|
| 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 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 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 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
"
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
| 367 |
return {
|
| 368 |
"ok": True,
|
| 369 |
"event_type": event_type,
|
| 370 |
"policy_slug": body.policy_slug,
|
| 371 |
-
"count": len(
|
| 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 |
-
"""
|
| 1015 |
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
"""
|
| 1020 |
_check_admin(request, x_admin_password)
|
| 1021 |
-
|
| 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 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 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 |
-
"""
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
|
| 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 |
-
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
|
| 1116 |
-
|
| 1117 |
-
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
|
| 1123 |
-
|
| 1124 |
-
|
| 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_slug — bump 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 {
|
|
@@ -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 |
-
|
| 1625 |
-
|
| 1626 |
-
|
| 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
|
| 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 |
-
|
| 1641 |
-
|
| 1642 |
-
|
| 1643 |
-
|
| 1644 |
-
|
| 1645 |
-
|
| 1646 |
-
|
| 1647 |
-
|
| 1648 |
-
|
| 1649 |
-
|
| 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 |
-
"
|
|
|
|
| 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 |
|
|
@@ -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 |
-
#
|
| 426 |
-
#
|
| 427 |
-
#
|
| 428 |
-
#
|
| 429 |
-
#
|
| 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 |
-
#
|
| 1367 |
-
#
|
| 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 |
-
"""
|
| 2182 |
-
|
| 2183 |
-
|
| 2184 |
|
| 2185 |
-
|
| 2186 |
-
|
| 2187 |
-
|
| 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 |
-
#
|
| 2265 |
-
#
|
| 2266 |
-
|
| 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 |
-
#
|
| 2290 |
-
#
|
| 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 |
-
#
|
| 4788 |
-
#
|
| 4789 |
-
#
|
| 4790 |
-
#
|
| 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) ----
|
|
@@ -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 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 (
|
| 9 |
-
- In-memory dict ONLY. No disk persistence.
|
| 10 |
- Sessions are evicted from memory after `_TTL_SECONDS = 60 * 60` idle.
|
| 11 |
-
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
`40-data/profiles/
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
| 21 |
|
| 22 |
Public API:
|
| 23 |
get_session(session_id) -> SessionState
|
| 24 |
-
|
| 25 |
-
SessionState.profile, .asked, .awaiting (question id pending answer)
|
| 26 |
SessionState.set_awaiting(qid)
|
| 27 |
-
SessionState.
|
|
|
|
|
|
|
|
|
|
| 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
|
| 226 |
-
#
|
| 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
|
| 232 |
-
#
|
| 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"
|
| 634 |
-
KI-118 (2026-05-15)
|
| 635 |
-
is the only side effect.
|
| 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 |
-
"""
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 666 |
-
|
| 667 |
-
|
|
|
|
| 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
|
|
@@ -737,63 +737,15 @@ def _build_contents(
|
|
| 737 |
return out
|
| 738 |
|
| 739 |
|
| 740 |
-
#
|
| 741 |
-
#
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 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 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 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 +
|
| 924 |
-
+
|
| 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 |
-
#
|
| 1947 |
-
# 2026-05-
|
| 1948 |
-
#
|
| 1949 |
-
#
|
| 1950 |
-
#
|
| 1951 |
-
|
| 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 |
-
#
|
| 2587 |
-
#
|
| 2588 |
-
#
|
| 2589 |
-
#
|
| 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=
|
| 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 |
|
|
@@ -565,32 +565,9 @@ export async function getPredictedPremiumBand(
|
|
| 565 |
return resp.json();
|
| 566 |
}
|
| 567 |
|
| 568 |
-
//
|
| 569 |
-
//
|
| 570 |
-
//
|
| 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`, {
|
|
@@ -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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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.
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 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
|