rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
f6249a7
·
1 Parent(s): 380491c

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 CHANGED
@@ -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
- if not (isinstance(sid, str) and sid.strip()):
 
 
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
- await upsert_profile_chunk(req.session_id, profile_dict)
 
 
 
 
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
 
backend/orchestrator.py CHANGED
@@ -548,26 +548,30 @@ async def handle_turn(
548
  session.free_form_session = True
549
  session._flush()
550
 
551
- # KI-063opportunistic profile-chunk re-upsert if any field changed
552
- # so this turn's downstream retrieval (when complete=true triggers a
553
- # follow-up recommendation) sees the latest profile.
554
- if fact_find_profile_updates:
 
555
  try:
556
  from backend.profile_rag import upsert_profile_chunk
 
557
  p = session.profile
558
- await upsert_profile_chunk(session_id or "anonymous", {
559
- "age": p.age,
560
- "dependents": p.dependents,
561
- "income_band": p.income_band,
562
- "existing_cover_inr": p.existing_cover_inr,
563
- "primary_goal": p.primary_goal,
564
- "location_tier": p.location_tier,
565
- "parents_to_insure": p.parents_to_insure,
566
- "parents_age_max": p.parents_age_max,
567
- "parents_has_ped": p.parents_has_ped,
568
- "budget_band": p.budget_band,
569
- "health_conditions": p.health_conditions,
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
- from backend.profile_rag import upsert_profile_chunk
713
- profile_dict_for_chunk = {
714
- "age": session.profile.age,
715
- "dependents": session.profile.dependents,
716
- "income_band": session.profile.income_band,
717
- "existing_cover_inr": session.profile.existing_cover_inr,
718
- "primary_goal": session.profile.primary_goal,
719
- "location_tier": session.profile.location_tier,
720
- "parents_to_insure": session.profile.parents_to_insure,
721
- "parents_age_max": session.profile.parents_age_max,
722
- "parents_has_ped": session.profile.parents_has_ped,
723
- "budget_band": session.profile.budget_band,
724
- "health_conditions": session.profile.health_conditions,
725
- }
726
- await upsert_profile_chunk(session_id or "anonymous", profile_dict_for_chunk)
 
 
 
 
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)
backend/profile_rag.py CHANGED
@@ -1,27 +1,26 @@
1
  """Customer-profile-as-RAG layer.
2
 
3
- When a user saves their profile (POST /api/profile), the profile dict is
4
- serialised into a natural-language paragraph and ingested into the same
5
- Chroma collection that holds policy + regulatory chunks. Metadata fields
6
- `doc_type='profile'` and `policy_id='profile_<session_id>'` distinguish it.
7
-
8
- At retrieval time, `rag/retrieve.py::retrieve(..., session_id=...)` can
9
- preferentially boost the matching profile chunk so the LLM sees the user's
10
- context inline with the retrieved policy/regulatory text — answers become
11
- personalised at the BRAIN level, not just at scorecard re-weighting.
12
-
13
- This is what the user meant by "we need an architecture to store customer
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(session_id, profile_dict, embedder) -> None
20
- Ingest / update the chunk for this session in Chroma.
21
- remove_profile_chunk(session_id) -> None
22
- Optional cleanup on session expiry.
23
 
24
- Storage model — one chunk per session_id. Replaced on each profile update.
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(session_id: str, profile_dict: dict) -> None:
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 session_id get replaced.
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: session_id must be a non-empty str.
138
- # Pre-fix, a missing/empty session_id caused upsert under id "profile_"
139
- # with `policy_id` colliding across all anonymous sessions — and the
140
- # initial KI-102 deploy wrote a `profile_anonymous` chunk WITHOUT a
141
- # session_id metadata field. That legacy chunk poisoned every subsequent
142
- # query whose `where` clause referenced session_id ($ne / $eq) Chroma's
143
- # HNSW + metadata-filter plan executor raised "Error finding id" against
144
- # the dangling row. Reject early with a noisy log so the bad write never
145
- # reaches Chroma.
146
- if not isinstance(session_id, str) or not session_id.strip():
147
  _log.warning(
148
- "profile_rag.upsert_profile_chunk: refusing to write — session_id "
149
  "must be a non-empty str, got %r. Profile not persisted.",
150
- session_id,
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 session_id=%s (expected %d-dim list of floats, "
171
  "got type=%s len=%s). Profile not persisted.",
172
- session_id, expected_dim, type(vec).__name__,
173
  (len(vec) if hasattr(vec, "__len__") else "?"),
174
  )
175
  return
176
 
177
  coll = _get_collection()
178
- chunk_id = f"profile_{session_id}"
179
 
180
- # Replace any existing chunk for this session
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 (session {session_id[:8]})",
207
  "doc_type": "profile",
208
- # KI-102 (2026-05-15) — privacy P0. Stamp the owning session_id so
209
- # the retrieve path can hard-exclude any OTHER session's profile
210
- # chunk via where={"session_id": current}. Pre-fix, profile chunks
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 session profile",
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-session profile context; "
227
  "next profile change will retry.",
228
  chunk_id, type(e).__name__, str(e)[:200],
229
  )
230
 
231
 
232
- def remove_profile_chunk(session_id: str) -> None:
233
- """Optional cleanup. Called on session expiry (1h TTL in session_state)."""
 
 
234
  try:
235
  coll = _get_collection()
236
- coll.delete(where={"policy_id": f"profile_{session_id}"})
237
  except Exception:
238
  pass
239
 
240
 
241
- def upsert_profile_chunk_sync(session_id: 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(session_id, profile_dict))
248
  return
249
  except RuntimeError:
250
  pass
251
- asyncio.run(upsert_profile_chunk(session_id, profile_dict))
 
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))
backend/session_state.py CHANGED
@@ -1,44 +1,43 @@
1
- """Per-session state for multi-turn fact-find continuity, persisted to disk.
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 (changed 2026-05-14):
9
- - In-memory dict for hot reads (avoids hitting disk every turn).
10
- - JSON file per session at 40-data/sessions/<session_id>.json survives
11
- Space restarts so a user returning after HF hibernation finds their
12
- profile intact.
13
- - Loaded lazily on first get_session(); flushed on every state mutation.
14
- - Same 1h idle TTL applies but ONLY garbage-collects the in-memory cache.
15
- The on-disk file lives until session_state.purge_old_files() runs (called
16
- daily by a cron, or on startup). 30-day disk TTL.
17
 
18
- The previous architecture (in-memory only) was lost on every Space cold-
19
- start. With HF Spaces hibernating after ~30 min of idleness, every returning
20
- user was getting a fresh blank profile.
 
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 + flushes
27
  """
28
 
29
  from __future__ import annotations
30
 
31
- import json
32
  import time
33
- from dataclasses import dataclass, field, asdict
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
- # On-disk storage root. Created on first write.
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
- """Atomic write to 40-data/sessions/<id>.json so a restart doesn't lose state."""
54
- try:
55
- _DATA_ROOT.mkdir(parents=True, exist_ok=True)
56
- target = _DATA_ROOT / f"{self.session_id}.json"
57
- payload = {
58
- "session_id": self.session_id,
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 + flush. Used by /api/profile."""
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 (still on disk)
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 (file on disk survives)
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
- # Try disk first survives Space restarts
156
- rehydrated = _load_from_disk(session_id)
157
- if rehydrated is None:
158
- rehydrated = SessionState(session_id=session_id)
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 and remove the disk file.
173
  Returns True if anything was actually deleted.
174
- KI-020 (2026-05-14) — backs the user-facing "Clear chat / start fresh" toggle."""
175
- deleted_any = False
 
 
 
176
  with _lock:
177
  if session_id in _sessions:
178
  del _sessions[session_id]
179
- deleted_any = True
180
- target = _DATA_ROOT / f"{session_id}.json"
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
- """Delete on-disk session files older than _DISK_TTL_SECONDS. Returns count."""
196
- if not _DATA_ROOT.exists():
197
- return 0
198
- now = time.time()
199
- purged = 0
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
 
 
 
 
 
 
 
 
frontend/src/app/page.tsx CHANGED
@@ -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
- localStorage.setItem("insurance_session_id", sessionId);
 
 
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
- const savedSession = localStorage.getItem("insurance_session_id");
 
 
 
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
- localStorage.setItem("insurance_session_id", res.session_id);
254
  }
255
  } else {
256
  setSessionId(undefined);
257
  if (typeof window !== "undefined") {
258
- localStorage.removeItem("insurance_session_id");
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
- localStorage.removeItem("insurance_session_id");
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") localStorage.setItem("insurance_session_id", sid);
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" ? localStorage.getItem("insurance_session_id") || undefined : 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;
rag/retrieve.py CHANGED
@@ -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 — when the orchestrator passes a session_id, look
299
- # up THAT user's profile chunk in Chroma. Inject it at the top of the
300
- # context (always, not just on high cosine) so the LLM sees the user
301
- # context block before any policy text. Mirrors the regulatory-boost
302
- # pattern below.
303
- if session_id:
304
- profile_chunk_id = f"profile_{session_id}"
305
- # KI-102 defence-in-depth. Filter by BOTH id AND session_id
306
- # metadata so any future ID collision (or migration-era chunk
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 new
311
- # sessions (no profile saved yet) and certain transient Chroma sqlite
312
- # states cause collection.get(ids=[missing_id]) to raise instead of
313
- # returning empty lists. Before this fix the bare `except: pass` here
314
- # masked the failure, the orchestrator's plan-executor still saw an
315
- # AttributeError on the swallowed-None path, and the user got HTTP
316
- # 500 "Error executing plan: Internal error: Error finding id".
 
317
  prof_res = _safe_collection_get(
318
  collection,
319
  ids=[profile_chunk_id],
320
- where={"session_id": session_id},
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: even if Chroma returns a row, refuse it unless
330
- # metadata.session_id matches. Belt + suspenders + parachute.
331
- if p_doc and p_meta.get("session_id") == session_id:
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
tests/test_name_persistence.py CHANGED
@@ -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
 
tests/test_profile_rag_isolation.py CHANGED
@@ -1,31 +1,29 @@
1
- """Regression tests for KI-102 profile-RAG cross-session privacy leak.
2
-
3
- Pre-fix bug (caught by live 15-persona smoke test 2026-05-15):
4
- 1. User A saves profile upsert_profile_chunk(session_id=A) writes a
5
- chunk to the shared 'policies' Chroma collection with metadata
6
- {policy_id: 'profile_A', doc_type: 'profile'} NO session_id field.
7
- 2. User B sends a chat retrieve(query, session_id=B) runs the main
8
- cosine pass with no doc_type filter, so user A's profile chunk is
9
- a candidate for top-k by raw cosine.
10
- 3. If user B's query is profile-shaped (age / dependents / health),
11
- user A's profile chunk surfaces in B's context and the LLM cites
12
- it as B's "User profile" — leaking A's facts into B's reply.
13
-
14
- Three fixes ship together:
15
- KI-102.a upsert_profile_chunk stamps session_id into Chroma metadata,
16
- so the retrieve path can filter by it.
17
- KI-102.b — retrieve()'s main cosine pass now passes
18
- where={'doc_type': {'$ne': 'profile'}} so NO profile chunk
19
- can ever surface via the cosine path. Profile chunks are
20
- exclusively surfaced via the explicit per-session
21
- collection.get(ids=[f'profile_{session_id}']) lookup.
22
- KI-102.c — that per-session lookup gates on metadata.session_id ==
23
- current session_id (triple-check) so even an ID collision
24
- or legacy chunk without session_id metadata cannot leak.
25
-
26
- These tests run WITHOUT touching a real LLM / network. We stub the
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 for each session)
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-102session A's profile must NEVER surface in session B's retrieve."""
 
101
 
102
  def setUp(self):
103
  self.coll = _make_ephemeral_collection()
104
- self.session_a = f"sessA_{uuid.uuid4().hex[:6]}"
105
- self.session_b = f"sessB_{uuid.uuid4().hex[:6]}"
106
 
107
- def _seed_profile(self, session_id: str, text: str) -> None:
108
- """Write a profile chunk for `session_id` directly to the test
109
  collection, mirroring what upsert_profile_chunk does in prod."""
110
  vec = asyncio.run(_StubEmbedder().embed([text]))[0]
111
- chunk_id = f"profile_{session_id}"
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 (session {session_id[:8]})",
120
  "doc_type": "profile",
121
- "session_id": session_id, # KI-102.a — stamped at write time
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, session_id: str, top_k: int = 5):
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
- session_id=session_id,
162
  ))
163
 
164
  # -----------------------------------------------------------------
165
- # CASE 1 — pre-fix leak repro: session A's profile must NOT show up
166
- # in session B's retrieved context.
167
  # -----------------------------------------------------------------
168
- def test_session_a_profile_never_leaks_into_session_b(self):
169
  self._seed_profile(
170
- self.session_a,
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.session_b,
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
- # Session B asks a profile-flavoured query
183
  chunks = self._run_retrieve(
184
  query="what plan suits my age and dependents",
185
- session_id=self.session_b,
186
  )
187
 
188
- leaked = [c for c in chunks if c.policy_id == f"profile_{self.session_a}"]
189
  self.assertEqual(
190
  leaked, [],
191
- f"PRIVACY LEAK: session A's profile chunk surfaced in session B's "
192
  f"retrieval. Found: {[c.policy_id for c in chunks]}",
193
  )
194
 
195
  # -----------------------------------------------------------------
196
- # CASE 2 — session B's OWN profile must still surface (positive path).
197
  # -----------------------------------------------------------------
198
- def test_session_b_own_profile_is_surfaced(self):
199
  self._seed_profile(
200
- self.session_b,
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
- session_id=self.session_b,
209
  )
210
- own = [c for c in chunks if c.policy_id == f"profile_{self.session_b}"]
211
  self.assertEqual(
212
  len(own), 1,
213
- f"Session B should see its OWN profile chunk. Got: {[c.policy_id for c in chunks]}",
214
  )
215
 
216
  # -----------------------------------------------------------------
217
  # CASE 3 — multiple foreign profiles + one own profile. Only the
218
- # current session's chunk may be present.
219
  # -----------------------------------------------------------------
220
  def test_three_foreign_profiles_none_leak(self):
221
- for sid in ["smokeA_1", "ki100_ve", "smokeB_B2"]:
222
  self._seed_profile(
223
- sid,
224
  f"USER CONTEXT — facts about the person asking this question:\n"
225
- f"- Age: {30 + len(sid)} years.\n- Health conditions: PII for {sid}.",
226
  )
227
  self._seed_profile(
228
- self.session_b,
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
- session_id=self.session_b,
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 session_b's
240
  self.assertEqual(
241
- profile_pids, [f"profile_{self.session_b}"],
242
  f"Foreign profile leaked. profile chunks in result: {profile_pids}",
243
  )
244
 
245
  # -----------------------------------------------------------------
246
- # CASE 4 — legacy chunk without session_id metadata is refused even
247
- # if its id happens to match (defence-in-depth from KI-102.c).
248
  # -----------------------------------------------------------------
249
- def test_legacy_chunk_without_session_id_metadata_is_refused(self):
250
- # Write a chunk under id 'profile_<session_b>' but with NO
251
- # session_id field (simulating a pre-fix legacy row).
252
  vec = asyncio.run(_StubEmbedder().embed(["USER CONTEXT — legacy"]))[0]
253
- chunk_id = f"profile_{self.session_b}"
254
  self.coll.add(
255
  ids=[chunk_id],
256
- documents=["USER CONTEXT — legacy row from before KI-102 deploy"],
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 'session_id' — simulating pre-fix state
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
- session_id=self.session_b,
275
  )
276
  # Legacy chunk must be refused — the triple-check at retrieve's
277
- # per-session lookup gates on metadata.session_id match.
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 session_id metadata must be refused. "
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_{sid}"]) on EVERY
291
- # query — and for new sessions (no profile saved yet) and certain Chroma
292
- # sqlite states, that call can raise. The bare `except: pass` in the
293
- # pre-KI-107 code masked the failure into the orchestrator's plan executor.
294
- # These tests pin the contract: retrieve() with a never-existed session_id
295
- # must NEVER raise and must NEVER return a profile chunk.
296
  # ---------------------------------------------------------------------------
297
 
298
 
299
  class TestRetrieveSurvivesMissingProfileId(unittest.TestCase):
300
- """KI-107 — retrieve(session_id=...) must be exception-safe across:
301
- (1) brand-new sessions with no profile chunk yet,
302
- (2) Chroma.get raising on the per-session lookup,
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, session_id: str, top_k: int = 5):
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
- session_id=session_id,
336
  ))
337
 
338
- def test_retrieve_with_never_existed_session_does_not_raise(self):
339
- """New session, no profile saved yet — must return policy chunks
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
- session_id="never_existed_session_xyz",
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 session should not produce profile chunks. "
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 test_retrieve_handles_chroma_get_raising_on_per_session_lookup(self):
365
- """Simulate the worst case: Chroma raises on the per-session
366
- profile lookup (e.g. transient sqlite lock during compaction).
367
- retrieve() must still return main cosine results, not 500."""
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
- session_id="some_session_id",
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-session profile lookup raises.",
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 session_id.
441
  # ---------------------------------------------------------------------------
442
 
443
 
444
- class TestUpsertStampsSessionId(unittest.TestCase):
445
- """KI-102.a — upsert_profile_chunk MUST write session_id into the
446
  chunk's Chroma metadata. Without it, the retrieve filter can't
447
- distinguish session A's profile from session B's."""
448
 
449
- def test_upsert_writes_session_id_to_metadata(self):
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
- sid = f"test_{uuid.uuid4().hex[:6]}"
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(sid, profile))
485
 
486
  self.assertIn("metadatas", captured, "upsert never called coll.add")
487
  meta = captured["metadatas"][0]
488
  self.assertEqual(
489
- meta.get("session_id"), sid,
490
- f"profile chunk metadata missing session_id. Got: {meta}",
491
  )
492
  self.assertEqual(meta.get("doc_type"), "profile")
493
- self.assertEqual(captured["ids"], [f"profile_{sid}"])
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
- # Two guards added in upsert_profile_chunk:
508
- # 1) session_id must be a non-empty str (rejects "", None, falsy values
509
- # that would write under "profile_" / "profile_anonymous" etc).
510
- # 2) embedding shape must match the embedder's declared dimension and
511
- # contain no None values (rejects mis-shaped vectors that would
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 sessions.
516
  # ---------------------------------------------------------------------------
517
 
518
 
519
  class TestUpsertRejectsBadInputs(unittest.TestCase):
520
- """KI-112 — bad session_id or embedding shape must NOT reach Chroma."""
521
 
522
- def test_upsert_rejects_empty_session_id(self):
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 bad_sid in ["", " ", None]:
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(bad_sid, {"age": 30}))
545
  self.assertFalse(
546
  captured["add_called"],
547
- f"upsert MUST refuse session_id={bad_sid!r} — bad write would "
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
- "valid_session_xyz", {"age": 30, "dependents": "self"},
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
- "valid_session_xyz", {"age": 30, "dependents": "self"},
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
- "good_session", {"age": 30, "dependents": "self"},
640
  ))
641
 
642
- self.assertEqual(captured.get("ids"), ["profile_good_session"])
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-118anonymous 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
 
tests/test_session_no_disk_persistence.py ADDED
@@ -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)