rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
4bb8da0
·
1 Parent(s): 66eb4ed

fix(privacy): KI-102 — scope profile-RAG retrieval to session_id only

Browse files

P0 privacy leak caught by live 15-persona smoke test (2026-05-15): user
B4 (session smokeB_B4_<ts>) retrieved profile chunks from sessions
smokeA_1, ki100_ve, and smokeB_B2, and the LLM cited them as B4's "User
profile" — leaking three other users' age / health-conditions / dependents
into B4's reply.

Root cause was a two-layer miss:

1. backend/profile_rag.py::upsert_profile_chunk wrote chunks with
metadata {policy_id, doc_type='profile'} but NO session_id field,
so any future where={"session_id": X} filter would be a no-op.

2. rag/retrieve.py::retrieve()'s main cosine pass at line 196-209 built
where={} when no policy_ids / insurer_slugs were passed, then ran
collection.query(..., where=None). With no doc_type filter, ALL
profile chunks (every session's) were eligible for cosine match.
Profile-shaped queries ("plan for my age", "my dependents") put
foreign profile chunks in the top-k. The explicit per-session
collection.get(ids=[f"profile_{session_id}"]) block at lines 224-241
prepended the caller's OWN profile, but never EXCLUDED the foreign
ones that the main pass had already pulled in.

Fix has three parts:

KI-102.a — upsert_profile_chunk stamps "session_id": session_id into
every profile chunk's metadata at write time.

KI-102.b — retrieve()'s main pass now ALWAYS filters
where={"doc_type": {"$ne": "profile"}} (combined via $and
with existing policy_id / insurer_slug filters when present).
Profile chunks are exclusively surfaced via the explicit
per-session collection.get(...) lookup below, so no profile
chunk can ever reach a foreign session via the cosine path.

KI-102.c — the per-session lookup now passes where={"session_id": sid}
AND triple-checks meta.session_id == session_id on the
returned row before injecting. Legacy chunks written
pre-deploy (without session_id metadata) are silently
refused — privacy fail-closed. Users re-save their profile
once after the deploy and the new chunk is correctly stamped.

Added tests/test_profile_rag_isolation.py with 5 test cases:
- session A's profile never leaks into session B's retrieve
- session B's own profile is still surfaced
- 3 foreign profiles + 1 own profile: only own profile appears
- legacy chunk without session_id metadata is refused
- upsert writes session_id to metadata

All 36 tests pass (5 new + 31 existing), 13 subtests pass.

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

backend/profile_rag.py CHANGED
@@ -152,6 +152,14 @@ async def upsert_profile_chunk(session_id: str, profile_dict: dict) -> None:
152
  "insurer_slug": "profile",
153
  "policy_name": f"User profile (session {session_id[:8]})",
154
  "doc_type": "profile",
 
 
 
 
 
 
 
 
155
  "source_url": "",
156
  "page_start": 0,
157
  "page_end": 0,
 
152
  "insurer_slug": "profile",
153
  "policy_name": f"User profile (session {session_id[:8]})",
154
  "doc_type": "profile",
155
+ # KI-102 (2026-05-15) — privacy P0. Stamp the owning session_id so
156
+ # the retrieve path can hard-exclude any OTHER session's profile
157
+ # chunk via where={"session_id": current}. Pre-fix, profile chunks
158
+ # had no session_id metadata and the main retrieval pass had no
159
+ # doc_type filter, so chunks from sessions smokeA_1, ki100_ve, etc.
160
+ # surfaced as cosine matches in session smokeB_B4's context —
161
+ # leaking one user's profile facts into another user's reply.
162
+ "session_id": session_id,
163
  "source_url": "",
164
  "page_start": 0,
165
  "page_end": 0,
rag/retrieve.py CHANGED
@@ -193,11 +193,26 @@ async def retrieve(
193
  embedder = embedder or VoyageEmbeddings()
194
  [query_vec] = await embedder.embed([query], input_type="query")
195
 
196
- where: dict = {}
 
 
 
 
 
 
 
 
 
 
 
197
  if policy_ids:
198
- where["policy_id"] = {"$in": policy_ids}
199
  if insurer_slugs:
200
- where["insurer_slug"] = {"$in": insurer_slugs}
 
 
 
 
201
 
202
  collection = get_collection()
203
 
@@ -205,7 +220,7 @@ async def retrieve(
205
  res = collection.query(
206
  query_embeddings=[query_vec],
207
  n_results=effective_top_k,
208
- where=where if where else None,
209
  )
210
 
211
  out: list[RetrievedChunk] = []
@@ -224,14 +239,21 @@ async def retrieve(
224
  if session_id:
225
  try:
226
  profile_chunk_id = f"profile_{session_id}"
 
 
 
 
227
  prof_res = collection.get(
228
  ids=[profile_chunk_id],
 
229
  include=["documents", "metadatas"],
230
  )
231
  if prof_res.get("ids"):
232
  p_doc = prof_res["documents"][0] if prof_res.get("documents") else ""
233
  p_meta = prof_res["metadatas"][0] if prof_res.get("metadatas") else {}
234
- if p_doc:
 
 
235
  # Profile gets max score (1.0) so it always tops the context
236
  profile_chunk = _build_chunk(profile_chunk_id, p_doc, p_meta, 1.0)
237
  # Prepend; trim to top_k so we keep budget
 
193
  embedder = embedder or VoyageEmbeddings()
194
  [query_vec] = await embedder.embed([query], input_type="query")
195
 
196
+ # KI-102 (2026-05-15) — privacy P0. The main retrieval pass MUST NEVER
197
+ # return chunks of doc_type='profile' because those chunks are scoped to a
198
+ # specific user's session_id. Profile chunks are exclusively surfaced via
199
+ # the explicit per-session collection.get(ids=[f"profile_{session_id}"])
200
+ # lookup below; allowing them through the cosine pass leaks another user's
201
+ # profile into the current session (live smoke test of 15 personas caught
202
+ # B4 retrieving profile chunks from smokeA_1, ki100_ve, smokeB_B2).
203
+ #
204
+ # We translate this to Chroma's `where` DSL using $ne (not-equals) on
205
+ # doc_type. Combined with optional policy_id / insurer_slug filters via
206
+ # $and so the existing comparison + per-policy Q&A flows still work.
207
+ _filter_clauses: list[dict] = [{"doc_type": {"$ne": "profile"}}]
208
  if policy_ids:
209
+ _filter_clauses.append({"policy_id": {"$in": policy_ids}})
210
  if insurer_slugs:
211
+ _filter_clauses.append({"insurer_slug": {"$in": insurer_slugs}})
212
+ if len(_filter_clauses) == 1:
213
+ where: dict = _filter_clauses[0]
214
+ else:
215
+ where = {"$and": _filter_clauses}
216
 
217
  collection = get_collection()
218
 
 
220
  res = collection.query(
221
  query_embeddings=[query_vec],
222
  n_results=effective_top_k,
223
+ where=where,
224
  )
225
 
226
  out: list[RetrievedChunk] = []
 
239
  if session_id:
240
  try:
241
  profile_chunk_id = f"profile_{session_id}"
242
+ # KI-102 — defence-in-depth. Filter by BOTH id AND session_id
243
+ # metadata so any future ID collision (or migration-era chunk
244
+ # written under a shared id) still can't leak another user's
245
+ # profile into this session's context.
246
  prof_res = collection.get(
247
  ids=[profile_chunk_id],
248
+ where={"session_id": session_id},
249
  include=["documents", "metadatas"],
250
  )
251
  if prof_res.get("ids"):
252
  p_doc = prof_res["documents"][0] if prof_res.get("documents") else ""
253
  p_meta = prof_res["metadatas"][0] if prof_res.get("metadatas") else {}
254
+ # Triple-check: even if Chroma returns a row, refuse it unless
255
+ # metadata.session_id matches. Belt + suspenders + parachute.
256
+ if p_doc and p_meta.get("session_id") == session_id:
257
  # Profile gets max score (1.0) so it always tops the context
258
  profile_chunk = _build_chunk(profile_chunk_id, p_doc, p_meta, 1.0)
259
  # Prepend; trim to top_k so we keep budget
tests/test_profile_rag_isolation.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
32
+ PYTHONPATH=$PWD .venv/bin/python -m pytest tests/test_profile_rag_isolation.py -v
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import asyncio
38
+ import sys
39
+ import unittest
40
+ import uuid
41
+ from pathlib import Path
42
+ from unittest import mock
43
+
44
+ _REPO_ROOT = Path(__file__).resolve().parent.parent
45
+ if str(_REPO_ROOT) not in sys.path:
46
+ sys.path.insert(0, str(_REPO_ROOT))
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # In-memory Chroma + stub embedder. We avoid touching the real
51
+ # settings.VECTORS_DIR so tests don't pollute the prod collection.
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def _make_ephemeral_collection():
56
+ """Return a fresh in-memory Chroma collection named 'policies'."""
57
+ import chromadb
58
+ from chromadb.config import Settings as ChromaSettings
59
+ client = chromadb.EphemeralClient(
60
+ settings=ChromaSettings(anonymized_telemetry=False),
61
+ )
62
+ # Use a unique name per call so parallel tests don't share state.
63
+ return client.get_or_create_collection(
64
+ name=f"policies_{uuid.uuid4().hex[:8]}",
65
+ metadata={"hnsw:space": "cosine"},
66
+ )
67
+
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
+
75
+ async def embed(self, texts, input_type="document"):
76
+ # Hash-based but stable: every "USER CONTEXT" doc maps near the same
77
+ # region of the unit sphere; that's the realistic case where two
78
+ # users' profile chunks both look like profile chunks to cosine.
79
+ vecs = []
80
+ for t in texts:
81
+ base = [0.0] * 8
82
+ if "USER CONTEXT" in t or "profile" in t.lower():
83
+ # All profile-flavoured text lands near vector [1, 0, ...]
84
+ base = [1.0, 0.05, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
85
+ elif "age" in t.lower() or "dependents" in t.lower():
86
+ base = [0.9, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
87
+ else:
88
+ # Generic policy text is far from the profile cluster
89
+ base = [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]
90
+ vecs.append(base)
91
+ return vecs
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Test cases
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ class TestProfileIsolation(unittest.TestCase):
100
+ """KI-102 — session 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],
115
+ embeddings=[vec],
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,
125
+ "chunk_idx": 0,
126
+ "local_path": "in-memory test profile",
127
+ }],
128
+ )
129
+
130
+ def _seed_policy(self, policy_id: str, text: str) -> None:
131
+ """Seed a generic non-profile chunk so the main retrieval pass
132
+ isn't empty (otherwise we're not actually testing the filter)."""
133
+ vec = asyncio.run(_StubEmbedder().embed([text]))[0]
134
+ self.coll.add(
135
+ ids=[policy_id],
136
+ documents=[text],
137
+ embeddings=[vec],
138
+ metadatas=[{
139
+ "policy_id": policy_id,
140
+ "insurer_slug": "test-insurer",
141
+ "policy_name": "Test Policy",
142
+ "doc_type": "policy",
143
+ "source_url": "",
144
+ "page_start": 1,
145
+ "page_end": 1,
146
+ "chunk_idx": 0,
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
154
+ # Clear the in-process cache so each test sees a fresh execution
155
+ retrieve_mod._RETRIEVAL_CACHE.clear()
156
+ with mock.patch.object(retrieve_mod, "get_collection", return_value=self.coll):
157
+ return asyncio.run(retrieve_mod.retrieve(
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
+ )
204
+ self._seed_policy("test_policy_1", "Generic policy text.")
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,
267
+ "chunk_idx": 0,
268
+ }],
269
+ )
270
+ self._seed_policy("test_policy_3", "Generic policy text.")
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
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Standalone upsert metadata test — no Chroma client; just verify the
288
+ # upsert builds metadata containing session_id.
289
+ # ---------------------------------------------------------------------------
290
+
291
+
292
+ class TestUpsertStampsSessionId(unittest.TestCase):
293
+ """KI-102.a — upsert_profile_chunk MUST write session_id into the
294
+ chunk's Chroma metadata. Without it, the retrieve filter can't
295
+ distinguish session A's profile from session B's."""
296
+
297
+ def test_upsert_writes_session_id_to_metadata(self):
298
+ from backend import profile_rag
299
+
300
+ captured: dict = {}
301
+
302
+ class _FakeColl:
303
+ def add(self, ids, documents, embeddings, metadatas):
304
+ captured["ids"] = ids
305
+ captured["metadatas"] = metadatas
306
+
307
+ def delete(self, where=None):
308
+ captured["deleted_where"] = where
309
+
310
+ class _FakeEmbedder:
311
+ async def embed(self, texts, input_type="document"):
312
+ return [[0.1] * 8 for _ in texts]
313
+
314
+ fake_coll = _FakeColl()
315
+ sid = f"test_{uuid.uuid4().hex[:6]}"
316
+ profile = {
317
+ "age": 32,
318
+ "dependents": "self_spouse",
319
+ "health_conditions": [],
320
+ "existing_cover_inr": 500000,
321
+ }
322
+
323
+ with mock.patch.object(profile_rag, "_get_collection", return_value=fake_coll), \
324
+ mock.patch("backend.providers.local_embeddings.LocalEmbeddings", _FakeEmbedder):
325
+ asyncio.run(profile_rag.upsert_profile_chunk(sid, profile))
326
+
327
+ self.assertIn("metadatas", captured, "upsert never called coll.add")
328
+ meta = captured["metadatas"][0]
329
+ self.assertEqual(
330
+ meta.get("session_id"), sid,
331
+ f"profile chunk metadata missing session_id. Got: {meta}",
332
+ )
333
+ self.assertEqual(meta.get("doc_type"), "profile")
334
+ self.assertEqual(captured["ids"], [f"profile_{sid}"])
335
+
336
+
337
+ if __name__ == "__main__":
338
+ unittest.main()