Spaces:
Sleeping
fix(rag+admin): KI-112 hardening + KI-116 banner + admin copy
Browse filesKI-112 β profile_rag.upsert_profile_chunk now refuses to write when:
- session_id is missing / empty / not a non-empty str (the actual root
cause: initial KI-102 deploy wrote `profile_anonymous` WITHOUT a
session_id metadata field, and that dangling row poisoned every
subsequent query via Chroma's plan executor β "Error finding id")
- embedding shape is not a 384-dim list of finite floats (defence
against future shape-mismatch corruption of the HNSW index)
Local Chroma DB at rag/vectors/chroma.sqlite3 already cleaned by the
KI-112 agent; corrupted copy backed up at
rag/_hf_dataset_backup/rag/vectors.corrupted.1778799730/.
KI-116 β admin /api/admin/health chain_credit_exhausted banner now
mirrors llm_health.is_credit_eligible's reset-window logic. Pre-fix,
NIM's 40-RPM rate signal (credits_remaining=0 after a spike) stayed
stale long after the 60s window cycled, so the banner contradicted
the per-model HEALTHY 100%-success status. Now: if credits_reset_at
has elapsed, treat as eligible (not exhausted), matching the elector.
Frontend admin panel header:
"Admin Β· LLM Control Panel" β "Admin Console"
"IP-gated. Only the home network IP can interact..." β
"Password-gated. Enter the admin password to view LLM health,
usage rollups, saved profiles, and chain controls."
(stale post-KI-097, IP gating was removed.)
Tests: 90 passed (existing + KI-102/107/112 isolation suite).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- CLAUDE.md +2 -1
- backend/admin.py +13 -0
- backend/profile_rag.py +37 -0
- frontend/src/app/page.tsx +2 -2
- tests/test_profile_rag_isolation.py +158 -1
|
@@ -44,6 +44,7 @@ Every LLM role is a `NimChainLLM` candidate pool, NOT a hardcoded single model.
|
|
| 44 |
- **Recommendation closer wired (KI-105, `8a58fa1`).** `RECOMMENDATION_CLOSER_PHRASES` frozenset ("show me the top 3", "rank", "pitch me", "compare X vs Y") classified as `recommendation` / `comparison` BEFORE the `FACT_FIND_TRIGGERS` check, so a fully-fact-found user can never get bounced back into fact-find. Persona prompt gets `RECOMMENDATION_CLOSER_ADDENDUM` with a strict 3-policy ranked-shortlist contract (3 policies, one-line rationale each, IRDAI disclaimer, no hedging). ADR-008 extended with closer-mode subsection.
|
| 45 |
- **Graceful TimeoutError + Exception on `/api/chat` (KI-106, `565bf31`).** `handle_turn(...)` wrapped in `asyncio.wait_for(45s)` with explicit `except asyncio.TimeoutError` + broad `except Exception`. Both return HTTP 200 with `source="graceful_timeout"` / `graceful_exception"` and an in-character recovery sentence instead of HTTP 500. Internal `logger.exception` still captures the full traceback for admin observability.
|
| 46 |
- **`_safe_collection_get` helper for Chroma (KI-107, `3a9a14f`).** Wraps every `collection.get(ids=[...])` and `collection.get(where=...)` call in `backend/profile_rag.py` in `try / except Exception`, returns `None` on miss with `logger.warning(...)`. Closes the KI-102 per-session profile lookup raising on never-existed sessions on HF Space (Chroma version-dependent behaviour). `None` return is treated identically to a `session_id` mismatch β fail-closed.
|
|
|
|
| 47 |
- **Chain budgets:** brain 20s Γ 35s total, fast-brain 12s Γ 22s total, judge 30s Γ 75s total. With KI-080 only PRIMARY + BACKUP consume budget in the common case β leaves headroom for KI-079 escalation. KI-084 per-phase httpx timeouts are nested inside these budgets.
|
| 48 |
- **STT/TTS/Translator** = Sarvam (Saarika v2.5 / Bulbul v2 / Sarvam-M). **Embeddings** = local BGE-small-en-v1.5.
|
| 49 |
- **Provider keys.** `NVIDIA_NIM_API_KEY` + `GROQ_API_KEY` + `OPENROUTER_API_KEY` required in `.env` (local) and HF Space environment (production β KI-081).
|
|
@@ -129,4 +130,4 @@ Three independent safety layers against ChromaDB HNSW bloat:
|
|
| 129 |
|
| 130 |
---
|
| 131 |
|
| 132 |
-
*Last reviewed 2026-05-15 β KI-101..KI-
|
|
|
|
| 44 |
- **Recommendation closer wired (KI-105, `8a58fa1`).** `RECOMMENDATION_CLOSER_PHRASES` frozenset ("show me the top 3", "rank", "pitch me", "compare X vs Y") classified as `recommendation` / `comparison` BEFORE the `FACT_FIND_TRIGGERS` check, so a fully-fact-found user can never get bounced back into fact-find. Persona prompt gets `RECOMMENDATION_CLOSER_ADDENDUM` with a strict 3-policy ranked-shortlist contract (3 policies, one-line rationale each, IRDAI disclaimer, no hedging). ADR-008 extended with closer-mode subsection.
|
| 45 |
- **Graceful TimeoutError + Exception on `/api/chat` (KI-106, `565bf31`).** `handle_turn(...)` wrapped in `asyncio.wait_for(45s)` with explicit `except asyncio.TimeoutError` + broad `except Exception`. Both return HTTP 200 with `source="graceful_timeout"` / `graceful_exception"` and an in-character recovery sentence instead of HTTP 500. Internal `logger.exception` still captures the full traceback for admin observability.
|
| 46 |
- **`_safe_collection_get` helper for Chroma (KI-107, `3a9a14f`).** Wraps every `collection.get(ids=[...])` and `collection.get(where=...)` call in `backend/profile_rag.py` in `try / except Exception`, returns `None` on miss with `logger.warning(...)`. Closes the KI-102 per-session profile lookup raising on never-existed sessions on HF Space (Chroma version-dependent behaviour). `None` return is treated identically to a `session_id` mismatch β fail-closed.
|
| 47 |
+
- **Chroma collection re-ingested + profile-write hardening (KI-112).** KI-111 wrapped `.query()` so the bot survived the corruption, but every embedding query was raising `InternalError: Error executing plan: Internal error: Error finding id` and silently returning empty retrieval β the bot was answering 208 policies' worth of Qs without access to any policy chunk. Root cause: a pre-KI-102 deploy wrote a `profile_anonymous` chunk with NO `session_id` metadata; that legacy row poisoned every later `coll.query(where={"doc_type": {"$ne": "profile"}})` and the damage spread across HNSW segments (full collection extraction surfaced 1580 / 7356 chunks across 148 policies as `Error getting embedding`). Fix: full re-ingest from `rag/corpus/` PDFs β clean `rag/vectors/` + two new write-time guards in `backend/profile_rag.py::upsert_profile_chunk` β (a) reject `session_id` that isn't a non-empty `str`, (b) reject any embedding whose length β `embedder.dimension` or that contains `None`. Both guards log a `WARNING` and return without writing, so a future model-drift or bad-input event can't re-poison HNSW. 4 new regression tests in `tests/test_profile_rag_isolation.py::TestUpsertRejectsBadInputs`. Repaired vectors uploaded to HF dataset `rohitsar567/insurance-bot-data` via `tools/upload_vectors_to_dataset.py` so the Space rebuild picks up the clean index. Old corrupted Chroma archived at `rag/_hf_dataset_backup/rag/vectors.corrupted.<ts>/`.
|
| 48 |
- **Chain budgets:** brain 20s Γ 35s total, fast-brain 12s Γ 22s total, judge 30s Γ 75s total. With KI-080 only PRIMARY + BACKUP consume budget in the common case β leaves headroom for KI-079 escalation. KI-084 per-phase httpx timeouts are nested inside these budgets.
|
| 49 |
- **STT/TTS/Translator** = Sarvam (Saarika v2.5 / Bulbul v2 / Sarvam-M). **Embeddings** = local BGE-small-en-v1.5.
|
| 50 |
- **Provider keys.** `NVIDIA_NIM_API_KEY` + `GROQ_API_KEY` + `OPENROUTER_API_KEY` required in `.env` (local) and HF Space environment (production β KI-081).
|
|
|
|
| 130 |
|
| 131 |
---
|
| 132 |
|
| 133 |
+
*Last reviewed 2026-05-15 β KI-101..KI-112 landed (orchestrator stability + profile-RAG session isolation + recommendation closer + graceful chat error handling + Chroma re-ingest + profile-write hardening).*
|
|
@@ -700,12 +700,25 @@ def _chain_summary(role: str, chains: dict[str, list[str]],
|
|
| 700 |
# is at-or-below its low-water mark. Chains with no signal at all are
|
| 701 |
# NOT flagged exhausted (cold-start should be permissive β election will
|
| 702 |
# try them and surface a real failure if any).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 703 |
any_signal = False
|
| 704 |
all_exhausted = True
|
| 705 |
for m in chain:
|
| 706 |
h = state.get(m)
|
| 707 |
if h is None or h.credits_remaining is None:
|
| 708 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 709 |
any_signal = True
|
| 710 |
if h.credits_remaining > (h.credits_low_water or 0.0):
|
| 711 |
all_exhausted = False
|
|
|
|
| 700 |
# is at-or-below its low-water mark. Chains with no signal at all are
|
| 701 |
# NOT flagged exhausted (cold-start should be permissive β election will
|
| 702 |
# try them and surface a real failure if any).
|
| 703 |
+
#
|
| 704 |
+
# KI-116 (2026-05-15) β mirror the `is_credit_eligible` reset-window logic.
|
| 705 |
+
# When credits_reset_at has elapsed, the LAST observed credits_remaining
|
| 706 |
+
# is stale (e.g., NIM 40-RPM cap from a spike 10 minutes ago says
|
| 707 |
+
# `remaining=0` but the 60s window has long since cycled). The elector
|
| 708 |
+
# treats elapsed-reset as permissive; the banner must match or the UI
|
| 709 |
+
# contradicts itself ("HEALTHY 100% success" + "credit-exhausted" at the
|
| 710 |
+
# same time, which user reported on the live admin panel).
|
| 711 |
any_signal = False
|
| 712 |
all_exhausted = True
|
| 713 |
for m in chain:
|
| 714 |
h = state.get(m)
|
| 715 |
if h is None or h.credits_remaining is None:
|
| 716 |
continue
|
| 717 |
+
# If the credit signal's reset window has elapsed, skip β the snapshot
|
| 718 |
+
# is stale and the elector will try this candidate next call.
|
| 719 |
+
if h.credits_reset_at is not None and now_mono >= h.credits_reset_at:
|
| 720 |
+
all_exhausted = False
|
| 721 |
+
break
|
| 722 |
any_signal = True
|
| 723 |
if h.credits_remaining > (h.credits_low_water or 0.0):
|
| 724 |
all_exhausted = False
|
|
@@ -134,9 +134,46 @@ async def upsert_profile_chunk(session_id: str, profile_dict: dict) -> None:
|
|
| 134 |
if not text or len(text) < 30:
|
| 135 |
return
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
embedder = LocalEmbeddings()
|
| 138 |
[vec] = await embedder.embed([text], input_type="document")
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
coll = _get_collection()
|
| 141 |
chunk_id = f"profile_{session_id}"
|
| 142 |
|
|
|
|
| 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 |
+
|
| 154 |
embedder = LocalEmbeddings()
|
| 155 |
[vec] = await embedder.embed([text], input_type="document")
|
| 156 |
|
| 157 |
+
# KI-112 (2026-05-15) β input guard 2: embedding must be a list of finite
|
| 158 |
+
# floats whose length matches the embedder's declared dimension. Pre-fix,
|
| 159 |
+
# an empty / None / mis-shaped embedding could be added to Chroma where it
|
| 160 |
+
# would silently corrupt HNSW (dangling pointer or shape mismatch). The
|
| 161 |
+
# corpus uses 384-dim BAAI/bge-small-en-v1.5; any other shape is a bug.
|
| 162 |
+
expected_dim = getattr(embedder, "dimension", None) or 384
|
| 163 |
+
if (
|
| 164 |
+
not isinstance(vec, (list, tuple))
|
| 165 |
+
or len(vec) != expected_dim
|
| 166 |
+
or any((v is None) for v in vec)
|
| 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 |
|
|
@@ -879,9 +879,9 @@ export default function Page() {
|
|
| 879 |
<div className="flex flex-col h-full">
|
| 880 |
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)] bg-[var(--card)]">
|
| 881 |
<div>
|
| 882 |
-
<h2 className="text-sm font-semibold">Admin
|
| 883 |
<p className="text-xs text-[var(--muted-foreground)]">
|
| 884 |
-
|
| 885 |
</p>
|
| 886 |
</div>
|
| 887 |
<button
|
|
|
|
| 879 |
<div className="flex flex-col h-full">
|
| 880 |
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)] bg-[var(--card)]">
|
| 881 |
<div>
|
| 882 |
+
<h2 className="text-sm font-semibold">Admin Console</h2>
|
| 883 |
<p className="text-xs text-[var(--muted-foreground)]">
|
| 884 |
+
Password-gated. Enter the admin password to view LLM health, usage rollups, saved profiles, and chain controls.
|
| 885 |
</p>
|
| 886 |
</div>
|
| 887 |
<button
|
|
@@ -460,8 +460,15 @@ class TestUpsertStampsSessionId(unittest.TestCase):
|
|
| 460 |
captured["deleted_where"] = where
|
| 461 |
|
| 462 |
class _FakeEmbedder:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
async def embed(self, texts, input_type="document"):
|
| 464 |
-
return [[0.1] *
|
| 465 |
|
| 466 |
fake_coll = _FakeColl()
|
| 467 |
sid = f"test_{uuid.uuid4().hex[:6]}"
|
|
@@ -486,5 +493,155 @@ class TestUpsertStampsSessionId(unittest.TestCase):
|
|
| 486 |
self.assertEqual(captured["ids"], [f"profile_{sid}"])
|
| 487 |
|
| 488 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
if __name__ == "__main__":
|
| 490 |
unittest.main()
|
|
|
|
| 460 |
captured["deleted_where"] = where
|
| 461 |
|
| 462 |
class _FakeEmbedder:
|
| 463 |
+
# KI-112 (2026-05-15) β the upsert path now validates that the
|
| 464 |
+
# embedding length matches embedder.dimension. Set both to 384 so
|
| 465 |
+
# the realistic-shape vector passes the shape check; the test's
|
| 466 |
+
# subject under scrutiny is the metadata stamping, not the shape
|
| 467 |
+
# guard (those have dedicated cases below).
|
| 468 |
+
dimension = 384
|
| 469 |
+
|
| 470 |
async def embed(self, texts, input_type="document"):
|
| 471 |
+
return [[0.1] * 384 for _ in texts]
|
| 472 |
|
| 473 |
fake_coll = _FakeColl()
|
| 474 |
sid = f"test_{uuid.uuid4().hex[:6]}"
|
|
|
|
| 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}
|
| 526 |
+
|
| 527 |
+
class _FakeColl:
|
| 528 |
+
def add(self, ids, documents, embeddings, metadatas):
|
| 529 |
+
captured["add_called"] = True
|
| 530 |
+
|
| 531 |
+
def delete(self, where=None):
|
| 532 |
+
captured["delete_called"] = True
|
| 533 |
+
|
| 534 |
+
class _FakeEmbedder:
|
| 535 |
+
dimension = 384
|
| 536 |
+
|
| 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 |
+
|
| 551 |
+
def test_upsert_rejects_mismatched_embedding_dim(self):
|
| 552 |
+
"""If the embedder somehow returns the wrong dim (model drift,
|
| 553 |
+
misconfig), upsert must NOT write it to Chroma."""
|
| 554 |
+
from backend import profile_rag
|
| 555 |
+
|
| 556 |
+
captured: dict = {"add_called": False}
|
| 557 |
+
|
| 558 |
+
class _FakeColl:
|
| 559 |
+
def add(self, ids, documents, embeddings, metadatas):
|
| 560 |
+
captured["add_called"] = True
|
| 561 |
+
|
| 562 |
+
def delete(self, where=None):
|
| 563 |
+
pass
|
| 564 |
+
|
| 565 |
+
class _BadDimEmbedder:
|
| 566 |
+
dimension = 384
|
| 567 |
+
|
| 568 |
+
async def embed(self, texts, input_type="document"):
|
| 569 |
+
# Wrong dim β 8-dim stub like other tests, but profile_rag
|
| 570 |
+
# expects 384.
|
| 571 |
+
return [[0.1] * 8 for _ in texts]
|
| 572 |
+
|
| 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(
|
| 580 |
+
captured["add_called"],
|
| 581 |
+
"upsert MUST refuse a mis-shaped embedding to prevent HNSW "
|
| 582 |
+
"corruption from a model drift event.",
|
| 583 |
+
)
|
| 584 |
+
|
| 585 |
+
def test_upsert_rejects_none_in_embedding(self):
|
| 586 |
+
from backend import profile_rag
|
| 587 |
+
|
| 588 |
+
captured: dict = {"add_called": False}
|
| 589 |
+
|
| 590 |
+
class _FakeColl:
|
| 591 |
+
def add(self, ids, documents, embeddings, metadatas):
|
| 592 |
+
captured["add_called"] = True
|
| 593 |
+
|
| 594 |
+
def delete(self, where=None):
|
| 595 |
+
pass
|
| 596 |
+
|
| 597 |
+
class _NoneVecEmbedder:
|
| 598 |
+
dimension = 384
|
| 599 |
+
|
| 600 |
+
async def embed(self, texts, input_type="document"):
|
| 601 |
+
vec = [0.1] * 384
|
| 602 |
+
vec[42] = None # one None value
|
| 603 |
+
return [vec for _ in texts]
|
| 604 |
+
|
| 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(
|
| 612 |
+
captured["add_called"],
|
| 613 |
+
"upsert MUST refuse a vector containing None values.",
|
| 614 |
+
)
|
| 615 |
+
|
| 616 |
+
def test_upsert_accepts_correct_shape(self):
|
| 617 |
+
"""Positive path β a well-formed 384-dim list must be persisted."""
|
| 618 |
+
from backend import profile_rag
|
| 619 |
+
|
| 620 |
+
captured: dict = {}
|
| 621 |
+
|
| 622 |
+
class _FakeColl:
|
| 623 |
+
def add(self, ids, documents, embeddings, metadatas):
|
| 624 |
+
captured["ids"] = ids
|
| 625 |
+
captured["embeddings"] = embeddings
|
| 626 |
+
|
| 627 |
+
def delete(self, where=None):
|
| 628 |
+
pass
|
| 629 |
+
|
| 630 |
+
class _GoodEmbedder:
|
| 631 |
+
dimension = 384
|
| 632 |
+
|
| 633 |
+
async def embed(self, texts, input_type="document"):
|
| 634 |
+
return [[0.1] * 384 for _ in texts]
|
| 635 |
+
|
| 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 |
+
|
| 646 |
if __name__ == "__main__":
|
| 647 |
unittest.main()
|