Spaces:
Sleeping
refactor(session): KI-118 — rip out session_id concept, name-based profiles only
Browse files- drop disk persistence in session_state.py (40-data/sessions/<id>.json no longer written)
- profile_rag chunks keyed by name_slug instead of session_id
- retrieve() takes profile_name_slug not session_id
- frontend uses sessionStorage not localStorage so token clears on tab close
- anonymous sessions never write to Chroma — corruption surface eliminated
- KI-102/107/112/117 isolation scaffolding still in place but its threat model is now moot
- added rehydrate_by_name() for cross-session re-entry via name lookup
- tests flipped: profile-isolation now pins anonymous-no-write + name-A-vs-name-B
- added tests/test_session_no_disk_persistence.py to lock the no-disk contract
Rationale: insurance shoppers don't multi-session; cross-session memory
is name-based (returning user provides name → pull saved profile). The
session_id machinery was the root of the Chroma corruption fought
today (profile_anonymous dangling row).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/main.py +17 -2
- backend/orchestrator.py +64 -33
- backend/profile_rag.py +52 -52
- backend/session_state.py +85 -123
- frontend/src/app/page.tsx +12 -7
- rag/retrieve.py +28 -22
- tests/test_name_persistence.py +1 -1
- tests/test_profile_rag_isolation.py +149 -131
- tests/test_session_no_disk_persistence.py +128 -0
|
@@ -250,8 +250,16 @@ async def _startup_purge_dangling_profile_chunks():
|
|
| 250 |
metas = res.get("metadatas") or []
|
| 251 |
bad_ids: list[str] = []
|
| 252 |
for cid, meta in zip(ids, metas):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
sid = (meta or {}).get("session_id")
|
| 254 |
-
|
|
|
|
|
|
|
| 255 |
bad_ids.append(cid)
|
| 256 |
|
| 257 |
if bad_ids:
|
|
@@ -849,8 +857,15 @@ async def profile_update(req: ProfileUpdateRequest):
|
|
| 849 |
# Ingest the profile into the RAG store so the brain sees user context
|
| 850 |
# at retrieval time alongside policy + regulatory chunks. Fire-and-forget
|
| 851 |
# — a profile upsert failure shouldn't block the API response.
|
|
|
|
|
|
|
|
|
|
| 852 |
try:
|
| 853 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 854 |
except Exception as e:
|
| 855 |
print(f"[profile_rag] upsert failed for {req.session_id}: {type(e).__name__}: {e}")
|
| 856 |
|
|
|
|
| 250 |
metas = res.get("metadatas") or []
|
| 251 |
bad_ids: list[str] = []
|
| 252 |
for cid, meta in zip(ids, metas):
|
| 253 |
+
# KI-118 (2026-05-15) — profile chunks are now keyed by name_slug;
|
| 254 |
+
# accept EITHER a non-empty name_slug (new) OR a non-empty
|
| 255 |
+
# session_id (legacy KI-102 row) as proof-of-ownership. A profile
|
| 256 |
+
# chunk with neither key is the dangling-row corruption case and
|
| 257 |
+
# must be purged.
|
| 258 |
+
slug = (meta or {}).get("name_slug")
|
| 259 |
sid = (meta or {}).get("session_id")
|
| 260 |
+
slug_ok = isinstance(slug, str) and slug.strip()
|
| 261 |
+
sid_ok = isinstance(sid, str) and sid.strip()
|
| 262 |
+
if not (slug_ok or sid_ok):
|
| 263 |
bad_ids.append(cid)
|
| 264 |
|
| 265 |
if bad_ids:
|
|
|
|
| 857 |
# Ingest the profile into the RAG store so the brain sees user context
|
| 858 |
# at retrieval time alongside policy + regulatory chunks. Fire-and-forget
|
| 859 |
# — a profile upsert failure shouldn't block the API response.
|
| 860 |
+
# KI-118 (2026-05-15) — gated on a known name; anonymous saves don't
|
| 861 |
+
# write to Chroma. The chunk is keyed by canonical name slug, not the
|
| 862 |
+
# session_id which is now opaque/in-memory.
|
| 863 |
try:
|
| 864 |
+
if p.name:
|
| 865 |
+
from backend.profile_store import _normalise_name
|
| 866 |
+
name_slug = _normalise_name(p.name)
|
| 867 |
+
if name_slug:
|
| 868 |
+
await upsert_profile_chunk(name_slug, profile_dict)
|
| 869 |
except Exception as e:
|
| 870 |
print(f"[profile_rag] upsert failed for {req.session_id}: {type(e).__name__}: {e}")
|
| 871 |
|
|
@@ -548,26 +548,30 @@ async def handle_turn(
|
|
| 548 |
session.free_form_session = True
|
| 549 |
session._flush()
|
| 550 |
|
| 551 |
-
# KI-
|
| 552 |
-
#
|
| 553 |
-
#
|
| 554 |
-
|
|
|
|
| 555 |
try:
|
| 556 |
from backend.profile_rag import upsert_profile_chunk
|
|
|
|
| 557 |
p = session.profile
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
|
|
|
|
|
|
| 571 |
except Exception as e:
|
| 572 |
logging.warning(
|
| 573 |
"fact_find_brain profile-chunk upsert failed (session=%s): %s: %s",
|
|
@@ -577,9 +581,18 @@ async def handle_turn(
|
|
| 577 |
# KI-040 / KI-062 — named-profile persistence preserved. If the brain
|
| 578 |
# captured (or already has) a name on the profile, write the merged
|
| 579 |
# profile to disk so the next visit can welcome the user back.
|
|
|
|
|
|
|
| 580 |
if session.profile.name:
|
| 581 |
try:
|
| 582 |
from backend.profile_store import save_profile
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 583 |
save_profile(session.profile.name, session.profile, session_id=session_id)
|
| 584 |
except Exception:
|
| 585 |
pass
|
|
@@ -707,23 +720,29 @@ async def handle_turn(
|
|
| 707 |
else:
|
| 708 |
session.update_profile_field(field_name, new_value)
|
| 709 |
profile_updates_applied[field_name] = new_value
|
| 710 |
-
# Re-upsert profile chunk so retrieval sees fresh profile THIS turn
|
|
|
|
|
|
|
| 711 |
try:
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 727 |
except Exception as e:
|
| 728 |
# KI-005 — log profile-chunk upsert failures so we can see
|
| 729 |
# when Chroma is locking or schema-drifting. The chat still
|
|
@@ -764,10 +783,22 @@ async def handle_turn(
|
|
| 764 |
if intent in ("recommendation", "comparison") and not policy_filter_ids:
|
| 765 |
effective_top_k = max(top_k, 12)
|
| 766 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 767 |
chunks: list[RetrievedChunk] = await retrieve(
|
| 768 |
query=user_text,
|
| 769 |
top_k=effective_top_k,
|
| 770 |
policy_ids=policy_filter_ids,
|
|
|
|
| 771 |
session_id=session_id,
|
| 772 |
)
|
| 773 |
context_str = format_for_llm_context(chunks)
|
|
|
|
| 548 |
session.free_form_session = True
|
| 549 |
session._flush()
|
| 550 |
|
| 551 |
+
# KI-118 (2026-05-15) — profile-chunk upsert is gated on a known
|
| 552 |
+
# name. Anonymous sessions never write to Chroma; the corruption
|
| 553 |
+
# surface (profile_anonymous dangling row) is eliminated. Named
|
| 554 |
+
# users get their chunk keyed by canonical name slug, not session_id.
|
| 555 |
+
if fact_find_profile_updates and session.profile.name:
|
| 556 |
try:
|
| 557 |
from backend.profile_rag import upsert_profile_chunk
|
| 558 |
+
from backend.profile_store import _normalise_name
|
| 559 |
p = session.profile
|
| 560 |
+
slug = _normalise_name(p.name or "")
|
| 561 |
+
if slug:
|
| 562 |
+
await upsert_profile_chunk(slug, {
|
| 563 |
+
"age": p.age,
|
| 564 |
+
"dependents": p.dependents,
|
| 565 |
+
"income_band": p.income_band,
|
| 566 |
+
"existing_cover_inr": p.existing_cover_inr,
|
| 567 |
+
"primary_goal": p.primary_goal,
|
| 568 |
+
"location_tier": p.location_tier,
|
| 569 |
+
"parents_to_insure": p.parents_to_insure,
|
| 570 |
+
"parents_age_max": p.parents_age_max,
|
| 571 |
+
"parents_has_ped": p.parents_has_ped,
|
| 572 |
+
"budget_band": p.budget_band,
|
| 573 |
+
"health_conditions": p.health_conditions,
|
| 574 |
+
})
|
| 575 |
except Exception as e:
|
| 576 |
logging.warning(
|
| 577 |
"fact_find_brain profile-chunk upsert failed (session=%s): %s: %s",
|
|
|
|
| 581 |
# KI-040 / KI-062 — named-profile persistence preserved. If the brain
|
| 582 |
# captured (or already has) a name on the profile, write the merged
|
| 583 |
# profile to disk so the next visit can welcome the user back.
|
| 584 |
+
# KI-118 — also trigger one-shot rehydrate when name was newly captured
|
| 585 |
+
# this turn so any stored profile from a prior visit gets merged in.
|
| 586 |
if session.profile.name:
|
| 587 |
try:
|
| 588 |
from backend.profile_store import save_profile
|
| 589 |
+
# If name was newly captured this turn AND there's a stored
|
| 590 |
+
# profile under that name, merge in the stored fields BEFORE
|
| 591 |
+
# writing back (so we don't immediately overwrite the stored
|
| 592 |
+
# snapshot with a partial new one).
|
| 593 |
+
if "name" in fact_find_profile_updates:
|
| 594 |
+
from backend.session_state import rehydrate_by_name
|
| 595 |
+
rehydrate_by_name(session, session.profile.name)
|
| 596 |
save_profile(session.profile.name, session.profile, session_id=session_id)
|
| 597 |
except Exception:
|
| 598 |
pass
|
|
|
|
| 720 |
else:
|
| 721 |
session.update_profile_field(field_name, new_value)
|
| 722 |
profile_updates_applied[field_name] = new_value
|
| 723 |
+
# Re-upsert profile chunk so retrieval sees fresh profile THIS turn.
|
| 724 |
+
# KI-118 (2026-05-15) — gated on a known name; anonymous sessions
|
| 725 |
+
# never write to Chroma.
|
| 726 |
try:
|
| 727 |
+
if session.profile.name:
|
| 728 |
+
from backend.profile_rag import upsert_profile_chunk
|
| 729 |
+
from backend.profile_store import _normalise_name
|
| 730 |
+
slug = _normalise_name(session.profile.name)
|
| 731 |
+
if slug:
|
| 732 |
+
profile_dict_for_chunk = {
|
| 733 |
+
"age": session.profile.age,
|
| 734 |
+
"dependents": session.profile.dependents,
|
| 735 |
+
"income_band": session.profile.income_band,
|
| 736 |
+
"existing_cover_inr": session.profile.existing_cover_inr,
|
| 737 |
+
"primary_goal": session.profile.primary_goal,
|
| 738 |
+
"location_tier": session.profile.location_tier,
|
| 739 |
+
"parents_to_insure": session.profile.parents_to_insure,
|
| 740 |
+
"parents_age_max": session.profile.parents_age_max,
|
| 741 |
+
"parents_has_ped": session.profile.parents_has_ped,
|
| 742 |
+
"budget_band": session.profile.budget_band,
|
| 743 |
+
"health_conditions": session.profile.health_conditions,
|
| 744 |
+
}
|
| 745 |
+
await upsert_profile_chunk(slug, profile_dict_for_chunk)
|
| 746 |
except Exception as e:
|
| 747 |
# KI-005 — log profile-chunk upsert failures so we can see
|
| 748 |
# when Chroma is locking or schema-drifting. The chat still
|
|
|
|
| 783 |
if intent in ("recommendation", "comparison") and not policy_filter_ids:
|
| 784 |
effective_top_k = max(top_k, 12)
|
| 785 |
|
| 786 |
+
# KI-118 (2026-05-15) — profile chunks are keyed by name_slug, not
|
| 787 |
+
# session_id. Pass the slug only when the live session has a captured
|
| 788 |
+
# name; anonymous sessions get retrieval without a profile-boost pass.
|
| 789 |
+
profile_slug_for_retrieve: Optional[str] = None
|
| 790 |
+
if session.profile.name:
|
| 791 |
+
try:
|
| 792 |
+
from backend.profile_store import _normalise_name
|
| 793 |
+
profile_slug_for_retrieve = _normalise_name(session.profile.name) or None
|
| 794 |
+
except Exception:
|
| 795 |
+
profile_slug_for_retrieve = None
|
| 796 |
+
|
| 797 |
chunks: list[RetrievedChunk] = await retrieve(
|
| 798 |
query=user_text,
|
| 799 |
top_k=effective_top_k,
|
| 800 |
policy_ids=policy_filter_ids,
|
| 801 |
+
profile_name_slug=profile_slug_for_retrieve,
|
| 802 |
session_id=session_id,
|
| 803 |
)
|
| 804 |
context_str = format_for_llm_context(chunks)
|
|
@@ -1,27 +1,26 @@
|
|
| 1 |
"""Customer-profile-as-RAG layer.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
profiles for RAG, complementing the brain alongside policy + regulation".
|
| 15 |
|
| 16 |
Public API:
|
| 17 |
profile_to_chunk_text(profile_dict) -> str
|
| 18 |
Render the structured profile as a single English paragraph.
|
| 19 |
-
upsert_profile_chunk(
|
| 20 |
-
Ingest / update the chunk for this
|
| 21 |
-
remove_profile_chunk(
|
| 22 |
-
Optional cleanup
|
| 23 |
|
| 24 |
-
Storage model — one chunk per
|
| 25 |
Profile chunks live in the SAME collection as policies so retrieval can
|
| 26 |
naturally surface them when scoring policies for the user.
|
| 27 |
"""
|
|
@@ -122,11 +121,16 @@ def _get_collection():
|
|
| 122 |
)
|
| 123 |
|
| 124 |
|
| 125 |
-
async def upsert_profile_chunk(
|
| 126 |
"""Embed the profile paragraph and store as a single chunk in Chroma.
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
Idempotent — calling this on every profile update is safe; existing
|
| 129 |
-
chunks for the same
|
| 130 |
"""
|
| 131 |
from backend.providers.local_embeddings import LocalEmbeddings
|
| 132 |
|
|
@@ -134,20 +138,19 @@ async def upsert_profile_chunk(session_id: str, profile_dict: dict) -> None:
|
|
| 134 |
if not text or len(text) < 30:
|
| 135 |
return
|
| 136 |
|
| 137 |
-
# KI-112 (2026-05-15) — input guard 1:
|
| 138 |
-
# Pre-fix, a missing/empty
|
| 139 |
-
# with `policy_id` colliding across all anonymous sessions
|
| 140 |
-
# initial KI-102 deploy wrote a `profile_anonymous` chunk WITHOUT a
|
| 141 |
-
# session_id metadata field
|
| 142 |
-
#
|
| 143 |
-
#
|
| 144 |
-
#
|
| 145 |
-
|
| 146 |
-
if not isinstance(session_id, str) or not session_id.strip():
|
| 147 |
_log.warning(
|
| 148 |
-
"profile_rag.upsert_profile_chunk: refusing to write —
|
| 149 |
"must be a non-empty str, got %r. Profile not persisted.",
|
| 150 |
-
|
| 151 |
)
|
| 152 |
return
|
| 153 |
|
|
@@ -167,17 +170,17 @@ async def upsert_profile_chunk(session_id: str, profile_dict: dict) -> None:
|
|
| 167 |
):
|
| 168 |
_log.warning(
|
| 169 |
"profile_rag.upsert_profile_chunk: refusing to write — embedding "
|
| 170 |
-
"shape invalid for
|
| 171 |
"got type=%s len=%s). Profile not persisted.",
|
| 172 |
-
|
| 173 |
(len(vec) if hasattr(vec, "__len__") else "?"),
|
| 174 |
)
|
| 175 |
return
|
| 176 |
|
| 177 |
coll = _get_collection()
|
| 178 |
-
chunk_id = f"profile_{
|
| 179 |
|
| 180 |
-
# Replace any existing chunk for this
|
| 181 |
try:
|
| 182 |
coll.delete(where={"policy_id": chunk_id})
|
| 183 |
except Exception as e:
|
|
@@ -203,49 +206,46 @@ async def upsert_profile_chunk(session_id: str, profile_dict: dict) -> None:
|
|
| 203 |
metadatas=[{
|
| 204 |
"policy_id": chunk_id,
|
| 205 |
"insurer_slug": "profile",
|
| 206 |
-
"policy_name": f"User profile (
|
| 207 |
"doc_type": "profile",
|
| 208 |
-
# KI-
|
| 209 |
-
#
|
| 210 |
-
|
| 211 |
-
# had no session_id metadata and the main retrieval pass had no
|
| 212 |
-
# doc_type filter, so chunks from sessions smokeA_1, ki100_ve, etc.
|
| 213 |
-
# surfaced as cosine matches in session smokeB_B4's context —
|
| 214 |
-
# leaking one user's profile facts into another user's reply.
|
| 215 |
-
"session_id": session_id,
|
| 216 |
"source_url": "",
|
| 217 |
"page_start": 0,
|
| 218 |
"page_end": 0,
|
| 219 |
"chunk_idx": 0,
|
| 220 |
-
"local_path": "in-memory
|
| 221 |
}],
|
| 222 |
)
|
| 223 |
except Exception as e:
|
| 224 |
_log.warning(
|
| 225 |
"profile_rag.upsert_profile_chunk: add(id=%s) failed: %s: %s — "
|
| 226 |
-
"user reply will proceed without per-
|
| 227 |
"next profile change will retry.",
|
| 228 |
chunk_id, type(e).__name__, str(e)[:200],
|
| 229 |
)
|
| 230 |
|
| 231 |
|
| 232 |
-
def remove_profile_chunk(
|
| 233 |
-
"""Optional cleanup.
|
|
|
|
|
|
|
| 234 |
try:
|
| 235 |
coll = _get_collection()
|
| 236 |
-
coll.delete(where={"policy_id": f"profile_{
|
| 237 |
except Exception:
|
| 238 |
pass
|
| 239 |
|
| 240 |
|
| 241 |
-
def upsert_profile_chunk_sync(
|
| 242 |
"""Sync wrapper for callers that aren't async — schedules + waits."""
|
| 243 |
try:
|
| 244 |
loop = asyncio.get_event_loop()
|
| 245 |
if loop.is_running():
|
| 246 |
# Already inside an async context — schedule on the loop
|
| 247 |
-
asyncio.ensure_future(upsert_profile_chunk(
|
| 248 |
return
|
| 249 |
except RuntimeError:
|
| 250 |
pass
|
| 251 |
-
asyncio.run(upsert_profile_chunk(
|
|
|
|
| 1 |
"""Customer-profile-as-RAG layer.
|
| 2 |
|
| 3 |
+
KI-118 (2026-05-15) — profile chunks are now keyed by `name_slug` (the
|
| 4 |
+
canonicalised user name), NOT by session_id. Only NAMED users ever get
|
| 5 |
+
embedded; anonymous sessions never write to Chroma. This eliminates the
|
| 6 |
+
corruption surface that the session_id-keyed chunks introduced (a missing
|
| 7 |
+
session_id metadata field on the legacy `profile_anonymous` row poisoned
|
| 8 |
+
every subsequent retrieval query — see KI-117 boot cleanup).
|
| 9 |
+
|
| 10 |
+
At retrieval time, `rag/retrieve.py::retrieve(..., profile_name_slug=...)`
|
| 11 |
+
boosts the user's profile chunk so the LLM sees the user's context inline
|
| 12 |
+
with the retrieved policy/regulatory text — answers become personalised at
|
| 13 |
+
the BRAIN level, not just at scorecard re-weighting.
|
|
|
|
| 14 |
|
| 15 |
Public API:
|
| 16 |
profile_to_chunk_text(profile_dict) -> str
|
| 17 |
Render the structured profile as a single English paragraph.
|
| 18 |
+
upsert_profile_chunk(name_slug, profile_dict, embedder) -> None
|
| 19 |
+
Ingest / update the chunk for this named user in Chroma.
|
| 20 |
+
remove_profile_chunk(name_slug) -> None
|
| 21 |
+
Optional cleanup.
|
| 22 |
|
| 23 |
+
Storage model — one chunk per name_slug. Replaced on each profile update.
|
| 24 |
Profile chunks live in the SAME collection as policies so retrieval can
|
| 25 |
naturally surface them when scoring policies for the user.
|
| 26 |
"""
|
|
|
|
| 121 |
)
|
| 122 |
|
| 123 |
|
| 124 |
+
async def upsert_profile_chunk(name_slug: str, profile_dict: dict) -> None:
|
| 125 |
"""Embed the profile paragraph and store as a single chunk in Chroma.
|
| 126 |
|
| 127 |
+
KI-118 (2026-05-15) — keyed by `name_slug` (canonical user name) instead
|
| 128 |
+
of `session_id`. Only NAMED users ever get embedded — anonymous chats
|
| 129 |
+
never write to Chroma, which eliminates the corruption surface that the
|
| 130 |
+
session_id keying introduced.
|
| 131 |
+
|
| 132 |
Idempotent — calling this on every profile update is safe; existing
|
| 133 |
+
chunks for the same name_slug get replaced.
|
| 134 |
"""
|
| 135 |
from backend.providers.local_embeddings import LocalEmbeddings
|
| 136 |
|
|
|
|
| 138 |
if not text or len(text) < 30:
|
| 139 |
return
|
| 140 |
|
| 141 |
+
# KI-112 (2026-05-15) / KI-118 — input guard 1: name_slug must be a
|
| 142 |
+
# non-empty str. Pre-fix, a missing/empty key caused upsert under id
|
| 143 |
+
# "profile_" with `policy_id` colliding across all anonymous sessions;
|
| 144 |
+
# the initial KI-102 deploy wrote a `profile_anonymous` chunk WITHOUT a
|
| 145 |
+
# session_id metadata field that poisoned every subsequent query whose
|
| 146 |
+
# `where` clause referenced session_id. Anonymous users no longer reach
|
| 147 |
+
# this function at all (the orchestrator gates on
|
| 148 |
+
# `session.profile.name` before calling) — this guard is belt-and-braces.
|
| 149 |
+
if not isinstance(name_slug, str) or not name_slug.strip():
|
|
|
|
| 150 |
_log.warning(
|
| 151 |
+
"profile_rag.upsert_profile_chunk: refusing to write — name_slug "
|
| 152 |
"must be a non-empty str, got %r. Profile not persisted.",
|
| 153 |
+
name_slug,
|
| 154 |
)
|
| 155 |
return
|
| 156 |
|
|
|
|
| 170 |
):
|
| 171 |
_log.warning(
|
| 172 |
"profile_rag.upsert_profile_chunk: refusing to write — embedding "
|
| 173 |
+
"shape invalid for name_slug=%s (expected %d-dim list of floats, "
|
| 174 |
"got type=%s len=%s). Profile not persisted.",
|
| 175 |
+
name_slug, expected_dim, type(vec).__name__,
|
| 176 |
(len(vec) if hasattr(vec, "__len__") else "?"),
|
| 177 |
)
|
| 178 |
return
|
| 179 |
|
| 180 |
coll = _get_collection()
|
| 181 |
+
chunk_id = f"profile_{name_slug}"
|
| 182 |
|
| 183 |
+
# Replace any existing chunk for this name
|
| 184 |
try:
|
| 185 |
coll.delete(where={"policy_id": chunk_id})
|
| 186 |
except Exception as e:
|
|
|
|
| 206 |
metadatas=[{
|
| 207 |
"policy_id": chunk_id,
|
| 208 |
"insurer_slug": "profile",
|
| 209 |
+
"policy_name": f"User profile ({name_slug[:16]})",
|
| 210 |
"doc_type": "profile",
|
| 211 |
+
# KI-118 (2026-05-15) — stamp name_slug instead of session_id.
|
| 212 |
+
# The retrieve path filters profile chunks via this field.
|
| 213 |
+
"name_slug": name_slug,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
"source_url": "",
|
| 215 |
"page_start": 0,
|
| 216 |
"page_end": 0,
|
| 217 |
"chunk_idx": 0,
|
| 218 |
+
"local_path": "in-memory named-profile chunk",
|
| 219 |
}],
|
| 220 |
)
|
| 221 |
except Exception as e:
|
| 222 |
_log.warning(
|
| 223 |
"profile_rag.upsert_profile_chunk: add(id=%s) failed: %s: %s — "
|
| 224 |
+
"user reply will proceed without per-user profile context; "
|
| 225 |
"next profile change will retry.",
|
| 226 |
chunk_id, type(e).__name__, str(e)[:200],
|
| 227 |
)
|
| 228 |
|
| 229 |
|
| 230 |
+
def remove_profile_chunk(name_slug: str) -> None:
|
| 231 |
+
"""Optional cleanup. KI-118 — keyed by name_slug."""
|
| 232 |
+
if not name_slug:
|
| 233 |
+
return
|
| 234 |
try:
|
| 235 |
coll = _get_collection()
|
| 236 |
+
coll.delete(where={"policy_id": f"profile_{name_slug}"})
|
| 237 |
except Exception:
|
| 238 |
pass
|
| 239 |
|
| 240 |
|
| 241 |
+
def upsert_profile_chunk_sync(name_slug: str, profile_dict: dict) -> None:
|
| 242 |
"""Sync wrapper for callers that aren't async — schedules + waits."""
|
| 243 |
try:
|
| 244 |
loop = asyncio.get_event_loop()
|
| 245 |
if loop.is_running():
|
| 246 |
# Already inside an async context — schedule on the loop
|
| 247 |
+
asyncio.ensure_future(upsert_profile_chunk(name_slug, profile_dict))
|
| 248 |
return
|
| 249 |
except RuntimeError:
|
| 250 |
pass
|
| 251 |
+
asyncio.run(upsert_profile_chunk(name_slug, profile_dict))
|
|
@@ -1,44 +1,43 @@
|
|
| 1 |
-
"""Per-session state for multi-turn fact-find continuity
|
| 2 |
|
| 3 |
The orchestrator was originally stateless — each user turn re-classified
|
| 4 |
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
|
| 10 |
-
-
|
| 11 |
-
|
| 12 |
-
profile
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
daily by a cron, or on startup). 30-day disk TTL.
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
| 21 |
|
| 22 |
Public API:
|
| 23 |
get_session(session_id) -> SessionState
|
|
|
|
| 24 |
SessionState.profile, .asked, .awaiting (question id pending answer)
|
| 25 |
SessionState.set_awaiting(qid)
|
| 26 |
-
SessionState.record_answer(qid, raw_answer) → also clears awaiting
|
| 27 |
"""
|
| 28 |
|
| 29 |
from __future__ import annotations
|
| 30 |
|
| 31 |
-
import
|
| 32 |
import time
|
| 33 |
-
from dataclasses import dataclass, field
|
| 34 |
-
from pathlib import Path
|
| 35 |
from threading import Lock
|
| 36 |
from typing import Optional
|
| 37 |
|
| 38 |
from backend.needs_finder import Profile, record_answer
|
| 39 |
|
| 40 |
-
|
| 41 |
-
_DATA_ROOT = Path(__file__).resolve().parent.parent / "40-data" / "sessions"
|
| 42 |
|
| 43 |
|
| 44 |
@dataclass
|
|
@@ -50,33 +49,16 @@ class SessionState:
|
|
| 50 |
last_touched: float = field(default_factory=time.time)
|
| 51 |
|
| 52 |
def _flush(self) -> None:
|
| 53 |
-
"""
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
"profile": asdict(self.profile),
|
| 60 |
-
"awaiting_question_id": self.awaiting_question_id,
|
| 61 |
-
"free_form_session": self.free_form_session,
|
| 62 |
-
"last_touched": self.last_touched,
|
| 63 |
-
}
|
| 64 |
-
tmp = target.with_suffix(".json.tmp")
|
| 65 |
-
tmp.write_text(json.dumps(payload, indent=2))
|
| 66 |
-
tmp.replace(target)
|
| 67 |
-
except Exception as e:
|
| 68 |
-
# KI-002 — Log silent failures so HF Space logs reveal them.
|
| 69 |
-
# Don't crash the request — disk hiccups shouldn't kill chat.
|
| 70 |
-
import logging
|
| 71 |
-
logging.warning(
|
| 72 |
-
"session_state flush failed for %s: %s: %s",
|
| 73 |
-
self.session_id, type(e).__name__, str(e)[:200],
|
| 74 |
-
)
|
| 75 |
|
| 76 |
def set_awaiting(self, question_id: Optional[str]) -> None:
|
| 77 |
self.awaiting_question_id = question_id
|
| 78 |
self.last_touched = time.time()
|
| 79 |
-
self._flush()
|
| 80 |
|
| 81 |
def record_user_answer(self, raw_answer: str) -> Optional[str]:
|
| 82 |
"""If we're awaiting an answer, parse + store it. Returns the answered question_id."""
|
|
@@ -86,122 +68,102 @@ class SessionState:
|
|
| 86 |
record_answer(self.profile, qid, raw_answer)
|
| 87 |
self.awaiting_question_id = None
|
| 88 |
self.last_touched = time.time()
|
| 89 |
-
self._flush()
|
| 90 |
return qid
|
| 91 |
|
| 92 |
def update_profile_field(self, name: str, value) -> None:
|
| 93 |
-
"""Set a Profile attribute
|
| 94 |
if hasattr(self.profile, name):
|
| 95 |
setattr(self.profile, name, value)
|
| 96 |
self.last_touched = time.time()
|
| 97 |
-
self._flush()
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
def _load_from_disk(session_id: str) -> Optional[SessionState]:
|
| 101 |
-
"""Rehydrate from 40-data/sessions/<id>.json if it exists."""
|
| 102 |
-
target = _DATA_ROOT / f"{session_id}.json"
|
| 103 |
-
if not target.exists():
|
| 104 |
-
return None
|
| 105 |
-
try:
|
| 106 |
-
raw = json.loads(target.read_text())
|
| 107 |
-
raw_profile_dict = raw.get("profile", {}) or {}
|
| 108 |
-
# Profile may have new fields added since this file was written —
|
| 109 |
-
# filter to only what the current Profile dataclass accepts so we
|
| 110 |
-
# never crash on schema drift.
|
| 111 |
-
valid_fields = {f for f in Profile.__dataclass_fields__.keys()}
|
| 112 |
-
dropped = set(raw_profile_dict.keys()) - valid_fields
|
| 113 |
-
if dropped:
|
| 114 |
-
# KI-095 — log schema-drift drops so silent data loss is visible.
|
| 115 |
-
import logging
|
| 116 |
-
logging.warning(
|
| 117 |
-
"session_state load_from_disk dropped %d unknown profile keys for %s: %s",
|
| 118 |
-
len(dropped), session_id, sorted(dropped),
|
| 119 |
-
)
|
| 120 |
-
prof_dict = {k: v for k, v in raw_profile_dict.items() if k in valid_fields}
|
| 121 |
-
return SessionState(
|
| 122 |
-
session_id=raw["session_id"],
|
| 123 |
-
profile=Profile(**prof_dict),
|
| 124 |
-
awaiting_question_id=raw.get("awaiting_question_id"),
|
| 125 |
-
free_form_session=bool(raw.get("free_form_session", False)),
|
| 126 |
-
last_touched=float(raw.get("last_touched", time.time())),
|
| 127 |
-
)
|
| 128 |
-
except Exception as e:
|
| 129 |
-
# KI-003 — Log schema-drift / corrupt-JSON failures. The user will
|
| 130 |
-
# get a fresh session either way, but the log lets us detect when
|
| 131 |
-
# the Profile dataclass evolves in a way that breaks old sessions.
|
| 132 |
-
import logging
|
| 133 |
-
logging.warning(
|
| 134 |
-
"session_state load_from_disk failed for %s: %s: %s",
|
| 135 |
-
session_id, type(e).__name__, str(e)[:200],
|
| 136 |
-
)
|
| 137 |
-
return None
|
| 138 |
|
| 139 |
|
| 140 |
_sessions: dict[str, SessionState] = {}
|
| 141 |
_lock = Lock()
|
| 142 |
-
_TTL_SECONDS = 60 * 60 # 1h idle → evict from in-memory cache
|
| 143 |
-
_DISK_TTL_SECONDS = 30 * 86400 # 30 days → delete the JSON file
|
| 144 |
|
| 145 |
|
| 146 |
def get_session(session_id: str) -> SessionState:
|
| 147 |
with _lock:
|
| 148 |
now = time.time()
|
| 149 |
-
# Evict idle entries from the hot cache
|
| 150 |
to_kill = [k for k, v in _sessions.items() if now - v.last_touched > _TTL_SECONDS]
|
| 151 |
for k in to_kill:
|
| 152 |
del _sessions[k]
|
| 153 |
if session_id in _sessions:
|
| 154 |
return _sessions[session_id]
|
| 155 |
-
#
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
_sessions[session_id] = rehydrated
|
| 160 |
return _sessions[session_id]
|
| 161 |
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
def set_free_form(session_id: str, free_form: bool = True) -> None:
|
| 164 |
s = get_session(session_id)
|
| 165 |
s.free_form_session = free_form
|
| 166 |
s.awaiting_question_id = None
|
| 167 |
s.last_touched = time.time()
|
| 168 |
-
s._flush()
|
| 169 |
|
| 170 |
|
| 171 |
def reset_session(session_id: str) -> bool:
|
| 172 |
-
"""Delete a session — evict from in-memory cache
|
| 173 |
Returns True if anything was actually deleted.
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
| 176 |
with _lock:
|
| 177 |
if session_id in _sessions:
|
| 178 |
del _sessions[session_id]
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
if target.exists():
|
| 182 |
-
try:
|
| 183 |
-
target.unlink()
|
| 184 |
-
deleted_any = True
|
| 185 |
-
except Exception as e:
|
| 186 |
-
import logging
|
| 187 |
-
logging.warning(
|
| 188 |
-
"reset_session unlink failed for %s: %s: %s",
|
| 189 |
-
session_id, type(e).__name__, str(e)[:200],
|
| 190 |
-
)
|
| 191 |
-
return deleted_any
|
| 192 |
|
| 193 |
|
| 194 |
def purge_old_files() -> int:
|
| 195 |
-
"""
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
for f in _DATA_ROOT.glob("*.json"):
|
| 201 |
-
try:
|
| 202 |
-
if (now - f.stat().st_mtime) > _DISK_TTL_SECONDS:
|
| 203 |
-
f.unlink()
|
| 204 |
-
purged += 1
|
| 205 |
-
except Exception:
|
| 206 |
-
continue
|
| 207 |
-
return purged
|
|
|
|
| 1 |
+
"""Per-session state for multi-turn fact-find continuity (in-memory only).
|
| 2 |
|
| 3 |
The orchestrator was originally stateless — each user turn re-classified
|
| 4 |
intent from scratch. That broke fact-find: after the bot asked "what's
|
| 5 |
your age?", the user's "39 years old" wasn't matched by intent_classifier
|
| 6 |
and got routed to RAG retrieval (which then refused). This module fixes that.
|
| 7 |
|
| 8 |
+
Persistence model (KI-118, 2026-05-15):
|
| 9 |
+
- In-memory dict ONLY. No disk persistence.
|
| 10 |
+
- Sessions are evicted from memory after `_TTL_SECONDS = 60 * 60` idle.
|
| 11 |
+
- Cross-session memory is name-based: when the user provides a name,
|
| 12 |
+
`rehydrate_by_name(session, name)` pulls the named profile from
|
| 13 |
+
`backend.profile_store.load_profile(name)` (canonical JSON at
|
| 14 |
+
`40-data/profiles/<persona_id>.json`).
|
| 15 |
+
- Anonymous sessions live only in-memory and never leave a trace on disk.
|
|
|
|
| 16 |
|
| 17 |
+
Rationale: insurance shoppers don't multi-session within a browsing window.
|
| 18 |
+
Cross-session memory is name-based. The previous disk-write side
|
| 19 |
+
(`40-data/sessions/<session_id>.json`) was the root of the Chroma
|
| 20 |
+
corruption fought 2026-05-14/15 (profile_anonymous dangling row).
|
| 21 |
|
| 22 |
Public API:
|
| 23 |
get_session(session_id) -> SessionState
|
| 24 |
+
rehydrate_by_name(session, name) -> bool # KI-118 cross-session re-entry
|
| 25 |
SessionState.profile, .asked, .awaiting (question id pending answer)
|
| 26 |
SessionState.set_awaiting(qid)
|
| 27 |
+
SessionState.record_answer(qid, raw_answer) → also clears awaiting
|
| 28 |
"""
|
| 29 |
|
| 30 |
from __future__ import annotations
|
| 31 |
|
| 32 |
+
import logging
|
| 33 |
import time
|
| 34 |
+
from dataclasses import dataclass, field
|
|
|
|
| 35 |
from threading import Lock
|
| 36 |
from typing import Optional
|
| 37 |
|
| 38 |
from backend.needs_finder import Profile, record_answer
|
| 39 |
|
| 40 |
+
_log = logging.getLogger(__name__)
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
@dataclass
|
|
|
|
| 49 |
last_touched: float = field(default_factory=time.time)
|
| 50 |
|
| 51 |
def _flush(self) -> None:
|
| 52 |
+
"""No-op since KI-118 (2026-05-15). Disk persistence was removed; the
|
| 53 |
+
in-memory dict is now the only store. We keep the method so existing
|
| 54 |
+
callers (orchestrator + fact_find_brain + tests) don't have to change
|
| 55 |
+
their write paths.
|
| 56 |
+
"""
|
| 57 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
def set_awaiting(self, question_id: Optional[str]) -> None:
|
| 60 |
self.awaiting_question_id = question_id
|
| 61 |
self.last_touched = time.time()
|
|
|
|
| 62 |
|
| 63 |
def record_user_answer(self, raw_answer: str) -> Optional[str]:
|
| 64 |
"""If we're awaiting an answer, parse + store it. Returns the answered question_id."""
|
|
|
|
| 68 |
record_answer(self.profile, qid, raw_answer)
|
| 69 |
self.awaiting_question_id = None
|
| 70 |
self.last_touched = time.time()
|
|
|
|
| 71 |
return qid
|
| 72 |
|
| 73 |
def update_profile_field(self, name: str, value) -> None:
|
| 74 |
+
"""Set a Profile attribute. Used by /api/profile."""
|
| 75 |
if hasattr(self.profile, name):
|
| 76 |
setattr(self.profile, name, value)
|
| 77 |
self.last_touched = time.time()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
|
| 80 |
_sessions: dict[str, SessionState] = {}
|
| 81 |
_lock = Lock()
|
| 82 |
+
_TTL_SECONDS = 60 * 60 # 1h idle → evict from in-memory cache
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
def get_session(session_id: str) -> SessionState:
|
| 86 |
with _lock:
|
| 87 |
now = time.time()
|
| 88 |
+
# Evict idle entries from the hot cache
|
| 89 |
to_kill = [k for k, v in _sessions.items() if now - v.last_touched > _TTL_SECONDS]
|
| 90 |
for k in to_kill:
|
| 91 |
del _sessions[k]
|
| 92 |
if session_id in _sessions:
|
| 93 |
return _sessions[session_id]
|
| 94 |
+
# KI-118 — no disk lookup; fresh sessions start blank. Cross-session
|
| 95 |
+
# rehydration happens via rehydrate_by_name() when the user provides
|
| 96 |
+
# their name to the fact_find brain.
|
| 97 |
+
_sessions[session_id] = SessionState(session_id=session_id)
|
|
|
|
| 98 |
return _sessions[session_id]
|
| 99 |
|
| 100 |
|
| 101 |
+
def rehydrate_by_name(session: SessionState, name: str) -> bool:
|
| 102 |
+
"""KI-118 (2026-05-15) — cross-session re-entry point.
|
| 103 |
+
|
| 104 |
+
When a user provides their name (captured in the fact_find brain), look
|
| 105 |
+
up the named profile via `backend.profile_store.load_profile(name)` and
|
| 106 |
+
populate the in-memory session with the stored profile data.
|
| 107 |
+
|
| 108 |
+
Returns True if a stored profile was found and merged; False otherwise.
|
| 109 |
+
Failures are logged but never raise — a fresh chat must always proceed.
|
| 110 |
+
|
| 111 |
+
Merge semantics: stored fields override current session-state fields IF
|
| 112 |
+
the current field is empty. Already-captured fields on the live session
|
| 113 |
+
win (the user may have corrected themselves mid-conversation).
|
| 114 |
+
"""
|
| 115 |
+
if not name or not name.strip():
|
| 116 |
+
return False
|
| 117 |
+
try:
|
| 118 |
+
from backend.profile_store import load_profile
|
| 119 |
+
stored = load_profile(name)
|
| 120 |
+
if stored is None:
|
| 121 |
+
return False
|
| 122 |
+
# Merge: stored value fills any empty slot on the live profile.
|
| 123 |
+
for fld in Profile.__dataclass_fields__.keys():
|
| 124 |
+
try:
|
| 125 |
+
cur = getattr(session.profile, fld, None)
|
| 126 |
+
if cur in (None, "", []):
|
| 127 |
+
new = getattr(stored, fld, None)
|
| 128 |
+
if new not in (None, "", []):
|
| 129 |
+
setattr(session.profile, fld, new)
|
| 130 |
+
except Exception:
|
| 131 |
+
continue
|
| 132 |
+
session.last_touched = time.time()
|
| 133 |
+
return True
|
| 134 |
+
except Exception as e:
|
| 135 |
+
_log.warning(
|
| 136 |
+
"rehydrate_by_name failed (name=%r): %s: %s",
|
| 137 |
+
name, type(e).__name__, str(e)[:200],
|
| 138 |
+
)
|
| 139 |
+
return False
|
| 140 |
+
|
| 141 |
+
|
| 142 |
def set_free_form(session_id: str, free_form: bool = True) -> None:
|
| 143 |
s = get_session(session_id)
|
| 144 |
s.free_form_session = free_form
|
| 145 |
s.awaiting_question_id = None
|
| 146 |
s.last_touched = time.time()
|
|
|
|
| 147 |
|
| 148 |
|
| 149 |
def reset_session(session_id: str) -> bool:
|
| 150 |
+
"""Delete a session — evict from in-memory cache.
|
| 151 |
Returns True if anything was actually deleted.
|
| 152 |
+
|
| 153 |
+
KI-020 (2026-05-14) — backs the user-facing "Clear chat / start fresh" toggle.
|
| 154 |
+
KI-118 (2026-05-15) — no disk file to remove anymore; in-memory eviction
|
| 155 |
+
is the only side effect. Returns True iff the session id was live.
|
| 156 |
+
"""
|
| 157 |
with _lock:
|
| 158 |
if session_id in _sessions:
|
| 159 |
del _sessions[session_id]
|
| 160 |
+
return True
|
| 161 |
+
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
|
| 164 |
def purge_old_files() -> int:
|
| 165 |
+
"""KI-118 (2026-05-15) — no-op. Disk persistence was removed; there are
|
| 166 |
+
no files to purge. Kept as a stub so any existing scheduled-task caller
|
| 167 |
+
(cron / startup hook) doesn't crash on attribute miss.
|
| 168 |
+
"""
|
| 169 |
+
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -83,7 +83,9 @@ export default function Page() {
|
|
| 83 |
// turn) — drives the score-gate on marketplace cards + detail modal.
|
| 84 |
useEffect(() => {
|
| 85 |
if (typeof window !== "undefined" && sessionId) {
|
| 86 |
-
|
|
|
|
|
|
|
| 87 |
getProfileCompleteness(sessionId)
|
| 88 |
.then(setProfileCompleteness)
|
| 89 |
.catch(() => setProfileCompleteness(null));
|
|
@@ -104,7 +106,10 @@ export default function Page() {
|
|
| 104 |
localStorage.removeItem("insurance_chat_messages");
|
| 105 |
}
|
| 106 |
}
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
| 108 |
if (savedSession) setSessionId(savedSession);
|
| 109 |
}, []);
|
| 110 |
|
|
@@ -250,12 +255,12 @@ export default function Page() {
|
|
| 250 |
if (res.session_id) {
|
| 251 |
setSessionId(res.session_id);
|
| 252 |
if (typeof window !== "undefined") {
|
| 253 |
-
|
| 254 |
}
|
| 255 |
} else {
|
| 256 |
setSessionId(undefined);
|
| 257 |
if (typeof window !== "undefined") {
|
| 258 |
-
|
| 259 |
}
|
| 260 |
}
|
| 261 |
} catch (e) {
|
|
@@ -263,7 +268,7 @@ export default function Page() {
|
|
| 263 |
// Even if backend failed, drop client-side session so next message starts fresh
|
| 264 |
setSessionId(undefined);
|
| 265 |
if (typeof window !== "undefined") {
|
| 266 |
-
|
| 267 |
}
|
| 268 |
}
|
| 269 |
}
|
|
@@ -971,7 +976,7 @@ function ProfileBuilderPanel({
|
|
| 971 |
if (!sid) {
|
| 972 |
sid = `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
| 973 |
setSessionId(sid);
|
| 974 |
-
if (typeof window !== "undefined")
|
| 975 |
}
|
| 976 |
try {
|
| 977 |
const resp = await postProfileUpdate({
|
|
@@ -2619,7 +2624,7 @@ function PolicyDetailModal({ policy, onClose }: { policy: MarketplacePolicy; onC
|
|
| 2619 |
// Profile completeness gates whether we render the per-user grade.
|
| 2620 |
// Below threshold: show universal grade only (insurer-quality-led) with a
|
| 2621 |
// CTA to complete the profile.
|
| 2622 |
-
const sid = typeof window !== "undefined" ?
|
| 2623 |
getProfileCompleteness(sid).then(setCompleteness).catch(() => setCompleteness(null));
|
| 2624 |
}, [policy.policy_id, policy.insurer_slug]);
|
| 2625 |
const isPersonalized = completeness?.is_personalized === true;
|
|
|
|
| 83 |
// turn) — drives the score-gate on marketplace cards + detail modal.
|
| 84 |
useEffect(() => {
|
| 85 |
if (typeof window !== "undefined" && sessionId) {
|
| 86 |
+
// KI-118 (2026-05-15) — sessionStorage clears on tab close so no
|
| 87 |
+
// persistent ghost session. Within-tab refresh still rehydrates.
|
| 88 |
+
sessionStorage.setItem("insurance_session_id", sessionId);
|
| 89 |
getProfileCompleteness(sessionId)
|
| 90 |
.then(setProfileCompleteness)
|
| 91 |
.catch(() => setProfileCompleteness(null));
|
|
|
|
| 106 |
localStorage.removeItem("insurance_chat_messages");
|
| 107 |
}
|
| 108 |
}
|
| 109 |
+
// KI-118 (2026-05-15) — sessionStorage clears on tab close. Cross-tab
|
| 110 |
+
// re-entry is name-based: when the user provides their name in chat,
|
| 111 |
+
// the backend pulls the named profile via profile_store.load_profile().
|
| 112 |
+
const savedSession = sessionStorage.getItem("insurance_session_id");
|
| 113 |
if (savedSession) setSessionId(savedSession);
|
| 114 |
}, []);
|
| 115 |
|
|
|
|
| 255 |
if (res.session_id) {
|
| 256 |
setSessionId(res.session_id);
|
| 257 |
if (typeof window !== "undefined") {
|
| 258 |
+
sessionStorage.setItem("insurance_session_id", res.session_id);
|
| 259 |
}
|
| 260 |
} else {
|
| 261 |
setSessionId(undefined);
|
| 262 |
if (typeof window !== "undefined") {
|
| 263 |
+
sessionStorage.removeItem("insurance_session_id");
|
| 264 |
}
|
| 265 |
}
|
| 266 |
} catch (e) {
|
|
|
|
| 268 |
// Even if backend failed, drop client-side session so next message starts fresh
|
| 269 |
setSessionId(undefined);
|
| 270 |
if (typeof window !== "undefined") {
|
| 271 |
+
sessionStorage.removeItem("insurance_session_id");
|
| 272 |
}
|
| 273 |
}
|
| 274 |
}
|
|
|
|
| 976 |
if (!sid) {
|
| 977 |
sid = `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
| 978 |
setSessionId(sid);
|
| 979 |
+
if (typeof window !== "undefined") sessionStorage.setItem("insurance_session_id", sid);
|
| 980 |
}
|
| 981 |
try {
|
| 982 |
const resp = await postProfileUpdate({
|
|
|
|
| 2624 |
// Profile completeness gates whether we render the per-user grade.
|
| 2625 |
// Below threshold: show universal grade only (insurer-quality-led) with a
|
| 2626 |
// CTA to complete the profile.
|
| 2627 |
+
const sid = typeof window !== "undefined" ? sessionStorage.getItem("insurance_session_id") || undefined : undefined;
|
| 2628 |
getProfileCompleteness(sid).then(setCompleteness).catch(() => setCompleteness(null));
|
| 2629 |
}, [policy.policy_id, policy.insurer_slug]);
|
| 2630 |
const isPersonalized = completeness?.is_personalized === true;
|
|
@@ -198,6 +198,7 @@ async def retrieve(
|
|
| 198 |
policy_ids: Optional[list[str]] = None,
|
| 199 |
insurer_slugs: Optional[list[str]] = None,
|
| 200 |
embedder: Optional[VoyageEmbeddings] = None,
|
|
|
|
| 201 |
session_id: Optional[str] = None,
|
| 202 |
) -> list[RetrievedChunk]:
|
| 203 |
"""Embed the query and return top-k most similar chunks.
|
|
@@ -210,6 +211,12 @@ async def retrieve(
|
|
| 210 |
merges the top 3 of those (score-boosted ×1.2) into the result set.
|
| 211 |
This ensures the brain sees regulatory ceilings even when policy
|
| 212 |
chunks dominate raw cosine.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
"""
|
| 214 |
# KI-034 — short-circuit identical-query re-asks via the LRU cache.
|
| 215 |
cache_key = _cache_key(query, top_k, policy_ids, insurer_slugs)
|
|
@@ -295,29 +302,28 @@ async def retrieve(
|
|
| 295 |
effective_top_k, where, type(e).__name__, str(e)[:300],
|
| 296 |
)
|
| 297 |
|
| 298 |
-
# Profile boost pass —
|
| 299 |
-
#
|
| 300 |
-
#
|
| 301 |
-
#
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
#
|
| 306 |
-
#
|
| 307 |
-
# written under a shared id) still can't leak another user's
|
| 308 |
-
# profile into this session's context.
|
| 309 |
#
|
| 310 |
-
# KI-107 (2026-05-15) — route through _safe_collection_get because
|
| 311 |
-
#
|
| 312 |
-
# states cause collection.get(ids=[missing_id]) to
|
| 313 |
-
# returning empty lists. Before this fix the bare
|
| 314 |
-
# masked the failure, the orchestrator's plan-executor
|
| 315 |
-
# AttributeError on the swallowed-None path, and the
|
| 316 |
-
# 500 "Error executing plan: Internal error: Error
|
|
|
|
| 317 |
prof_res = _safe_collection_get(
|
| 318 |
collection,
|
| 319 |
ids=[profile_chunk_id],
|
| 320 |
-
where={"
|
| 321 |
include=["documents", "metadatas"],
|
| 322 |
)
|
| 323 |
# prof_res is None on exception, a dict (possibly with empty lists) on success.
|
|
@@ -326,9 +332,9 @@ async def retrieve(
|
|
| 326 |
metadatas = prof_res.get("metadatas") or []
|
| 327 |
p_doc = documents[0] if documents else ""
|
| 328 |
p_meta = metadatas[0] if metadatas else {}
|
| 329 |
-
# Triple-check:
|
| 330 |
-
#
|
| 331 |
-
if p_doc and p_meta.get("
|
| 332 |
# Profile gets max score (1.0) so it always tops the context
|
| 333 |
profile_chunk = _build_chunk(profile_chunk_id, p_doc, p_meta, 1.0)
|
| 334 |
# Prepend; trim to top_k so we keep budget
|
|
|
|
| 198 |
policy_ids: Optional[list[str]] = None,
|
| 199 |
insurer_slugs: Optional[list[str]] = None,
|
| 200 |
embedder: Optional[VoyageEmbeddings] = None,
|
| 201 |
+
profile_name_slug: Optional[str] = None,
|
| 202 |
session_id: Optional[str] = None,
|
| 203 |
) -> list[RetrievedChunk]:
|
| 204 |
"""Embed the query and return top-k most similar chunks.
|
|
|
|
| 211 |
merges the top 3 of those (score-boosted ×1.2) into the result set.
|
| 212 |
This ensures the brain sees regulatory ceilings even when policy
|
| 213 |
chunks dominate raw cosine.
|
| 214 |
+
|
| 215 |
+
KI-118 (2026-05-15) — profile chunks are keyed by `profile_name_slug`
|
| 216 |
+
(canonical user name) instead of session_id. Only named users have a
|
| 217 |
+
profile chunk to boost. `session_id` is retained for the per-session
|
| 218 |
+
quarantine (user-uploaded PDF) lookup but is no longer used for
|
| 219 |
+
profile boosting.
|
| 220 |
"""
|
| 221 |
# KI-034 — short-circuit identical-query re-asks via the LRU cache.
|
| 222 |
cache_key = _cache_key(query, top_k, policy_ids, insurer_slugs)
|
|
|
|
| 302 |
effective_top_k, where, type(e).__name__, str(e)[:300],
|
| 303 |
)
|
| 304 |
|
| 305 |
+
# Profile boost pass — KI-118 (2026-05-15). Profile chunks are now
|
| 306 |
+
# keyed by `profile_name_slug` (canonical user name), not session_id.
|
| 307 |
+
# The orchestrator passes the slug only when the live session has a
|
| 308 |
+
# known name; anonymous chats skip this branch entirely.
|
| 309 |
+
if profile_name_slug:
|
| 310 |
+
profile_chunk_id = f"profile_{profile_name_slug}"
|
| 311 |
+
# KI-118 — filter by BOTH id AND name_slug metadata so any future
|
| 312 |
+
# ID collision (or migration-era chunk written under a shared id)
|
| 313 |
+
# still can't leak the wrong profile into this user's context.
|
|
|
|
|
|
|
| 314 |
#
|
| 315 |
+
# KI-107 (2026-05-15) — route through _safe_collection_get because
|
| 316 |
+
# first-time named users (no profile saved yet) and certain transient
|
| 317 |
+
# Chroma sqlite states cause collection.get(ids=[missing_id]) to
|
| 318 |
+
# raise instead of returning empty lists. Before this fix the bare
|
| 319 |
+
# `except: pass` masked the failure, the orchestrator's plan-executor
|
| 320 |
+
# still saw an AttributeError on the swallowed-None path, and the
|
| 321 |
+
# user got HTTP 500 "Error executing plan: Internal error: Error
|
| 322 |
+
# finding id".
|
| 323 |
prof_res = _safe_collection_get(
|
| 324 |
collection,
|
| 325 |
ids=[profile_chunk_id],
|
| 326 |
+
where={"name_slug": profile_name_slug},
|
| 327 |
include=["documents", "metadatas"],
|
| 328 |
)
|
| 329 |
# prof_res is None on exception, a dict (possibly with empty lists) on success.
|
|
|
|
| 332 |
metadatas = prof_res.get("metadatas") or []
|
| 333 |
p_doc = documents[0] if documents else ""
|
| 334 |
p_meta = metadatas[0] if metadatas else {}
|
| 335 |
+
# Triple-check: refuse the row unless its metadata.name_slug
|
| 336 |
+
# matches the caller's slug. Belt + suspenders + parachute.
|
| 337 |
+
if p_doc and p_meta.get("name_slug") == profile_name_slug:
|
| 338 |
# Profile gets max score (1.0) so it always tops the context
|
| 339 |
profile_chunk = _build_chunk(profile_chunk_id, p_doc, p_meta, 1.0)
|
| 340 |
# Prepend; trim to top_k so we keep budget
|
|
@@ -73,7 +73,7 @@ def _stub_brain_pick(intent: str, language: str):
|
|
| 73 |
return BrainPick(_StubProvider(), f"stub::{intent}")
|
| 74 |
|
| 75 |
|
| 76 |
-
async def _stub_retrieve(query, top_k=5, policy_ids=None, session_id=None):
|
| 77 |
return [] # empty context — perfectly valid for these tests
|
| 78 |
|
| 79 |
|
|
|
|
| 73 |
return BrainPick(_StubProvider(), f"stub::{intent}")
|
| 74 |
|
| 75 |
|
| 76 |
+
async def _stub_retrieve(query, top_k=5, policy_ids=None, profile_name_slug=None, session_id=None):
|
| 77 |
return [] # empty context — perfectly valid for these tests
|
| 78 |
|
| 79 |
|
|
@@ -1,31 +1,29 @@
|
|
| 1 |
-
"""Regression tests for KI-102
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
embedder and use chromadb's in-memory ephemeral client to verify the
|
| 28 |
-
retrieve path's filter behaviour end-to-end.
|
| 29 |
|
| 30 |
Run:
|
| 31 |
cd /Users/rohitsar/Developer/Insurance\\ Sales\\ Bot
|
|
@@ -68,7 +66,7 @@ def _make_ephemeral_collection():
|
|
| 68 |
|
| 69 |
class _StubEmbedder:
|
| 70 |
"""Deterministic 8-dim embedder so semantically-similar text gets
|
| 71 |
-
semantically-similar vectors. Two profile chunks (one
|
| 72 |
will end up with near-identical embeddings, which is exactly what
|
| 73 |
triggers the pre-fix leak in the wild."""
|
| 74 |
|
|
@@ -92,23 +90,24 @@ class _StubEmbedder:
|
|
| 92 |
|
| 93 |
|
| 94 |
# ---------------------------------------------------------------------------
|
| 95 |
-
# Test cases
|
| 96 |
# ---------------------------------------------------------------------------
|
| 97 |
|
| 98 |
|
| 99 |
class TestProfileIsolation(unittest.TestCase):
|
| 100 |
-
"""KI-
|
|
|
|
| 101 |
|
| 102 |
def setUp(self):
|
| 103 |
self.coll = _make_ephemeral_collection()
|
| 104 |
-
self.
|
| 105 |
-
self.
|
| 106 |
|
| 107 |
-
def _seed_profile(self,
|
| 108 |
-
"""Write a profile chunk for `
|
| 109 |
collection, mirroring what upsert_profile_chunk does in prod."""
|
| 110 |
vec = asyncio.run(_StubEmbedder().embed([text]))[0]
|
| 111 |
-
chunk_id = f"profile_{
|
| 112 |
self.coll.add(
|
| 113 |
ids=[chunk_id],
|
| 114 |
documents=[text],
|
|
@@ -116,9 +115,9 @@ class TestProfileIsolation(unittest.TestCase):
|
|
| 116 |
metadatas=[{
|
| 117 |
"policy_id": chunk_id,
|
| 118 |
"insurer_slug": "profile",
|
| 119 |
-
"policy_name": f"User profile (
|
| 120 |
"doc_type": "profile",
|
| 121 |
-
"
|
| 122 |
"source_url": "",
|
| 123 |
"page_start": 0,
|
| 124 |
"page_end": 0,
|
|
@@ -147,7 +146,7 @@ class TestProfileIsolation(unittest.TestCase):
|
|
| 147 |
}],
|
| 148 |
)
|
| 149 |
|
| 150 |
-
def _run_retrieve(self, query: str,
|
| 151 |
"""Invoke rag.retrieve.retrieve() with the test collection +
|
| 152 |
stub embedder patched in."""
|
| 153 |
from rag import retrieve as retrieve_mod
|
|
@@ -158,46 +157,46 @@ class TestProfileIsolation(unittest.TestCase):
|
|
| 158 |
query=query,
|
| 159 |
top_k=top_k,
|
| 160 |
embedder=_StubEmbedder(),
|
| 161 |
-
|
| 162 |
))
|
| 163 |
|
| 164 |
# -----------------------------------------------------------------
|
| 165 |
-
# CASE 1 — pre-fix leak repro:
|
| 166 |
-
# in
|
| 167 |
# -----------------------------------------------------------------
|
| 168 |
-
def
|
| 169 |
self._seed_profile(
|
| 170 |
-
self.
|
| 171 |
"USER CONTEXT — facts about the person asking this question:\n"
|
| 172 |
"- Age: 45 years.\n- User's own pre-existing conditions: diabetes, hypertension.",
|
| 173 |
)
|
| 174 |
self._seed_profile(
|
| 175 |
-
self.
|
| 176 |
"USER CONTEXT — facts about the person asking this question:\n"
|
| 177 |
"- Age: 28 years.\n- First-time buyer; no existing health insurance.",
|
| 178 |
)
|
| 179 |
# Add a generic policy chunk so there's something to retrieve.
|
| 180 |
self._seed_policy("hdfc_ergo_optima_secure_v1", "Standard health policy text about waiting periods.")
|
| 181 |
|
| 182 |
-
#
|
| 183 |
chunks = self._run_retrieve(
|
| 184 |
query="what plan suits my age and dependents",
|
| 185 |
-
|
| 186 |
)
|
| 187 |
|
| 188 |
-
leaked = [c for c in chunks if c.policy_id == f"profile_{self.
|
| 189 |
self.assertEqual(
|
| 190 |
leaked, [],
|
| 191 |
-
f"PRIVACY LEAK:
|
| 192 |
f"retrieval. Found: {[c.policy_id for c in chunks]}",
|
| 193 |
)
|
| 194 |
|
| 195 |
# -----------------------------------------------------------------
|
| 196 |
-
# CASE 2 —
|
| 197 |
# -----------------------------------------------------------------
|
| 198 |
-
def
|
| 199 |
self._seed_profile(
|
| 200 |
-
self.
|
| 201 |
"USER CONTEXT — facts about the person asking this question:\n"
|
| 202 |
"- Age: 28 years.",
|
| 203 |
)
|
|
@@ -205,62 +204,62 @@ class TestProfileIsolation(unittest.TestCase):
|
|
| 205 |
|
| 206 |
chunks = self._run_retrieve(
|
| 207 |
query="recommend a plan for me",
|
| 208 |
-
|
| 209 |
)
|
| 210 |
-
own = [c for c in chunks if c.policy_id == f"profile_{self.
|
| 211 |
self.assertEqual(
|
| 212 |
len(own), 1,
|
| 213 |
-
f"
|
| 214 |
)
|
| 215 |
|
| 216 |
# -----------------------------------------------------------------
|
| 217 |
# CASE 3 — multiple foreign profiles + one own profile. Only the
|
| 218 |
-
# current
|
| 219 |
# -----------------------------------------------------------------
|
| 220 |
def test_three_foreign_profiles_none_leak(self):
|
| 221 |
-
for
|
| 222 |
self._seed_profile(
|
| 223 |
-
|
| 224 |
f"USER CONTEXT — facts about the person asking this question:\n"
|
| 225 |
-
f"- Age: {30 + len(
|
| 226 |
)
|
| 227 |
self._seed_profile(
|
| 228 |
-
self.
|
| 229 |
"USER CONTEXT — facts about the person asking this question:\n- Age: 28 years.",
|
| 230 |
)
|
| 231 |
self._seed_policy("test_policy_2", "Generic policy text.")
|
| 232 |
|
| 233 |
chunks = self._run_retrieve(
|
| 234 |
query="my age health conditions dependents",
|
| 235 |
-
|
| 236 |
top_k=10,
|
| 237 |
)
|
| 238 |
profile_pids = [c.policy_id for c in chunks if c.doc_type == "profile"]
|
| 239 |
-
# Only ONE profile chunk may appear, and it must be
|
| 240 |
self.assertEqual(
|
| 241 |
-
profile_pids, [f"profile_{self.
|
| 242 |
f"Foreign profile leaked. profile chunks in result: {profile_pids}",
|
| 243 |
)
|
| 244 |
|
| 245 |
# -----------------------------------------------------------------
|
| 246 |
-
# CASE 4 — legacy chunk without
|
| 247 |
-
# if its id happens to match (defence-in-depth from KI-
|
| 248 |
# -----------------------------------------------------------------
|
| 249 |
-
def
|
| 250 |
-
# Write a chunk under id 'profile_<
|
| 251 |
-
#
|
| 252 |
vec = asyncio.run(_StubEmbedder().embed(["USER CONTEXT — legacy"]))[0]
|
| 253 |
-
chunk_id = f"profile_{self.
|
| 254 |
self.coll.add(
|
| 255 |
ids=[chunk_id],
|
| 256 |
-
documents=["USER CONTEXT — legacy row from before KI-
|
| 257 |
embeddings=[vec],
|
| 258 |
metadatas=[{
|
| 259 |
"policy_id": chunk_id,
|
| 260 |
"insurer_slug": "profile",
|
| 261 |
"policy_name": "legacy profile",
|
| 262 |
"doc_type": "profile",
|
| 263 |
-
# No '
|
| 264 |
"source_url": "",
|
| 265 |
"page_start": 0,
|
| 266 |
"page_end": 0,
|
|
@@ -271,14 +270,42 @@ class TestProfileIsolation(unittest.TestCase):
|
|
| 271 |
|
| 272 |
chunks = self._run_retrieve(
|
| 273 |
query="anything",
|
| 274 |
-
|
| 275 |
)
|
| 276 |
# Legacy chunk must be refused — the triple-check at retrieve's
|
| 277 |
-
# per-
|
| 278 |
legacy_hits = [c for c in chunks if c.policy_id == chunk_id]
|
| 279 |
self.assertEqual(
|
| 280 |
legacy_hits, [],
|
| 281 |
-
"Legacy profile chunk without
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
f"Got: {[c.policy_id for c in chunks]}",
|
| 283 |
)
|
| 284 |
|
|
@@ -287,19 +314,18 @@ class TestProfileIsolation(unittest.TestCase):
|
|
| 287 |
# KI-107 (2026-05-15) — graceful handling of Chroma get(ids=[missing]).
|
| 288 |
# C5 port-in persona saw 3× HTTP 500 "Error executing plan: Internal error:
|
| 289 |
# Error finding id". After KI-102 added the per-session profile-chunk
|
| 290 |
-
# lookup, retrieve() runs collection.get(ids=[f"profile_{
|
| 291 |
-
# query — and for new
|
| 292 |
-
# sqlite states, that call can raise.
|
| 293 |
-
#
|
| 294 |
-
#
|
| 295 |
-
# must NEVER raise and must NEVER return a profile chunk.
|
| 296 |
# ---------------------------------------------------------------------------
|
| 297 |
|
| 298 |
|
| 299 |
class TestRetrieveSurvivesMissingProfileId(unittest.TestCase):
|
| 300 |
-
"""KI-107 — retrieve(
|
| 301 |
-
(1)
|
| 302 |
-
(2) Chroma.get raising on the per-
|
| 303 |
(3) Chroma.get returning empty lists for missing ids."""
|
| 304 |
|
| 305 |
def setUp(self):
|
|
@@ -324,7 +350,7 @@ class TestRetrieveSurvivesMissingProfileId(unittest.TestCase):
|
|
| 324 |
}],
|
| 325 |
)
|
| 326 |
|
| 327 |
-
def _run_retrieve(self, query: str,
|
| 328 |
from rag import retrieve as retrieve_mod
|
| 329 |
retrieve_mod._RETRIEVAL_CACHE.clear()
|
| 330 |
with mock.patch.object(retrieve_mod, "get_collection", return_value=self.coll):
|
|
@@ -332,12 +358,12 @@ class TestRetrieveSurvivesMissingProfileId(unittest.TestCase):
|
|
| 332 |
query=query,
|
| 333 |
top_k=top_k,
|
| 334 |
embedder=_StubEmbedder(),
|
| 335 |
-
|
| 336 |
))
|
| 337 |
|
| 338 |
-
def
|
| 339 |
-
"""
|
| 340 |
-
without raising. Pre-KI-107 this surfaced as HTTP 500 "Error
|
| 341 |
finding id" because get(ids=[missing]) raised + bare except: pass
|
| 342 |
let downstream code index into a None result."""
|
| 343 |
self._seed_one_policy()
|
|
@@ -345,14 +371,14 @@ class TestRetrieveSurvivesMissingProfileId(unittest.TestCase):
|
|
| 345 |
# Should not raise
|
| 346 |
chunks = self._run_retrieve(
|
| 347 |
query="what is the waiting period for cataract",
|
| 348 |
-
|
| 349 |
)
|
| 350 |
|
| 351 |
# No profile chunk should appear (none was ever written)
|
| 352 |
profile_chunks = [c for c in chunks if c.doc_type == "profile"]
|
| 353 |
self.assertEqual(
|
| 354 |
profile_chunks, [],
|
| 355 |
-
f"never-existed
|
| 356 |
f"Got: {[(c.chunk_id, c.doc_type) for c in chunks]}",
|
| 357 |
)
|
| 358 |
# But main cosine retrieval must still work
|
|
@@ -361,10 +387,10 @@ class TestRetrieveSurvivesMissingProfileId(unittest.TestCase):
|
|
| 361 |
"main cosine pass should still return the seeded policy chunk.",
|
| 362 |
)
|
| 363 |
|
| 364 |
-
def
|
| 365 |
-
"""Simulate the worst case: Chroma raises on the per-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
self._seed_one_policy()
|
| 369 |
|
| 370 |
# Wrap the real collection so .get() raises but .query() works
|
|
@@ -390,14 +416,14 @@ class TestRetrieveSurvivesMissingProfileId(unittest.TestCase):
|
|
| 390 |
query="what is the waiting period",
|
| 391 |
top_k=5,
|
| 392 |
embedder=_StubEmbedder(),
|
| 393 |
-
|
| 394 |
))
|
| 395 |
|
| 396 |
# Main cosine still works → at least the seeded policy chunk returns
|
| 397 |
self.assertGreater(
|
| 398 |
len(chunks), 0,
|
| 399 |
"retrieve() should fall back to main cosine results when "
|
| 400 |
-
"the per-
|
| 401 |
)
|
| 402 |
# No profile chunk surfaced
|
| 403 |
self.assertEqual(
|
|
@@ -437,16 +463,16 @@ class TestRetrieveSurvivesMissingProfileId(unittest.TestCase):
|
|
| 437 |
|
| 438 |
# ---------------------------------------------------------------------------
|
| 439 |
# Standalone upsert metadata test — no Chroma client; just verify the
|
| 440 |
-
# upsert builds metadata containing
|
| 441 |
# ---------------------------------------------------------------------------
|
| 442 |
|
| 443 |
|
| 444 |
-
class
|
| 445 |
-
"""KI-
|
| 446 |
chunk's Chroma metadata. Without it, the retrieve filter can't
|
| 447 |
-
distinguish
|
| 448 |
|
| 449 |
-
def
|
| 450 |
from backend import profile_rag
|
| 451 |
|
| 452 |
captured: dict = {}
|
|
@@ -471,7 +497,7 @@ class TestUpsertStampsSessionId(unittest.TestCase):
|
|
| 471 |
return [[0.1] * 384 for _ in texts]
|
| 472 |
|
| 473 |
fake_coll = _FakeColl()
|
| 474 |
-
|
| 475 |
profile = {
|
| 476 |
"age": 32,
|
| 477 |
"dependents": "self_spouse",
|
|
@@ -481,45 +507,37 @@ class TestUpsertStampsSessionId(unittest.TestCase):
|
|
| 481 |
|
| 482 |
with mock.patch.object(profile_rag, "_get_collection", return_value=fake_coll), \
|
| 483 |
mock.patch("backend.providers.local_embeddings.LocalEmbeddings", _FakeEmbedder):
|
| 484 |
-
asyncio.run(profile_rag.upsert_profile_chunk(
|
| 485 |
|
| 486 |
self.assertIn("metadatas", captured, "upsert never called coll.add")
|
| 487 |
meta = captured["metadatas"][0]
|
| 488 |
self.assertEqual(
|
| 489 |
-
meta.get("
|
| 490 |
-
f"profile chunk metadata missing
|
| 491 |
)
|
| 492 |
self.assertEqual(meta.get("doc_type"), "profile")
|
| 493 |
-
self.assertEqual(captured["ids"], [f"profile_{
|
| 494 |
|
| 495 |
|
| 496 |
# ---------------------------------------------------------------------------
|
| 497 |
-
# KI-112 (2026-05-15) — input validation hardening.
|
| 498 |
-
#
|
| 499 |
-
# Root cause of the HNSW corruption: KI-102's initial deploy wrote a profile
|
| 500 |
-
# chunk under id "profile_anonymous" with NO session_id metadata. That legacy
|
| 501 |
-
# chunk poisoned every subsequent collection.query() that referenced
|
| 502 |
-
# session_id or doc_type$ne in the where clause — Chroma's plan executor
|
| 503 |
-
# raised "Error finding id" against the dangling row's HNSW pointer, and
|
| 504 |
-
# corruption propagated across HNSW segments (1580 / 7356 chunks turned
|
| 505 |
-
# unfetchable on full collection rebuild).
|
| 506 |
#
|
| 507 |
-
#
|
| 508 |
-
#
|
| 509 |
-
#
|
| 510 |
-
#
|
| 511 |
-
#
|
| 512 |
-
# corrupt HNSW segment files).
|
| 513 |
#
|
|
|
|
| 514 |
# These tests pin the contract: bad inputs MUST be rejected at write time,
|
| 515 |
-
# not silently corrupt the index for future
|
| 516 |
# ---------------------------------------------------------------------------
|
| 517 |
|
| 518 |
|
| 519 |
class TestUpsertRejectsBadInputs(unittest.TestCase):
|
| 520 |
-
"""KI-112 — bad
|
| 521 |
|
| 522 |
-
def
|
| 523 |
from backend import profile_rag
|
| 524 |
|
| 525 |
captured: dict = {"add_called": False}
|
|
@@ -537,14 +555,14 @@ class TestUpsertRejectsBadInputs(unittest.TestCase):
|
|
| 537 |
async def embed(self, texts, input_type="document"):
|
| 538 |
return [[0.1] * 384 for _ in texts]
|
| 539 |
|
| 540 |
-
for
|
| 541 |
with mock.patch.object(profile_rag, "_get_collection", return_value=_FakeColl()), \
|
| 542 |
mock.patch("backend.providers.local_embeddings.LocalEmbeddings", _FakeEmbedder):
|
| 543 |
captured["add_called"] = False
|
| 544 |
-
asyncio.run(profile_rag.upsert_profile_chunk(
|
| 545 |
self.assertFalse(
|
| 546 |
captured["add_called"],
|
| 547 |
-
f"upsert MUST refuse
|
| 548 |
"corrupt the policies collection.",
|
| 549 |
)
|
| 550 |
|
|
@@ -573,7 +591,7 @@ class TestUpsertRejectsBadInputs(unittest.TestCase):
|
|
| 573 |
with mock.patch.object(profile_rag, "_get_collection", return_value=_FakeColl()), \
|
| 574 |
mock.patch("backend.providers.local_embeddings.LocalEmbeddings", _BadDimEmbedder):
|
| 575 |
asyncio.run(profile_rag.upsert_profile_chunk(
|
| 576 |
-
"
|
| 577 |
))
|
| 578 |
|
| 579 |
self.assertFalse(
|
|
@@ -605,7 +623,7 @@ class TestUpsertRejectsBadInputs(unittest.TestCase):
|
|
| 605 |
with mock.patch.object(profile_rag, "_get_collection", return_value=_FakeColl()), \
|
| 606 |
mock.patch("backend.providers.local_embeddings.LocalEmbeddings", _NoneVecEmbedder):
|
| 607 |
asyncio.run(profile_rag.upsert_profile_chunk(
|
| 608 |
-
"
|
| 609 |
))
|
| 610 |
|
| 611 |
self.assertFalse(
|
|
@@ -636,10 +654,10 @@ class TestUpsertRejectsBadInputs(unittest.TestCase):
|
|
| 636 |
with mock.patch.object(profile_rag, "_get_collection", return_value=_FakeColl()), \
|
| 637 |
mock.patch("backend.providers.local_embeddings.LocalEmbeddings", _GoodEmbedder):
|
| 638 |
asyncio.run(profile_rag.upsert_profile_chunk(
|
| 639 |
-
"
|
| 640 |
))
|
| 641 |
|
| 642 |
-
self.assertEqual(captured.get("ids"), ["
|
| 643 |
self.assertEqual(len(captured["embeddings"][0]), 384)
|
| 644 |
|
| 645 |
|
|
|
|
| 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
|
|
|
|
| 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 |
|
|
|
|
| 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],
|
|
|
|
| 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,
|
|
|
|
| 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
|
|
|
|
| 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 |
)
|
|
|
|
| 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,
|
|
|
|
| 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 |
|
|
|
|
| 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):
|
|
|
|
| 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):
|
|
|
|
| 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()
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
| 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(
|
|
|
|
| 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 = {}
|
|
|
|
| 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",
|
|
|
|
| 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}
|
|
|
|
| 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 |
|
|
|
|
| 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(
|
|
|
|
| 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(
|
|
|
|
| 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 |
|
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression test for KI-118 (2026-05-15) — session_state has NO disk persistence.
|
| 2 |
+
|
| 3 |
+
The pre-KI-118 architecture wrote `40-data/sessions/<session_id>.json` on
|
| 4 |
+
every state mutation so a Space restart wouldn't lose state. That disk-write
|
| 5 |
+
side was:
|
| 6 |
+
|
| 7 |
+
1. The root of the Chroma corruption incident (the legacy
|
| 8 |
+
`profile_anonymous` chunk that poisoned every subsequent query).
|
| 9 |
+
2. A privacy and operational liability (orphan files accumulate, schema
|
| 10 |
+
drift hits old files, cross-session memory leak surface).
|
| 11 |
+
|
| 12 |
+
KI-118 rip-out: session_state is in-memory only. Cross-session memory is
|
| 13 |
+
strictly name-based (returning user provides their name → fact_find brain
|
| 14 |
+
captures it → `rehydrate_by_name` pulls the named profile from
|
| 15 |
+
`40-data/profiles/`).
|
| 16 |
+
|
| 17 |
+
This test pins the contract: a session lifecycle (get, mutate, set_awaiting,
|
| 18 |
+
record_answer, update_profile_field, reset) MUST NOT create ANY file under
|
| 19 |
+
`40-data/sessions/`. If a future change reintroduces disk persistence, this
|
| 20 |
+
test fires.
|
| 21 |
+
|
| 22 |
+
Run:
|
| 23 |
+
cd /Users/rohitsar/Developer/Insurance\\ Sales\\ Bot
|
| 24 |
+
PYTHONPATH=$PWD .venv/bin/python -m pytest tests/test_session_no_disk_persistence.py -v
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import sys
|
| 30 |
+
import unittest
|
| 31 |
+
import uuid
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
|
| 34 |
+
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 35 |
+
if str(_REPO_ROOT) not in sys.path:
|
| 36 |
+
sys.path.insert(0, str(_REPO_ROOT))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
_SESSIONS_DIR = _REPO_ROOT / "40-data" / "sessions"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _file_count() -> int:
|
| 43 |
+
if not _SESSIONS_DIR.exists():
|
| 44 |
+
return 0
|
| 45 |
+
return sum(1 for _ in _SESSIONS_DIR.glob("*.json"))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class TestSessionNoDiskPersistence(unittest.TestCase):
|
| 49 |
+
"""KI-118 — session lifecycle must not touch 40-data/sessions/."""
|
| 50 |
+
|
| 51 |
+
def setUp(self):
|
| 52 |
+
# Capture the pre-test file count so we only assert on the delta.
|
| 53 |
+
# Other tests / contributors may legitimately have files in this
|
| 54 |
+
# dir; we only care that this test's session id doesn't write one.
|
| 55 |
+
self.before_count = _file_count()
|
| 56 |
+
self.session_id = f"ki118_no_disk_{uuid.uuid4().hex[:10]}"
|
| 57 |
+
|
| 58 |
+
def tearDown(self):
|
| 59 |
+
# Defensive cleanup: if some other code path DID create our file,
|
| 60 |
+
# remove it so we don't pollute the working dir.
|
| 61 |
+
target = _SESSIONS_DIR / f"{self.session_id}.json"
|
| 62 |
+
if target.exists():
|
| 63 |
+
try:
|
| 64 |
+
target.unlink()
|
| 65 |
+
except OSError:
|
| 66 |
+
pass
|
| 67 |
+
|
| 68 |
+
def test_session_lifecycle_creates_no_disk_file(self):
|
| 69 |
+
from backend.session_state import (
|
| 70 |
+
get_session,
|
| 71 |
+
reset_session,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# 1. get_session → in-memory
|
| 75 |
+
sess = get_session(self.session_id)
|
| 76 |
+
self.assertEqual(sess.session_id, self.session_id)
|
| 77 |
+
|
| 78 |
+
# 2. mutate every public path that USED to flush to disk pre-KI-118
|
| 79 |
+
sess.profile.age = 35
|
| 80 |
+
sess.profile.name = "Rohit"
|
| 81 |
+
sess.set_awaiting("dependents")
|
| 82 |
+
sess.update_profile_field("income_band", "10-25L")
|
| 83 |
+
sess.free_form_session = True
|
| 84 |
+
sess._flush() # legacy callers still invoke this — must be a no-op
|
| 85 |
+
|
| 86 |
+
# 3. Reset
|
| 87 |
+
reset_session(self.session_id)
|
| 88 |
+
|
| 89 |
+
# 4. Assert: no new file in 40-data/sessions/ AND specifically no
|
| 90 |
+
# file under our session id.
|
| 91 |
+
target = _SESSIONS_DIR / f"{self.session_id}.json"
|
| 92 |
+
self.assertFalse(
|
| 93 |
+
target.exists(),
|
| 94 |
+
f"REGRESSION (KI-118): session lifecycle created {target}. "
|
| 95 |
+
"session_state.py was refactored to be in-memory only; a disk "
|
| 96 |
+
"write was reintroduced.",
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
# The directory may still exist if it was already there pre-test,
|
| 100 |
+
# but our file count delta must be 0.
|
| 101 |
+
after = _file_count()
|
| 102 |
+
self.assertEqual(
|
| 103 |
+
after, self.before_count,
|
| 104 |
+
f"REGRESSION (KI-118): session lifecycle created {after - self.before_count} "
|
| 105 |
+
f"new file(s) under {_SESSIONS_DIR}. Expected zero.",
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
def test_flush_is_a_noop(self):
|
| 109 |
+
"""SessionState._flush() is kept for backwards-compat with existing
|
| 110 |
+
callers (orchestrator + fact_find_brain). It must do nothing — no
|
| 111 |
+
I/O, no exceptions on missing dir, no return value."""
|
| 112 |
+
from backend.session_state import get_session
|
| 113 |
+
sess = get_session(self.session_id)
|
| 114 |
+
sess.profile.age = 35
|
| 115 |
+
# Must not raise even if the parent dir doesn't exist.
|
| 116 |
+
result = sess._flush()
|
| 117 |
+
self.assertIsNone(result, "_flush() must be a no-op returning None")
|
| 118 |
+
|
| 119 |
+
def test_purge_old_files_is_noop(self):
|
| 120 |
+
"""KI-118 — purge_old_files is a stub now; must return 0 and never
|
| 121 |
+
crash, even if 40-data/sessions/ doesn't exist."""
|
| 122 |
+
from backend.session_state import purge_old_files
|
| 123 |
+
result = purge_old_files()
|
| 124 |
+
self.assertEqual(result, 0)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
unittest.main(verbosity=2)
|