rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
119579e
Β·
1 Parent(s): cdf5ea5

fix(rag+admin): KI-112 hardening + KI-116 banner + admin copy

Browse files

KI-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 CHANGED
@@ -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-107 landed (orchestrator stability + profile-RAG session isolation + recommendation closer + graceful chat error handling).*
 
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).*
backend/admin.py CHANGED
@@ -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
backend/profile_rag.py CHANGED
@@ -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
 
frontend/src/app/page.tsx CHANGED
@@ -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 Β· LLM Control Panel</h2>
883
  <p className="text-xs text-[var(--muted-foreground)]">
884
- IP-gated. Only the home network IP can interact with chain reordering / probes.
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
tests/test_profile_rag_isolation.py CHANGED
@@ -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] * 8 for _ in texts]
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()