rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
f15911b
·
1 Parent(s): 7fdaa57

fix(arch+robustness): KI-225..KI-235 — Path B single-LLM rewrite + audit bundle

Browse files

Architectural simplification (KI-225, Path B) — replaces the two-brain
sales_brain/qa_brain handoff with a single Gemini Flash call per turn using
native function-calling. Kills 40+ scaffolding failure modes at once:
- No fact_find vs QA routing boundary (every confirmation/knowledge edge
case in should_route_to_fact_find disappears).
- No faithfulness gate rejecting natural phrasing ("yes, that sounds correct").
- No separate profile_extractor pass overwriting captured slots with null
(KI-091/094 root cause permanently fixed via save_profile_field tool guard).
- No rigid normalizer enum — accepts what Gemini emits, light coerce only.
- No 700-token JSON contract — Gemini function-calling separates tool calls
from prose, no truncation trade-off.

Feature-flagged: USE_SINGLE_BRAIN=true activates the new path; default off,
legacy orchestrator preserved as fallback. SingleBrainError falls back to
orchestrator transparently.

New files:
- backend/single_brain.py — Gemini function-calling loop, 5 iterations max,
25s per-call timeout, defensive synthesised reply if no text part emitted.
Uses raw HTTPX (no new pip dep) matching existing google_gemini_llm pattern.
- backend/brain_tools.py — save_profile_field / retrieve_policies /
mark_recommendation. Universal None-drop guard prevents KI-091/094-class
null overwrites. retrieve_policies threads session.profile through to
retrieval_filters.filter_pipeline for profile-fit + dedup + citation
grounding.
- backend/retrieval_filters.py — sidecar: apply_profile_filter (drops
senior-only when age<50, adult-only when age>=60, maternity if no female
adult/maternity goal, min/max_entry_age out of range), exact-UIN bypass,
empty_retrieval_guard, citation_grounding, dedup_by_policy.
- frontend/src/lib/voice_resilience.ts — retryPostTranscribe (3 attempts,
exp backoff, partial transcript preserved), AdaptiveNoiseFloor (rolling
EMA*4 + 0.005 clamped [0.02, 0.15], raises barge-in floor in noisy
rooms only), sample-rate-scaled ZCR band.

Path A patches (kept healthy as fallback):
- backend/orchestrator.py — confirmation routing fix (short replies after
slots complete route to fact_find), welcome-back pending_profile_recall
ordering, knowledge_question carve-out, _is_recommendation keyword
expansion ("good for me", "which one", "fits", "help me pick"),
empty-retrieval short-circuit before brain call, post-recommendation
ordinal routing (#1/#2/#3 → last_recommendation_ids[idx] policy filter),
policy-name reference injection fallback, followup_policy_id on
TurnResult.
- backend/sales_brain_normalizer.py — name garbage rejection (vowel
check + Mr/Dr/St stripping), dependents "self/single/unmarried/just
myself" aliases + substring fallthrough, budget min-floor 5K, income
min-floor 1L, income_band natural phrasings ("10-25L", "between 10
and 25 lakh", "above 25"), primary_goal phrasings ("first time",
"tax planning", "upgrade", "compare specific"), existing_cover phrasings
("5L employer", "no insurance", spelled-digit "ten lakh"),
health_conditions multi-value normalization ("BP" → "hypertension",
"BP and thyroid" → list, "type 2 diabetes" → "diabetes").
- backend/providers/google_gemini_llm.py — cache TTL refresh at 50min,
cache key partition by dynamic prefix, BrainProviderError normalization
(retryable=bool), timeout binding to per_tier - 2s.
- backend/providers/nvidia_nim_llm.py — connection pool singleton
(max_connections=10, max_keepalive=5), assert_family_coverage()
enforces no "unknown" family in chains, json_schema mirrored to
nvext.guided_json, chain ordering confirms nemotron-LAST invariant.
- backend/providers/openrouter_llm.py — free-pool stable ordering
(cheapest-first by len + alpha), is_free_model + enforce_free_pool
cost guard.
- backend/providers/tiered_brain_llm.py — per_tier_timeout default 15s
(was 30s; total 3-tier cap 45s).
- backend/llm_health.py — effective_status("stale") at 600s without
ping, _is_election_eligible rejects stale, structured election event
log on every primary/backup change.
- backend/faithfulness.py — allow-list for short confirmations / bot
self-references, citation_required gate emits soft_hint when numeric
claims lack [Source:N] marker, hallucinated_policy gate emits
cited_policies hint when reply names policy absent from retrieved set,
pre_recap_emitted bypass for confirmation regex.
- backend/admin.py — /api/admin/persona-drift + /api/admin/recommendation-history
endpoints; ADMIN_PASSWORD unset warning at module load.
- backend/session_state.py — last_recommendation_ids field (consumed by
ordinal routing + single_brain.mark_recommendation).

Voice fixes:
- frontend/src/lib/useStreamingVoice.ts — recognitionRef null + handler
unbind on stop (post-abort tail events can no longer mutate state),
teardownAudio on terminal mic errors (not-allowed/audio-capture),
triggerBargeIn signal via consumeBargeInSignal (one-shot read-and-clear),
ZCR sanity band [20, 250] in fftSize=2048 @ 48kHz units (rejects
keyboard typing / HVAC), userSpeechRms ceiling 0.15 + 1s wall-clock
decay (prevents threshold creep), pendingUtterance flush on stop()
before clear, silent-onend grace timer skip when no Web Speech text
AND no audio chunks (kills "no-speech" infinite extension), additive
layer of retryPostTranscribe + AdaptiveNoiseFloor + sample-rate-scaled
ZCR band + utterance flush on barge-in + onVoiceError callback.
- frontend/src/app/page.tsx — interruptBotAudio helper revokes blob URLs
+ clears src + calls audio.load(); wired into barge-in-abort event /
userPrefersLive OFF / send() start / PTT startRecording; mid-utterance
"⏸ paused" suffix on last assistant message (idempotent); codec
probe via MediaSource.isTypeSupported → X-Preferred-Codec header +
audio_mime response handling; live PTT interim transcript with 200ms
throttle + aria-live=polite + atomic clear on stop; final-transcript
500ms dedup window via lastFinalTextRef; backspace word-erase when
inputFromTranscriptRef set; mobile keyboard env(safe-area-inset-bottom);
empty-message guard.
- frontend/src/app/layout.tsx — viewport.viewportFit="cover" + min-h-[100dvh].
- frontend/src/lib/api.ts — postChat({preferred_codec}); ChatResponse.audio_mime.

Admin:
- frontend/public/admin/llm-control.html — stale-90s freshness badge with
1s tick footers, Persona Drift collapsible panel (newest-20 by last_seen,
red <50% / yellow 50-80% / green >=80% slot completeness), Recommendation
History collapsible panel (last 10 with selected/rejected/shown pills),
KI-200 2-table compliance re-verified.

Verification:
- python -m py_compile passes on all 14 backend files touched.
- npx tsc --noEmit passes on frontend.
- npx next build succeeds (X8 confirmed).
- assert_family_coverage() passes for current BRAIN_CHAIN/FAST_BRAIN_CHAIN/JUDGE_CHAIN.
- single_brain.handle_turn dry-run: 7 required slots save correctly,
profile_complete flips True at 7th, unknown field rejected, None value
rejected (no overwrite), mark_recommendation populates
session.last_recommendation_ids.
- Headless Chrome smoke test on admin: zero JS console errors.

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

backend/admin.py CHANGED
@@ -42,6 +42,22 @@ router = APIRouter()
42
  USAGE_TAIL_LINES = 1000
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def _password_ok(supplied: Optional[str]) -> bool:
46
  expected = os.environ.get("ADMIN_PASSWORD", "").strip()
47
  if not expected:
@@ -929,3 +945,169 @@ async def admin_llm_health(
929
  "recent_turns": recent,
930
  "snapshot_ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
931
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  USAGE_TAIL_LINES = 1000
43
 
44
 
45
+ # A5 — Audit fix #7: emit a console warning at import time when the admin
46
+ # password is unset. We don't break access (the password check below already
47
+ # returns False which 401s every request), but ops needs a loud signal that
48
+ # the gate is effectively unconfigured so deployments don't silently sit
49
+ # behind an unreachable admin surface.
50
+ if not os.environ.get("ADMIN_PASSWORD", "").strip():
51
+ import sys as _sys
52
+ print(
53
+ "[admin] WARNING: ADMIN_UNGATED — ADMIN_PASSWORD env var is empty. "
54
+ "All /api/admin/* requests will return 401. Set ADMIN_PASSWORD in "
55
+ "the deployment env to enable access.",
56
+ file=_sys.stderr,
57
+ flush=True,
58
+ )
59
+
60
+
61
  def _password_ok(supplied: Optional[str]) -> bool:
62
  expected = os.environ.get("ADMIN_PASSWORD", "").strip()
63
  if not expected:
 
945
  "recent_turns": recent,
946
  "snapshot_ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
947
  }
948
+
949
+
950
+ # ---------------------------------------------------------------------------
951
+ # A5 — Audit fix #4: /api/admin/persona-drift — slot-capture completeness
952
+ # for the last 20 personas. Six canonical slots: name, age, income_band,
953
+ # location_tier, primary_goal, health_conditions. <50% capture is flagged
954
+ # red on the frontend.
955
+ # ---------------------------------------------------------------------------
956
+
957
+ # Six canonical fact-find slots. Match the orchestrator's profile_extractor
958
+ # targets — adding/removing a slot here must mirror the next_question
959
+ # routing logic. Health is captured as a list (empty list counts as
960
+ # "asked but no conditions" → still a valid captured signal once the
961
+ # `asked` array contains the field name).
962
+ _PERSONA_DRIFT_SLOTS = ("name", "age", "income_band", "location_tier",
963
+ "primary_goal", "health_conditions")
964
+
965
+
966
+ def _slot_captured(profile: dict, slot: str, asked: list[str]) -> bool:
967
+ """A slot is 'captured' when the field has a non-empty value OR (for
968
+ health_conditions) when the user was asked and confirmed no conditions
969
+ (asked-list contains the slot but the list is empty → still a positive
970
+ answer the bot heard, not a missing signal)."""
971
+ v = profile.get(slot)
972
+ if slot == "health_conditions":
973
+ if isinstance(v, list) and len(v) > 0:
974
+ return True
975
+ if "health_conditions" in (asked or []):
976
+ return True
977
+ return False
978
+ return v not in (None, "", [], 0)
979
+
980
+
981
+ @router.get("/api/admin/persona-drift")
982
+ async def admin_persona_drift(
983
+ request: Request,
984
+ x_admin_password: Optional[str] = Header(default=None, alias="X-Admin-Password"),
985
+ ):
986
+ """Return slot-capture completeness for the last 20 personas, newest first.
987
+
988
+ Each row: { persona_id, name_display, last_seen, captured_slots,
989
+ completeness_pct, missing_slots }
990
+ The frontend highlights any row with completeness_pct < 50.
991
+ """
992
+ _check_admin(request, x_admin_password)
993
+ if not _PROFILES_DIR_FOR_DRIFT.exists():
994
+ return {"personas": [], "total": 0,
995
+ "snapshot_ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")}
996
+
997
+ rows: list[dict] = []
998
+ for p in _PROFILES_DIR_FOR_DRIFT.glob("*.json"):
999
+ try:
1000
+ raw = json.loads(p.read_text())
1001
+ except Exception:
1002
+ continue
1003
+ profile = raw.get("profile") or {}
1004
+ asked = profile.get("asked") or []
1005
+ captured = [s for s in _PERSONA_DRIFT_SLOTS if _slot_captured(profile, s, asked)]
1006
+ missing = [s for s in _PERSONA_DRIFT_SLOTS if s not in captured]
1007
+ rows.append({
1008
+ "persona_id": raw.get("persona_id") or raw.get("name_slug") or p.stem,
1009
+ "name_display": raw.get("name_display") or "—",
1010
+ "last_seen": raw.get("last_seen"),
1011
+ "captured_slots": captured,
1012
+ "missing_slots": missing,
1013
+ "completeness_pct": round(100.0 * len(captured) / len(_PERSONA_DRIFT_SLOTS), 1),
1014
+ "session_count": len(raw.get("sessions") or []),
1015
+ })
1016
+ # Newest first by last_seen (None sorts last)
1017
+ rows.sort(key=lambda r: (r["last_seen"] or ""), reverse=True)
1018
+ rows = rows[:20]
1019
+ return {
1020
+ "personas": rows,
1021
+ "total": len(rows),
1022
+ "slots": list(_PERSONA_DRIFT_SLOTS),
1023
+ "snapshot_ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
1024
+ }
1025
+
1026
+
1027
+ # Cached path resolver — re-uses profile_store's _PROFILES_DIR but we import
1028
+ # lazily to avoid a circular import at module top.
1029
+ def _resolve_profiles_dir() -> Path:
1030
+ from backend import profile_store
1031
+ return profile_store._PROFILES_DIR
1032
+
1033
+
1034
+ # Lazy-evaluated singleton — instantiate on first call. We can't reference
1035
+ # profile_store at module top because admin.py imports llm_health which
1036
+ # may not yet have its config wired during tests.
1037
+ class _LazyProfilesDir:
1038
+ def __init__(self) -> None:
1039
+ self._p: Optional[Path] = None
1040
+ def __getattr__(self, name: str):
1041
+ if self._p is None:
1042
+ self._p = _resolve_profiles_dir()
1043
+ return getattr(self._p, name)
1044
+ def exists(self) -> bool:
1045
+ if self._p is None:
1046
+ self._p = _resolve_profiles_dir()
1047
+ return self._p.exists()
1048
+ def glob(self, pat: str):
1049
+ if self._p is None:
1050
+ self._p = _resolve_profiles_dir()
1051
+ return self._p.glob(pat)
1052
+
1053
+
1054
+ _PROFILES_DIR_FOR_DRIFT = _LazyProfilesDir()
1055
+
1056
+
1057
+ # ---------------------------------------------------------------------------
1058
+ # A5 — Audit fix #5: /api/admin/recommendation-history — last 10 policy
1059
+ # recommendation events across all profiles, newest first.
1060
+ # ---------------------------------------------------------------------------
1061
+
1062
+ @router.get("/api/admin/recommendation-history")
1063
+ async def admin_recommendation_history(
1064
+ request: Request,
1065
+ x_admin_password: Optional[str] = Header(default=None, alias="X-Admin-Password"),
1066
+ ):
1067
+ """Return the last 10 policy-event entries across every profile,
1068
+ newest first.
1069
+
1070
+ Each row: { persona_id, name_display, event_type, policy_slug, insurer,
1071
+ event_at, session_id, outcome }
1072
+ outcome: 'selected' / 'rejected' / 'shown' (passthrough from event_type;
1073
+ callers may map 'shown' → 'abandoned' if no follow-up exists,
1074
+ but we leave the raw label so the operator can decide).
1075
+ """
1076
+ _check_admin(request, x_admin_password)
1077
+ events: list[dict] = []
1078
+ if not _PROFILES_DIR_FOR_DRIFT.exists():
1079
+ return {"events": [], "total": 0,
1080
+ "snapshot_ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")}
1081
+
1082
+ for p in _PROFILES_DIR_FOR_DRIFT.glob("*.json"):
1083
+ try:
1084
+ raw = json.loads(p.read_text())
1085
+ except Exception:
1086
+ continue
1087
+ profile = raw.get("profile") or {}
1088
+ persona_id = raw.get("persona_id") or raw.get("name_slug") or p.stem
1089
+ name_display = raw.get("name_display") or "—"
1090
+ for evt_type, field_name in (("shown", "shown_policies"),
1091
+ ("selected", "selected_policies"),
1092
+ ("rejected", "rejected_policies")):
1093
+ for entry in (profile.get(field_name) or []):
1094
+ events.append({
1095
+ "persona_id": persona_id,
1096
+ "name_display": name_display,
1097
+ "event_type": evt_type,
1098
+ "policy_slug": entry.get("policy_slug"),
1099
+ "insurer": entry.get("insurer"),
1100
+ "event_at": entry.get("event_at"),
1101
+ "session_id": entry.get("session_id"),
1102
+ "reason": entry.get("reason"),
1103
+ # Outcome label is the raw event_type — operator decides
1104
+ # what 'shown without follow-up' means in their context.
1105
+ "outcome": evt_type,
1106
+ })
1107
+ events.sort(key=lambda e: (e["event_at"] or ""), reverse=True)
1108
+ events = events[:10]
1109
+ return {
1110
+ "events": events,
1111
+ "total": len(events),
1112
+ "snapshot_ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
1113
+ }
backend/brain_tools.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tool functions for single_brain.py (Path B — single-LLM architecture).
2
+
3
+ The Gemini Flash model exposed in `backend/single_brain.py` calls these
4
+ three tools to (a) persist captured profile fields, (b) retrieve policy
5
+ chunks from Chroma, and (c) mark which policies it has recommended on
6
+ the current turn so follow-ups like "tell me more about #2" can resolve.
7
+
8
+ Each function:
9
+ * Takes plain JSON-serialisable inputs (str / int / list[str]).
10
+ * Returns a plain JSON-serialisable dict that gets fed back to the LLM
11
+ on the next iteration of the function-calling loop.
12
+ * Never raises — failures are surfaced via {"ok": False, "error": "..."}
13
+ so the LLM can decide whether to retry or recover.
14
+
15
+ The Gemini function-calling DSL (JSON Schema-flavoured) for these three
16
+ tools is generated by `single_brain.TOOL_SCHEMAS` from this module's
17
+ metadata.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from typing import Any, Optional
24
+
25
+ _log = logging.getLogger(__name__)
26
+
27
+
28
+ # ---- accepted fields for save_profile_field --------------------------------
29
+
30
+ # Mirrors the slot list in sales_brain._REQUIRED_FOR_READY plus the
31
+ # nice-to-have fields the LLM may capture opportunistically. `gender`
32
+ # is listed in the spec but is NOT a Profile dataclass field today —
33
+ # we silently no-op on it rather than rejecting (forward-compat).
34
+ _ACCEPTED_FIELDS = {
35
+ "name",
36
+ "age",
37
+ "dependents",
38
+ "location_tier",
39
+ "income_band",
40
+ "primary_goal",
41
+ "health_conditions",
42
+ "existing_cover_inr",
43
+ "budget_band",
44
+ "gender", # tolerated; not persisted unless Profile gains the field
45
+ }
46
+
47
+ # These are the slots the brain MUST capture before recommending — same
48
+ # list as sales_brain._REQUIRED_FOR_READY. Kept inline (not imported)
49
+ # to avoid coupling single_brain to sales_brain.
50
+ _REQUIRED_FOR_READY = (
51
+ "name",
52
+ "age",
53
+ "dependents",
54
+ "location_tier",
55
+ "income_band",
56
+ "primary_goal",
57
+ "health_conditions",
58
+ )
59
+
60
+
61
+ def _profile_complete(profile) -> bool:
62
+ """Return True when every slot in _REQUIRED_FOR_READY is non-empty on
63
+ the live Profile dataclass."""
64
+ for slot in _REQUIRED_FOR_READY:
65
+ val = getattr(profile, slot, None)
66
+ if val in (None, "", []):
67
+ return False
68
+ return True
69
+
70
+
71
+ # ---- save_profile_field ----------------------------------------------------
72
+
73
+ def save_profile_field(session, field: str, value: Any) -> dict:
74
+ """Validate + persist a captured profile field on session.profile.
75
+
76
+ Accepted fields: name, age, dependents, location_tier, income_band,
77
+ primary_goal, health_conditions, existing_cover_inr,
78
+ budget_band, gender (tolerated, may no-op).
79
+
80
+ Lightweight normalization:
81
+ - age: int, clamp to [0, 110]
82
+ - dependents: pass through `_normalize_dependents` when available,
83
+ else stringify.
84
+ - existing_cover_inr: parse via `_parse_inr_amount` when available,
85
+ else int-coerce.
86
+ - health_conditions: coerce to list[str], lowercase, strip empties.
87
+ - everything else: string pass-through (Gemini already emits
88
+ canonical values when prompted correctly).
89
+
90
+ Returns: {"saved": True, "field": ..., "value": ...,
91
+ "profile_complete": bool}
92
+ On unknown field: {"saved": False, "error": "unknown_field"}
93
+ """
94
+ if not isinstance(field, str) or not field:
95
+ return {"saved": False, "error": "missing_field_name"}
96
+
97
+ fld = field.strip().lower()
98
+ if fld not in _ACCEPTED_FIELDS:
99
+ return {"saved": False, "error": f"unknown_field:{field}"}
100
+
101
+ profile = session.profile
102
+ normalized: Any = value
103
+
104
+ try:
105
+ if fld == "age":
106
+ normalized = _coerce_age(value)
107
+ elif fld == "dependents":
108
+ normalized = _coerce_dependents(value)
109
+ elif fld == "health_conditions":
110
+ normalized = _coerce_health_conditions(value)
111
+ elif fld == "existing_cover_inr":
112
+ normalized = _coerce_existing_cover(value)
113
+ elif fld == "name":
114
+ normalized = (str(value).strip() if value is not None else None) or None
115
+ elif fld == "gender":
116
+ # Profile dataclass has no gender field today; silently
117
+ # accept + skip persistence so the LLM doesn't loop trying
118
+ # to save it. Forward-compat: when Profile gains gender,
119
+ # this branch just becomes `normalized = str(value).strip()`.
120
+ return {
121
+ "saved": False,
122
+ "field": fld,
123
+ "value": value,
124
+ "error": "field_not_on_profile_dataclass",
125
+ "profile_complete": _profile_complete(profile),
126
+ }
127
+ else:
128
+ # location_tier / income_band / primary_goal / budget_band — pass through
129
+ normalized = (str(value).strip() if value is not None else None) or None
130
+ except (TypeError, ValueError) as e:
131
+ return {
132
+ "saved": False,
133
+ "field": fld,
134
+ "value": value,
135
+ "error": f"normalize_failed:{type(e).__name__}:{e}",
136
+ }
137
+
138
+ # Drop None/empty so we don't overwrite a previously-captured slot
139
+ # with a Gemini turn that "didn't extract anything". Universal rule
140
+ # from KI-091/094 (extractor null overwrite).
141
+ if normalized in (None, "", []):
142
+ return {
143
+ "saved": False,
144
+ "field": fld,
145
+ "value": value,
146
+ "error": "normalized_empty",
147
+ "profile_complete": _profile_complete(profile),
148
+ }
149
+
150
+ if not hasattr(profile, fld):
151
+ return {
152
+ "saved": False,
153
+ "field": fld,
154
+ "value": value,
155
+ "error": "field_not_on_profile_dataclass",
156
+ "profile_complete": _profile_complete(profile),
157
+ }
158
+
159
+ setattr(profile, fld, normalized)
160
+ # Track that the brain has "asked" this field so the rest of the
161
+ # codebase's helpers (which inspect profile.asked) stay in sync.
162
+ try:
163
+ if fld not in getattr(profile, "asked", []):
164
+ profile.asked.append(fld)
165
+ except Exception: # noqa: BLE001 — best-effort bookkeeping
166
+ pass
167
+
168
+ return {
169
+ "saved": True,
170
+ "field": fld,
171
+ "value": normalized,
172
+ "profile_complete": _profile_complete(profile),
173
+ }
174
+
175
+
176
+ # ---- retrieve_policies -----------------------------------------------------
177
+
178
+ async def retrieve_policies(
179
+ query: str,
180
+ top_k: int = 8,
181
+ policy_filter_ids: Optional[list[str]] = None,
182
+ profile=None,
183
+ intent: str = "recommendation",
184
+ ) -> dict:
185
+ """Call the existing Chroma retriever and return policy chunks.
186
+
187
+ Returns:
188
+ {"chunks": [{policy_id, policy_name, insurer_slug, chunk_text,
189
+ doc_type, source_url, score, ...}, ...],
190
+ "count": N,
191
+ "query": query,
192
+ "guard": optional {reason, fallback} if filter pipeline says abort}
193
+
194
+ On failure: {"chunks": [], "count": 0, "error": "..."}.
195
+ """
196
+ if not isinstance(query, str) or not query.strip():
197
+ return {"chunks": [], "count": 0, "error": "empty_query"}
198
+
199
+ try:
200
+ from rag.retrieve import retrieve as _retrieve
201
+
202
+ chunks = await _retrieve(
203
+ query=query,
204
+ top_k=int(top_k) if top_k else 8,
205
+ policy_ids=policy_filter_ids or None,
206
+ )
207
+ except Exception as e: # noqa: BLE001 — return graceful empty
208
+ _log.warning(
209
+ "retrieve_policies failed (q=%r): %s: %s",
210
+ query[:120], type(e).__name__, str(e)[:200],
211
+ )
212
+ return {
213
+ "chunks": [],
214
+ "count": 0,
215
+ "error": f"{type(e).__name__}:{str(e)[:200]}",
216
+ }
217
+
218
+ raw: list[dict] = []
219
+ for c in chunks or []:
220
+ raw.append(
221
+ {
222
+ "chunk_id": getattr(c, "chunk_id", ""),
223
+ "policy_id": getattr(c, "policy_id", ""),
224
+ "policy_name": getattr(c, "policy_name", ""),
225
+ "insurer_slug": getattr(c, "insurer_slug", ""),
226
+ "doc_type": getattr(c, "doc_type", ""),
227
+ "source_url": getattr(c, "source_url", ""),
228
+ "chunk_text": (getattr(c, "text", "") or "")[:1200],
229
+ "score": float(getattr(c, "score", 0.0) or 0.0),
230
+ "min_entry_age": getattr(c, "min_entry_age", None),
231
+ "max_entry_age": getattr(c, "max_entry_age", None),
232
+ }
233
+ )
234
+
235
+ # X5 sidecar: apply profile-fit + citation-grounding + dedup. Skip when
236
+ # caller supplied an explicit policy_filter_ids (we already know which
237
+ # policies they want).
238
+ guard_signal = None
239
+ filtered = raw
240
+ if not policy_filter_ids:
241
+ try:
242
+ from backend.retrieval_filters import filter_pipeline
243
+ filtered, guard_signal = filter_pipeline(
244
+ raw, profile=profile, query=query, intent=intent,
245
+ )
246
+ except Exception as e: # noqa: BLE001 — pipeline must never break retrieval
247
+ _log.warning("retrieval_filters.filter_pipeline failed: %s", e)
248
+ filtered = raw
249
+
250
+ out = {
251
+ "chunks": filtered,
252
+ "count": len(filtered),
253
+ "query": query,
254
+ }
255
+ if guard_signal is not None:
256
+ out["guard"] = guard_signal
257
+ return out
258
+
259
+
260
+ # ---- mark_recommendation ---------------------------------------------------
261
+
262
+ def mark_recommendation(
263
+ session,
264
+ policy_ids: list[str],
265
+ is_final: bool = False,
266
+ ) -> dict:
267
+ """Persist the policies just recommended so follow-up turns can resolve
268
+ references like "tell me about #2".
269
+
270
+ Sets `session.last_recommendation_ids = policy_ids` (the same field the
271
+ orchestrator already maintains for follow-up routing — KI-224 / KI-228).
272
+ `is_final` is accepted for forward-compat (when the session grows a
273
+ `closed` field); today it's logged but not persisted.
274
+
275
+ Returns: {"recorded": True, "policy_ids": [...], "is_final": bool}
276
+ """
277
+ if not isinstance(policy_ids, list):
278
+ return {"recorded": False, "error": "policy_ids_not_list"}
279
+
280
+ # Coerce + dedupe while preserving order.
281
+ seen: set[str] = set()
282
+ cleaned: list[str] = []
283
+ for pid in policy_ids:
284
+ s = str(pid).strip()
285
+ if s and s not in seen:
286
+ seen.add(s)
287
+ cleaned.append(s)
288
+
289
+ try:
290
+ session.last_recommendation_ids = cleaned
291
+ except Exception as e: # noqa: BLE001
292
+ return {
293
+ "recorded": False,
294
+ "error": f"setattr_failed:{type(e).__name__}:{e}",
295
+ }
296
+
297
+ if is_final and hasattr(session, "closed"):
298
+ try:
299
+ session.closed = True # type: ignore[attr-defined]
300
+ except Exception: # noqa: BLE001
301
+ pass
302
+
303
+ return {
304
+ "recorded": True,
305
+ "policy_ids": cleaned,
306
+ "is_final": bool(is_final),
307
+ }
308
+
309
+
310
+ # ---- private normalizers ---------------------------------------------------
311
+
312
+ def _coerce_age(value: Any) -> Optional[int]:
313
+ """int(value), clamped to [0, 110]. Empty / non-numeric → None."""
314
+ if value is None:
315
+ return None
316
+ try:
317
+ if isinstance(value, bool): # bool is an int subclass — block first
318
+ return None
319
+ n = int(value)
320
+ except (TypeError, ValueError):
321
+ # Try string parse — Gemini sometimes emits "29" as a JSON string
322
+ try:
323
+ n = int(str(value).strip())
324
+ except (TypeError, ValueError):
325
+ return None
326
+ if n < 0:
327
+ n = 0
328
+ if n > 110:
329
+ n = 110
330
+ return n
331
+
332
+
333
+ def _coerce_dependents(value: Any) -> Optional[str]:
334
+ """Delegate to sales_brain_normalizer when importable; else stringify."""
335
+ if value is None:
336
+ return None
337
+ try:
338
+ from backend.sales_brain_normalizer import _normalize_dependents
339
+
340
+ # The normalizer signature is (value, schema); pass an empty dict
341
+ # so it falls back to its built-in canonical-string mapping.
342
+ return _normalize_dependents(value, {})
343
+ except Exception: # noqa: BLE001 — best-effort
344
+ s = str(value).strip()
345
+ return s or None
346
+
347
+
348
+ def _coerce_existing_cover(value: Any) -> Optional[int]:
349
+ """Parse INR amounts like "5L" / "5 lakh" / 500000 via the canonical
350
+ parser. Numeric pass-throughs are clamped to >= 0.
351
+ """
352
+ if value is None:
353
+ return None
354
+ if isinstance(value, bool):
355
+ return None
356
+ if isinstance(value, (int, float)):
357
+ n = int(value)
358
+ return max(0, n)
359
+ try:
360
+ from backend.needs_finder import _parse_inr_amount
361
+
362
+ parsed = _parse_inr_amount(str(value))
363
+ if parsed is not None:
364
+ return max(0, int(parsed))
365
+ except Exception: # noqa: BLE001
366
+ pass
367
+ # Last-ditch: strip non-digits
368
+ try:
369
+ digits = "".join(ch for ch in str(value) if ch.isdigit())
370
+ if digits:
371
+ return max(0, int(digits))
372
+ except Exception: # noqa: BLE001
373
+ pass
374
+ return None
375
+
376
+
377
+ def _coerce_health_conditions(value: Any) -> Optional[list[str]]:
378
+ """Always return list[str] lowercase, stripped, empties dropped."""
379
+ if value is None:
380
+ return None
381
+ if isinstance(value, str):
382
+ # Gemini sometimes emits comma-joined strings instead of a list.
383
+ items = [t.strip() for t in value.split(",")]
384
+ elif isinstance(value, (list, tuple)):
385
+ items = [str(t).strip() for t in value]
386
+ else:
387
+ items = [str(value).strip()]
388
+ cleaned = [t.lower() for t in items if t]
389
+ # "none" / "no" → empty list (user explicitly said no conditions).
390
+ cleaned = [t for t in cleaned if t not in {"none", "no", "n/a", "na", "nil"}]
391
+ return cleaned
392
+
393
+
394
+ __all__ = [
395
+ "save_profile_field",
396
+ "retrieve_policies",
397
+ "mark_recommendation",
398
+ ]
backend/faithfulness.py CHANGED
@@ -59,12 +59,116 @@ MIN_TOP_SCORE = 0.18 # below this we refuse outright (BGE-small cosine similari
59
  MIN_AVG_SCORE = 0.22 # average of top 5 must be above this
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  @dataclass
63
  class FaithfulnessVerdict:
64
  passed: bool
65
  reasons: list[str] = field(default_factory=list) # gate names that failed
66
  unsupported_claims: list[str] = field(default_factory=list)
67
  suggested_reply: Optional[str] = None # what to show user if blocked
 
68
 
69
 
70
  # ============================================================================
@@ -90,6 +194,11 @@ def _gate_retrieval_floor(chunks: list[RetrievedChunk]) -> tuple[bool, str]:
90
  # Match [Source: <something>] or [Regulation: <something>] patterns
91
  CITATION_PATTERN = re.compile(r"\[(?:Source|Regulation):\s*([^\]]+)\]", flags=re.IGNORECASE)
92
 
 
 
 
 
 
93
 
94
  def _gate_citation_integrity(reply: str, chunks: list[RetrievedChunk]) -> tuple[bool, list[str]]:
95
  """Every cited policy name must be one we actually retrieved."""
@@ -120,6 +229,112 @@ def _gate_citation_integrity(reply: str, chunks: list[RetrievedChunk]) -> tuple[
120
  return True, []
121
 
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  # ============================================================================
124
  # Gate 3 — NUMERIC GROUNDING
125
  # ============================================================================
@@ -283,15 +498,36 @@ async def check_faithfulness(
283
  user_text: str = "",
284
  run_llm_judge: bool = True,
285
  brain_model_used: Optional[str] = None,
 
286
  ) -> FaithfulnessVerdict:
287
  """Run all gates. Return verdict with reasons + a safe reply to show user if blocked.
288
 
289
  `brain_model_used` is forwarded to Gate 4 so the judge can never be the
290
  same model (or same family) as the brain that produced `reply` —
291
  enforces the cross-grading independence invariant.
 
 
 
 
 
292
  """
293
  verdict = FaithfulnessVerdict(passed=True)
294
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  # Gate 1 — retrieval floor
296
  ok1, msg1 = _gate_retrieval_floor(chunks)
297
  if not ok1:
@@ -310,6 +546,29 @@ async def check_faithfulness(
310
  verdict.passed = False
311
  verdict.reasons.extend(bad_citations)
312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  # Gate 3 — numeric grounding
314
  ok3, bad_nums = _gate_numeric_grounding(reply, chunks)
315
  if not ok3:
 
59
  MIN_AVG_SCORE = 0.22 # average of top 5 must be above this
60
 
61
 
62
+ # ============================================================================
63
+ # A3 — Allow-list for non-content replies that must never trigger rejection.
64
+ # These are confirmations, fillers, and bot self-references that aren't
65
+ # factual claims, so the citation/grounding/judge gates would only ever
66
+ # produce false positives on them.
67
+ # ============================================================================
68
+
69
+ _ALLOWLIST_EXACT = {
70
+ # Single-word confirmations / fillers
71
+ "yes", "yeah", "yep", "yup", "yes.", "yeah.",
72
+ "ok", "okay", "ok.", "okay.",
73
+ "sure", "sure.", "right", "right.",
74
+ "correct", "correct.", "exactly", "exactly.",
75
+ "no", "no.", "nope", "nope.",
76
+ "thanks", "thanks.", "thank you", "thank you.",
77
+ }
78
+
79
+ # Multi-word allow-list phrases — substring match is enough because the
80
+ # entire reply is short. These are the canonical confirmation / hedge
81
+ # patterns the brain emits during fact-find recap turns.
82
+ _ALLOWLIST_SUBSTRINGS = (
83
+ "yes, that sounds correct",
84
+ "yes that sounds correct",
85
+ "yes please",
86
+ "let me think about it",
87
+ "compare them",
88
+ "got it",
89
+ "noted",
90
+ "understood",
91
+ "sounds good",
92
+ "makes sense",
93
+ )
94
+
95
+ # Bot self-reference tokens. These are stylistic, not factual claims; the
96
+ # legacy citation gate sometimes flagged a reply that opened with "Let me
97
+ # suggest..." as missing a citation because the prose itself made no claim.
98
+ _BOT_SELFREF_RE = re.compile(
99
+ r"\b(i (?:can|will|would|could|think|am|'ll|'d|'ve)|"
100
+ r"let me (?:suggest|think|check|recap|confirm|walk|note|share)|"
101
+ r"the bot|as your advisor|in my view|here's what)\b",
102
+ flags=re.IGNORECASE,
103
+ )
104
+
105
+ # Profile-recap confirmation regex — when the prior assistant turn ended in
106
+ # a recap ("Let me confirm: 35yo, family floater, ₹10L cover, metro tier...
107
+ # does that look right?") and the user replies with a yes/no/correction, the
108
+ # next bot turn typically just acknowledges. That acknowledgement is
109
+ # metadata, not a content claim, and must bypass faithfulness entirely.
110
+ _CONFIRMATION_RE = re.compile(
111
+ r"^\s*(?:"
112
+ r"yes|yeah|yep|yup|"
113
+ r"no|nope|"
114
+ r"ok(?:ay)?|sure|right|correct|exactly|"
115
+ r"that(?:'s| is| sounds)? (?:right|correct|fine|good)|"
116
+ r"sounds (?:right|good|correct|fine)|"
117
+ r"thanks?|thank you"
118
+ r")\b",
119
+ flags=re.IGNORECASE,
120
+ )
121
+
122
+
123
+ def _is_allowlisted_reply(reply: str) -> bool:
124
+ """Return True if `reply` is a non-content message that must skip
125
+ faithfulness gating. Confirmations, single-word fillers, and short bot
126
+ self-references count as non-content."""
127
+ if not reply:
128
+ return True
129
+ stripped = reply.strip().lower()
130
+ if not stripped:
131
+ return True
132
+ if stripped in _ALLOWLIST_EXACT:
133
+ return True
134
+ # Short replies are inspected for allowlist substrings + self-references.
135
+ if len(stripped) < 80:
136
+ for needle in _ALLOWLIST_SUBSTRINGS:
137
+ if needle in stripped:
138
+ return True
139
+ # Pure bot self-reference w/ no numeric claim: allow.
140
+ if _BOT_SELFREF_RE.search(stripped) and not _has_numeric_claim(stripped):
141
+ return True
142
+ return False
143
+
144
+
145
+ def _is_confirmation_response(text: str) -> bool:
146
+ """User-text or bot-reply matches the confirmation regex."""
147
+ if not text:
148
+ return False
149
+ return bool(_CONFIRMATION_RE.match(text.strip()))
150
+
151
+
152
+ def _has_numeric_claim(text: str) -> bool:
153
+ """Quick test: does this reply make any numeric / monetary / percentage
154
+ claim that would need a citation?"""
155
+ if not text:
156
+ return False
157
+ return bool(
158
+ RUPEE_RE.search(text)
159
+ or PERCENT_RE.search(text)
160
+ or DURATION_RE.search(text)
161
+ or re.search(r"\b\d{4,}\b", text) # bare 4+ digit numbers (sum insured)
162
+ )
163
+
164
+
165
  @dataclass
166
  class FaithfulnessVerdict:
167
  passed: bool
168
  reasons: list[str] = field(default_factory=list) # gate names that failed
169
  unsupported_claims: list[str] = field(default_factory=list)
170
  suggested_reply: Optional[str] = None # what to show user if blocked
171
+ soft_hint: Optional[dict] = None # structured guidance for orchestrator retry
172
 
173
 
174
  # ============================================================================
 
194
  # Match [Source: <something>] or [Regulation: <something>] patterns
195
  CITATION_PATTERN = re.compile(r"\[(?:Source|Regulation):\s*([^\]]+)\]", flags=re.IGNORECASE)
196
 
197
+ # A3 — inline-style citation marker: [policy_id:chunk_offset]
198
+ # e.g. "the sum insured is ₹10L [POL/00X:7]". This is the preferred shape
199
+ # for numeric claims because it points back to the exact chunk.
200
+ INLINE_CITE_RE = re.compile(r"\[([A-Z0-9_/\-\.]{3,}):(\d+)\]")
201
+
202
 
203
  def _gate_citation_integrity(reply: str, chunks: list[RetrievedChunk]) -> tuple[bool, list[str]]:
204
  """Every cited policy name must be one we actually retrieved."""
 
229
  return True, []
230
 
231
 
232
+ # ============================================================================
233
+ # Gate 2b — CITATION REQUIRED FOR NUMERIC CLAIMS (A3)
234
+ # ============================================================================
235
+
236
+ def _gate_citation_required_for_numerics(
237
+ reply: str, chunks: list[RetrievedChunk]
238
+ ) -> tuple[bool, Optional[dict]]:
239
+ """If the reply makes a numeric / monetary / percentage / sum-insured
240
+ claim, require at least one citation marker — either the legacy
241
+ [Source: …] form OR the inline [policy_id:chunk_offset] form.
242
+
243
+ Returns (passed, soft_hint). soft_hint is a structured dict the
244
+ orchestrator can use to re-prompt the brain with a stricter constraint.
245
+ """
246
+ if not _has_numeric_claim(reply):
247
+ return True, None
248
+
249
+ has_source_cite = bool(CITATION_PATTERN.search(reply))
250
+ has_inline_cite = bool(INLINE_CITE_RE.search(reply))
251
+ if has_source_cite or has_inline_cite:
252
+ return True, None
253
+
254
+ # No citation accompanying a numeric claim — surface a structured hint
255
+ # so the orchestrator can retry with an explicit cite-required prompt.
256
+ expected_pid = chunks[0].policy_id if chunks else "POL/00X"
257
+ expected_offset = chunks[0].chunk_idx if chunks else 0
258
+ return False, {
259
+ "reason": "missing_citation",
260
+ "expected": f"[{expected_pid}:{expected_offset}]",
261
+ }
262
+
263
+
264
+ # ============================================================================
265
+ # Gate 2c — HALLUCINATED POLICY NAME DETECTOR (A3)
266
+ # ============================================================================
267
+
268
+ # A policy name in the reply that does NOT appear in any retrieved chunk is
269
+ # the textbook hallucination class. Detection: match common policy-name
270
+ # patterns ("X Health Plus", "Care Supreme", etc.) and check each against
271
+ # the retrieved set.
272
+
273
+ _POLICY_NAME_HINT_RE = re.compile(
274
+ r"\b([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z0-9+]+){1,4})\b"
275
+ )
276
+
277
+ # Common English bigrams that look like policy names but aren't. Used as a
278
+ # negative filter so we don't flag e.g. "United States" as a fake policy.
279
+ _NON_POLICY_TOKENS = {
280
+ "united states", "new delhi", "good morning", "thank you", "hello there",
281
+ "let me", "i think", "i would", "i can", "i will", "you can", "you should",
282
+ "the bot", "your advisor", "the policy", "this policy", "that policy",
283
+ "hi there", "in fact", "as well", "of course", "the same", "no problem",
284
+ "for example", "based on", "according to",
285
+ }
286
+
287
+
288
+ def _gate_hallucinated_policy(
289
+ reply: str, chunks: list[RetrievedChunk]
290
+ ) -> tuple[bool, list[str], Optional[dict]]:
291
+ """Flag responses that name a policy not in the retrieved chunk set.
292
+
293
+ Returns (passed, hallucinated_names, soft_hint).
294
+ """
295
+ if not chunks:
296
+ return True, [], None
297
+
298
+ valid_names = {c.policy_name.lower().strip() for c in chunks if c.policy_name}
299
+ valid_insurers = {c.insurer_slug.lower().strip() for c in chunks if c.insurer_slug}
300
+
301
+ candidates = _POLICY_NAME_HINT_RE.findall(reply or "")
302
+ hallucinated: list[str] = []
303
+ for cand in candidates:
304
+ cl = cand.lower().strip()
305
+ if cl in _NON_POLICY_TOKENS:
306
+ continue
307
+ # Skip candidates without policy-ish keywords
308
+ if not any(
309
+ kw in cl for kw in (
310
+ "health", "care", "supreme", "plus", "shield", "star",
311
+ "optima", "secure", "guard", "assure", "cover", "medi",
312
+ "wellness", "protect", "active", "smart", "elite", "premier",
313
+ )
314
+ ):
315
+ continue
316
+ if any(cl in vn or vn in cl for vn in valid_names if len(vn) >= 4):
317
+ continue
318
+ if any(slug in cl for slug in valid_insurers if len(slug) >= 4):
319
+ continue
320
+ hallucinated.append(cand)
321
+
322
+ if not hallucinated:
323
+ return True, [], None
324
+
325
+ cited_policies = sorted({c.policy_id for c in chunks if c.policy_id})
326
+ soft_hint = {
327
+ "reason": "hallucinated_policy",
328
+ "hallucinated": hallucinated,
329
+ "cited_policies": cited_policies,
330
+ "instruction": (
331
+ "Retry with explicit cited_policies constraint: only mention "
332
+ f"policies whose policy_id is in {cited_policies}."
333
+ ),
334
+ }
335
+ return False, hallucinated, soft_hint
336
+
337
+
338
  # ============================================================================
339
  # Gate 3 — NUMERIC GROUNDING
340
  # ============================================================================
 
498
  user_text: str = "",
499
  run_llm_judge: bool = True,
500
  brain_model_used: Optional[str] = None,
501
+ pre_recap_emitted: bool = False,
502
  ) -> FaithfulnessVerdict:
503
  """Run all gates. Return verdict with reasons + a safe reply to show user if blocked.
504
 
505
  `brain_model_used` is forwarded to Gate 4 so the judge can never be the
506
  same model (or same family) as the brain that produced `reply` —
507
  enforces the cross-grading independence invariant.
508
+
509
+ `pre_recap_emitted` — set by the orchestrator when the previous bot
510
+ turn ended with a profile recap. When True AND the current user-text
511
+ or reply is a confirmation token, the entire faithfulness pipeline
512
+ is bypassed (this is metadata, not a content claim).
513
  """
514
  verdict = FaithfulnessVerdict(passed=True)
515
 
516
+ # A3 — PRE-RECAP CONFIRMATION BYPASS. When the bot just emitted a profile
517
+ # recap, the user's "yes"/"correct"/etc. plus the bot's acknowledgement
518
+ # are pure metadata. Running citation/grounding gates over those yields
519
+ # only false positives.
520
+ if pre_recap_emitted and (
521
+ _is_confirmation_response(user_text) or _is_confirmation_response(reply)
522
+ ):
523
+ return verdict # auto-pass
524
+
525
+ # A3 — ALLOW-LIST: short bot replies that aren't factual claims at all
526
+ # (yes, ok, "let me think about it", "compare them", pure bot self-ref)
527
+ # never reach the citation/grounding gates.
528
+ if _is_allowlisted_reply(reply):
529
+ return verdict # auto-pass
530
+
531
  # Gate 1 — retrieval floor
532
  ok1, msg1 = _gate_retrieval_floor(chunks)
533
  if not ok1:
 
546
  verdict.passed = False
547
  verdict.reasons.extend(bad_citations)
548
 
549
+ # Gate 2b — numeric claim must be accompanied by a citation marker.
550
+ # Emit a structured soft-hint so the orchestrator can retry with the
551
+ # exact expected citation shape.
552
+ ok2b, cite_hint = _gate_citation_required_for_numerics(reply, chunks)
553
+ if not ok2b:
554
+ verdict.passed = False
555
+ verdict.reasons.append("gate2b_missing_citation_for_numeric")
556
+ verdict.soft_hint = cite_hint
557
+
558
+ # Gate 2c — hallucinated policy name. Flag policies named in the reply
559
+ # that aren't in the retrieved chunk set. Surface a soft hint that the
560
+ # orchestrator can pass back to the brain as a `cited_policies=[...]`
561
+ # constraint on retry.
562
+ ok2c, hallucinated_names, hp_hint = _gate_hallucinated_policy(reply, chunks)
563
+ if not ok2c:
564
+ verdict.passed = False
565
+ verdict.reasons.append(
566
+ f"gate2c_hallucinated_policy: {', '.join(hallucinated_names)}"
567
+ )
568
+ verdict.unsupported_claims.extend(hallucinated_names)
569
+ # If we already have a soft hint from 2b, prefer 2c (it's more actionable).
570
+ verdict.soft_hint = hp_hint or verdict.soft_hint
571
+
572
  # Gate 3 — numeric grounding
573
  ok3, bad_nums = _gate_numeric_grounding(reply, chunks)
574
  if not ok3:
backend/llm_health.py CHANGED
@@ -93,6 +93,13 @@ HEALTH_FILE.parent.mkdir(parents=True, exist_ok=True)
93
  # of a pool degradation (more than fast enough at our chat volume) and
94
  # drops probe-driven Groq spend to ~3K/day baseline (well inside quota).
95
  PROBE_INTERVAL_SEC = 300
 
 
 
 
 
 
 
96
  # KI-084 — completion size on probes cut from 5 → 1. The probe only
97
  # needs a non-empty 200 response to mark a candidate healthy; we never
98
  # parse the body content. max_tokens=1 keeps the same response shape +
@@ -104,6 +111,11 @@ PROBE_HISTORY_LEN = 5 # rolling window for success_rate signal
104
  HEALTHY_PROBE_AGE_SEC = 600 # KI-084 — election candidates need a probe
105
  # within the last 600s (tracks 300s cadence
106
  # plus headroom for one missed tick).
 
 
 
 
 
107
  DEGRADED_WINDOW_SEC = 30 # report_failure sidelines a model this long
108
  # for transient failures (timeout / 5xx).
109
  # KI-084 — rate-limit failures (HTTP 429 + provider 'RateLimit' bodies) are
@@ -200,7 +212,12 @@ def provider_of(model_id: str) -> str:
200
  @dataclass
201
  class ModelHealth:
202
  model: str
203
- status: str = "unknown" # 'healthy' | 'degraded' | 'down' | 'unknown'
 
 
 
 
 
204
  last_success_at: Optional[str] = None
205
  last_failure_at: Optional[str] = None
206
  last_error: Optional[str] = None
@@ -358,6 +375,25 @@ def _score(h: ModelHealth) -> float:
358
  return (1.0 / max(50, h.latency_ms)) * _success_rate(h)
359
 
360
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  def _is_election_eligible(h: ModelHealth, now_mono: float) -> bool:
362
  """A candidate is electable when:
363
  - status is healthy (or degraded with a recent success)
@@ -365,6 +401,9 @@ def _is_election_eligible(h: ModelHealth, now_mono: float) -> bool:
365
  - it is NOT currently in the degraded-window sin-bin
366
  - KI-085 (2026-05-15): it has credits remaining above its low-water
367
  mark, OR no credit signal yet (cold-start = permissive).
 
 
 
368
  """
369
  if h.degraded_until_monotonic > now_mono:
370
  return False
@@ -375,6 +414,8 @@ def _is_election_eligible(h: ModelHealth, now_mono: float) -> bool:
375
  return False
376
  if h.latency_ms is None:
377
  return False
 
 
378
  if not _has_credits(h, now_mono):
379
  logger.info(
380
  "election: skipping %s — credits %s/%s below water %s",
@@ -422,6 +463,57 @@ def _ranked_candidates(chain_name: str) -> list[ModelHealth]:
422
  return [h for _, h in eligible]
423
 
424
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  def get_primary(chain_name: str) -> Optional[str]:
426
  """KI-201 (2026-05-15) — elect by CHAIN ORDER, not latency score.
427
 
@@ -440,9 +532,15 @@ def get_primary(chain_name: str) -> Optional[str]:
440
  Latency is no longer used for primary/backup selection — eligibility
441
  filtering still uses the rolling probe state, but ordering is
442
  purely chain-positional.
 
 
 
 
 
443
  """
444
  chain = _chain_for(chain_name)
445
  if not chain:
 
446
  return None
447
  # _ranked_candidates returns the eligibility-filtered set (probe-fresh,
448
  # not in sin-bin, credit-not-exhausted, healthy). Reduce to a set for
@@ -450,7 +548,9 @@ def get_primary(chain_name: str) -> Optional[str]:
450
  eligible_models = {h.model for h in _ranked_candidates(chain_name)}
451
  for model in chain:
452
  if model in eligible_models:
 
453
  return model
 
454
  return None # nothing eligible
455
 
456
 
@@ -465,9 +565,13 @@ def get_backup(chain_name: str) -> Optional[str]:
465
  Meta → NVIDIA), so walking past the primary in chain order naturally
466
  preserves the cross-family backup invariant KI-087 used to enforce
467
  via provider_of().
 
 
 
468
  """
469
  chain = _chain_for(chain_name)
470
  if not chain:
 
471
  return None
472
  eligible_models = {h.model for h in _ranked_candidates(chain_name)}
473
  primary = get_primary(chain_name)
@@ -475,7 +579,9 @@ def get_backup(chain_name: str) -> Optional[str]:
475
  if model == primary:
476
  continue
477
  if model in eligible_models:
 
478
  return model
 
479
  return None
480
 
481
 
@@ -1039,12 +1145,17 @@ def filter_chain(chain: list[str]) -> list[str]:
1039
  def status_summary() -> dict:
1040
  """Compact summary for /api/health/llms endpoint."""
1041
  state = load()
1042
- summary = {"updated_at": None, "by_status": {"healthy": 0, "degraded": 0, "down": 0, "unknown": 0}, "models": []}
1043
  for m, h in state.items():
1044
- summary["by_status"][h.status] = summary["by_status"].get(h.status, 0) + 1
 
 
 
 
1045
  summary["models"].append({
1046
  "model": m,
1047
- "status": h.status,
 
1048
  "latency_ms": h.latency_ms,
1049
  "last_success_at": h.last_success_at,
1050
  "last_failure_at": h.last_failure_at,
 
93
  # of a pool degradation (more than fast enough at our chat volume) and
94
  # drops probe-driven Groq spend to ~3K/day baseline (well inside quota).
95
  PROBE_INTERVAL_SEC = 300
96
+ # A4 (2026-05-15) — cadence audit: 300s for steady-state probes is comfortably
97
+ # above the audit's 90s minimum for non-failing providers. The post-failure
98
+ # tighter cadence is implemented OUT-OF-BAND in `report_failure()`, which
99
+ # schedules a `_reprobe_one()` immediately after every chat failure (effective
100
+ # 0s after-failure cadence on the loop, not 30s). The 300s cadence then
101
+ # resumes for the long-run probe stream so the steady state stays cheap.
102
+ PROBE_INTERVAL_SEC_FAILING = 30 # A4 — documented post-failure cadence ceiling
103
  # KI-084 — completion size on probes cut from 5 → 1. The probe only
104
  # needs a non-empty 200 response to mark a candidate healthy; we never
105
  # parse the body content. max_tokens=1 keeps the same response shape +
 
111
  HEALTHY_PROBE_AGE_SEC = 600 # KI-084 — election candidates need a probe
112
  # within the last 600s (tracks 300s cadence
113
  # plus headroom for one missed tick).
114
+ # A4 (2026-05-15) — explicit STALE window. If a candidate hasn't been probed
115
+ # in >STALE_AGE_SEC, its on-record status is rewritten to "stale" so the
116
+ # router treats it as untested rather than trusting the last-known
117
+ # "healthy"/"unhealthy" verdict from minutes/hours ago.
118
+ STALE_AGE_SEC = HEALTHY_PROBE_AGE_SEC # alias for clarity; same threshold.
119
  DEGRADED_WINDOW_SEC = 30 # report_failure sidelines a model this long
120
  # for transient failures (timeout / 5xx).
121
  # KI-084 — rate-limit failures (HTTP 429 + provider 'RateLimit' bodies) are
 
212
  @dataclass
213
  class ModelHealth:
214
  model: str
215
+ # A4 (2026-05-15) 'stale' added. Set by `effective_status()` when a
216
+ # row hasn't been pinged in > STALE_AGE_SEC; the router then treats it
217
+ # as untested instead of trusting a last-known healthy/unhealthy verdict
218
+ # from minutes/hours ago. Persisted records may still carry the older
219
+ # status — the elector calls effective_status() at decision time.
220
+ status: str = "unknown" # 'healthy' | 'degraded' | 'down' | 'stale' | 'unknown'
221
  last_success_at: Optional[str] = None
222
  last_failure_at: Optional[str] = None
223
  last_error: Optional[str] = None
 
375
  return (1.0 / max(50, h.latency_ms)) * _success_rate(h)
376
 
377
 
378
+ def effective_status(h: ModelHealth) -> str:
379
+ """A4 (2026-05-15) — Return the routing-relevant status, applying the
380
+ STALE_AGE_SEC override at read time.
381
+
382
+ A stored `status` of "healthy" can mean "the last probe N hours ago
383
+ said this was healthy" — which the router must NOT trust. When
384
+ `tested_at` is older than `STALE_AGE_SEC` (or missing entirely), we
385
+ return "stale" so the elector treats the candidate as untested.
386
+
387
+ The stored status is not mutated here — that's the probe's job. Only
388
+ the live decision surface (election eligibility, status_summary) calls
389
+ this so historical inspection (logs, on-disk JSON) is preserved.
390
+ """
391
+ age = _iso_age_seconds(h.tested_at)
392
+ if age is None or age > STALE_AGE_SEC:
393
+ return "stale"
394
+ return h.status
395
+
396
+
397
  def _is_election_eligible(h: ModelHealth, now_mono: float) -> bool:
398
  """A candidate is electable when:
399
  - status is healthy (or degraded with a recent success)
 
401
  - it is NOT currently in the degraded-window sin-bin
402
  - KI-085 (2026-05-15): it has credits remaining above its low-water
403
  mark, OR no credit signal yet (cold-start = permissive).
404
+ - A4 (2026-05-15): effective_status != "stale" (catches the case
405
+ where status field is 'healthy' but the probe is older than
406
+ STALE_AGE_SEC).
407
  """
408
  if h.degraded_until_monotonic > now_mono:
409
  return False
 
414
  return False
415
  if h.latency_ms is None:
416
  return False
417
+ if effective_status(h) == "stale":
418
+ return False
419
  if not _has_credits(h, now_mono):
420
  logger.info(
421
  "election: skipping %s — credits %s/%s below water %s",
 
463
  return [h for _, h in eligible]
464
 
465
 
466
+ # A4 (2026-05-15) — Election event log. Tracks the last elected primary/
467
+ # backup per chain so we can emit a structured promotion/demotion log line
468
+ # when the elected model changes. Stored in-memory only (cheap; resets on
469
+ # process restart, which is fine — first post-restart election re-emits).
470
+ _LAST_ELECTION_LOCK = threading.Lock()
471
+ _LAST_ELECTION: dict[str, dict[str, Optional[str]]] = {}
472
+
473
+
474
+ def _emit_election_event(chain_name: str, role: str, from_m: Optional[str],
475
+ to_m: Optional[str], reason: str) -> None:
476
+ """A4 (2026-05-15) — Structured log line for every primary/backup
477
+ promotion or demotion. Format matches the audit brief:
478
+ {event, chain, role, from, to, reason, ts}
479
+ The router / admin UI / log-shipper can grep on `event=election` to
480
+ rebuild the timeline of who served what when.
481
+ """
482
+ event = {
483
+ "event": "election",
484
+ "chain": chain_name,
485
+ "role": role,
486
+ "from": from_m,
487
+ "to": to_m,
488
+ "reason": reason,
489
+ "ts": _now_iso(),
490
+ }
491
+ try:
492
+ logger.info("llm_health.election %s", json.dumps(event, ensure_ascii=False))
493
+ except Exception:
494
+ # Logging must never block the election path.
495
+ pass
496
+
497
+
498
+ def _record_election(chain_name: str, role: str, new_model: Optional[str],
499
+ reason: str = "elect") -> None:
500
+ """Compare against last-recorded election for this (chain, role) and
501
+ emit a structured log line on change. No-op if the value is unchanged.
502
+ """
503
+ with _LAST_ELECTION_LOCK:
504
+ per_chain = _LAST_ELECTION.setdefault(chain_name, {})
505
+ prev = per_chain.get(role, "__UNSET__") # sentinel — None is a real value
506
+ if prev == new_model:
507
+ return
508
+ per_chain[role] = new_model
509
+ # Logging outside the lock — never block other elections on logger I/O.
510
+ _emit_election_event(
511
+ chain_name, role,
512
+ None if prev == "__UNSET__" else prev,
513
+ new_model, reason,
514
+ )
515
+
516
+
517
  def get_primary(chain_name: str) -> Optional[str]:
518
  """KI-201 (2026-05-15) — elect by CHAIN ORDER, not latency score.
519
 
 
532
  Latency is no longer used for primary/backup selection — eligibility
533
  filtering still uses the rolling probe state, but ordering is
534
  purely chain-positional.
535
+
536
+ A4 (2026-05-15) — emits a structured `event=election` log line via
537
+ `_record_election` whenever the elected primary changes for this
538
+ chain, so the operator/admin/log-shipper can rebuild the promotion/
539
+ demotion timeline. No log emit when the value is unchanged.
540
  """
541
  chain = _chain_for(chain_name)
542
  if not chain:
543
+ _record_election(chain_name, "primary", None, reason="empty_chain")
544
  return None
545
  # _ranked_candidates returns the eligibility-filtered set (probe-fresh,
546
  # not in sin-bin, credit-not-exhausted, healthy). Reduce to a set for
 
548
  eligible_models = {h.model for h in _ranked_candidates(chain_name)}
549
  for model in chain:
550
  if model in eligible_models:
551
+ _record_election(chain_name, "primary", model, reason="chain_walk")
552
  return model
553
+ _record_election(chain_name, "primary", None, reason="no_eligible")
554
  return None # nothing eligible
555
 
556
 
 
565
  Meta → NVIDIA), so walking past the primary in chain order naturally
566
  preserves the cross-family backup invariant KI-087 used to enforce
567
  via provider_of().
568
+
569
+ A4 (2026-05-15) — emits a structured `event=election` log line via
570
+ `_record_election` whenever the elected backup changes.
571
  """
572
  chain = _chain_for(chain_name)
573
  if not chain:
574
+ _record_election(chain_name, "backup", None, reason="empty_chain")
575
  return None
576
  eligible_models = {h.model for h in _ranked_candidates(chain_name)}
577
  primary = get_primary(chain_name)
 
579
  if model == primary:
580
  continue
581
  if model in eligible_models:
582
+ _record_election(chain_name, "backup", model, reason="chain_walk")
583
  return model
584
+ _record_election(chain_name, "backup", None, reason="no_eligible")
585
  return None
586
 
587
 
 
1145
  def status_summary() -> dict:
1146
  """Compact summary for /api/health/llms endpoint."""
1147
  state = load()
1148
+ summary = {"updated_at": None, "by_status": {"healthy": 0, "degraded": 0, "down": 0, "stale": 0, "unknown": 0}, "models": []}
1149
  for m, h in state.items():
1150
+ # A4 (2026-05-15) `effective_status` applies the STALE_AGE_SEC
1151
+ # override at read time so the admin UI / router see "stale" for
1152
+ # rows whose stored 'healthy' verdict is older than STALE_AGE_SEC.
1153
+ eff = effective_status(h)
1154
+ summary["by_status"][eff] = summary["by_status"].get(eff, 0) + 1
1155
  summary["models"].append({
1156
  "model": m,
1157
+ "status": eff,
1158
+ "stored_status": h.status, # preserved for debug / drift detection
1159
  "latency_ms": h.latency_ms,
1160
  "last_success_at": h.last_success_at,
1161
  "last_failure_at": h.last_failure_at,
backend/main.py CHANGED
@@ -28,6 +28,16 @@ from backend.orchestrator import handle_turn
28
  from backend.providers.sarvam_stt import SarvamSTT
29
  from backend.providers.sarvam_tts import SarvamTTS
30
 
 
 
 
 
 
 
 
 
 
 
31
  # Singleton provider instances (initialized on first call)
32
  _stt: Optional[SarvamSTT] = None
33
  _tts: Optional[SarvamTTS] = None
@@ -416,17 +426,52 @@ async def chat(req: ChatRequest):
416
  # not a connection-reset to the user. 45s is generous but tighter than
417
  # HF Space's gateway timeout, so the user always gets a response.
418
  try:
419
- turn = await asyncio.wait_for(
420
- handle_turn(
421
- user_text=req.user_text,
422
- chat_history=req.chat_history,
423
- user_profile=req.profile,
424
- policy_filter_ids=req.policy_filter_ids,
425
- session_id=session_id,
426
- view_context=req.view_context,
427
- ),
428
- timeout=45.0,
429
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  except asyncio.TimeoutError:
431
  logging.warning(
432
  "handle_turn outer TimeoutError; returning graceful reply (session=%s)",
 
28
  from backend.providers.sarvam_stt import SarvamSTT
29
  from backend.providers.sarvam_tts import SarvamTTS
30
 
31
+ # Path B — opt-in single-LLM brain. Off by default; flip via env var.
32
+ # When on we replace orchestrator.handle_turn with single_brain.handle_turn
33
+ # for the /api/chat hot path and fall back to the legacy orchestrator on
34
+ # any SingleBrainError (so users always get a reply).
35
+ import os as _os # local alias to avoid stomping any later `import os`
36
+
37
+ USE_SINGLE_BRAIN = _os.environ.get("USE_SINGLE_BRAIN", "false").lower() in (
38
+ "1", "true", "yes", "on",
39
+ )
40
+
41
  # Singleton provider instances (initialized on first call)
42
  _stt: Optional[SarvamSTT] = None
43
  _tts: Optional[SarvamTTS] = None
 
426
  # not a connection-reset to the user. 45s is generous but tighter than
427
  # HF Space's gateway timeout, so the user always gets a response.
428
  try:
429
+ if USE_SINGLE_BRAIN:
430
+ # Path B — one Gemini call per turn with native function-calling.
431
+ # Falls back to the legacy orchestrator on SingleBrainError so a
432
+ # missing GOOGLE_API_KEY / model outage never breaks the chat.
433
+ from backend import single_brain
434
+ from backend.session_state import get_session
435
+
436
+ _sb_session = get_session(session_id)
437
+ try:
438
+ turn = await asyncio.wait_for(
439
+ single_brain.handle_turn(
440
+ session=_sb_session,
441
+ user_text=req.user_text,
442
+ chat_history=req.chat_history,
443
+ ),
444
+ timeout=45.0,
445
+ )
446
+ except single_brain.SingleBrainError as _sb_err:
447
+ logging.warning(
448
+ "single_brain failed, falling back to orchestrator "
449
+ "(session=%s): %s",
450
+ session_id, _sb_err,
451
+ )
452
+ turn = await asyncio.wait_for(
453
+ handle_turn(
454
+ user_text=req.user_text,
455
+ chat_history=req.chat_history,
456
+ user_profile=req.profile,
457
+ policy_filter_ids=req.policy_filter_ids,
458
+ session_id=session_id,
459
+ view_context=req.view_context,
460
+ ),
461
+ timeout=45.0,
462
+ )
463
+ else:
464
+ turn = await asyncio.wait_for(
465
+ handle_turn(
466
+ user_text=req.user_text,
467
+ chat_history=req.chat_history,
468
+ user_profile=req.profile,
469
+ policy_filter_ids=req.policy_filter_ids,
470
+ session_id=session_id,
471
+ view_context=req.view_context,
472
+ ),
473
+ timeout=45.0,
474
+ )
475
  except asyncio.TimeoutError:
476
  logging.warning(
477
  "handle_turn outer TimeoutError; returning graceful reply (session=%s)",
backend/orchestrator.py CHANGED
@@ -249,6 +249,32 @@ class BrainPick:
249
  CONTEXT_DEPENDENT_INTENTS = frozenset({"recommendation", "comparison"})
250
 
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  def should_route_to_fact_find(
253
  intent: str,
254
  *,
@@ -289,6 +315,13 @@ def should_route_to_fact_find(
289
  ql = query.lower()
290
  if any(tok in ql for tok in _NAMED_INSURER_TOKENS):
291
  return False
 
 
 
 
 
 
 
292
  # Otherwise — empty-profile generic intent — route to fact_find so the
293
  # bot collects basic context before quoting policy specifics.
294
  return True
@@ -527,6 +560,10 @@ class TurnResult:
527
  faithfulness_reasons: list[str] = field(default_factory=list)
528
  blocked: bool = False
529
  profile_updates: dict = field(default_factory=dict)
 
 
 
 
530
 
531
 
532
  async def handle_turn(
@@ -672,14 +709,6 @@ async def handle_turn(
672
  profile_updates={},
673
  )
674
 
675
- treat_as_fact_find = should_route_to_fact_find(
676
- intent,
677
- profile_is_empty=profile_is_empty,
678
- in_fact_find_continuation=in_fact_find_continuation,
679
- free_form_session=session.free_form_session,
680
- query=user_text,
681
- )
682
-
683
  # KI-196 (ADR-041) — confirmation-gated profile recall. If the prior
684
  # turn staged a pending recall (matched name → on-disk profile), the
685
  # bot's last reply already asked the user whether to continue or start
@@ -689,6 +718,14 @@ async def handle_turn(
689
  # - Other → leave pending_profile_recall in place; the brain will
690
  # re-ask one more time. (One re-ask cap is enforced by the
691
  # brain's prompt — see sales_brain._build_system_prompt.)
 
 
 
 
 
 
 
 
692
  if session.pending_profile_recall is not None:
693
  _utxt = (user_text or "").strip().lower()
694
  _AFFIRM = (
@@ -731,6 +768,60 @@ async def handle_turn(
731
  # If neither was detected, leave it staged — sales_brain will re-ask
732
  # via its system-prompt hook (see _build_system_prompt).
733
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
734
  if treat_as_fact_find:
735
  # KI-167 (2026-05-15) — WS2: replaces drive_fact_find with
736
  # drive_sales_brain. The new brain owns conversation flow end-to-end
@@ -1087,6 +1178,106 @@ async def handle_turn(
1087
  session_id, type(e).__name__, str(e)[:200],
1088
  )
1089
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1090
  # 2. Retrieve — pass session_id so the user's profile chunk (stored in
1091
  # Chroma at POST /api/profile time) gets boosted to the top of the
1092
  # context. Without session_id this path is dormant and the brain never
@@ -1142,6 +1333,13 @@ async def handle_turn(
1142
  "show me a few", "show me some", "side by side", "side-by-side",
1143
  "three options", "few options", "some options", "compare options",
1144
  "shortlist", "give me options", "give me three",
 
 
 
 
 
 
 
1145
  )
1146
  )
1147
 
@@ -1153,6 +1351,33 @@ async def handle_turn(
1153
  if _is_recommendation or intent == "comparison":
1154
  chunks = [c for c in chunks if (c.insurer_slug or "").lower() != "regulatory"]
1155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1156
  context_str = format_for_llm_context(chunks)
1157
 
1158
  # 3. Pick brain
@@ -1348,6 +1573,34 @@ async def handle_turn(
1348
  if (c.insurer_slug or "").lower() not in ("profile", "regulatory")
1349
  ]
1350
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1351
  # 7. INDIC CASCADE — translate the English reply back into Hinglish/Hindi,
1352
  # then run THREE drift checks. If any catches drift, revert to the English
1353
  # reply (user sees correct facts even if not in their preferred language).
@@ -1469,4 +1722,5 @@ async def handle_turn(
1469
  faithfulness_reasons=verdict.reasons,
1470
  blocked=blocked,
1471
  profile_updates=profile_updates_applied,
 
1472
  )
 
249
  CONTEXT_DEPENDENT_INTENTS = frozenset({"recommendation", "comparison"})
250
 
251
 
252
+ # KI-225 — knowledge-question carve-out. KI-154 force-routed empty-profile QA
253
+ # without a named insurer to fact_find, which over-corrected: pure knowledge
254
+ # lookups like "what is co-pay?", "how does NCB work?", "explain pre-existing
255
+ # diseases" were being interrupted with "First, your age?". These are
256
+ # definition-style queries — no user context required.
257
+ _KNOWLEDGE_QUESTION_PATTERNS = (
258
+ "what is", "what are", "what's a", "what's the", "whats a", "whats the",
259
+ "how does", "how do", "how is", "how are",
260
+ "tell me about", "explain", "define", "definition of",
261
+ "difference between", "differ between",
262
+ "meaning of", "what does", "what means",
263
+ )
264
+
265
+
266
+ def _is_knowledge_question(query: str) -> bool:
267
+ """KI-225 — return True when the query opens with a definition-style
268
+ pattern (what is, how does, explain, etc.). Such queries are policy-fact
269
+ lookups that don't depend on user profile and should pass straight to
270
+ the QA brain even when the live session has an empty profile.
271
+ """
272
+ q = (query or "").lower().strip()
273
+ if not q:
274
+ return False
275
+ return any(q.startswith(p) for p in _KNOWLEDGE_QUESTION_PATTERNS)
276
+
277
+
278
  def should_route_to_fact_find(
279
  intent: str,
280
  *,
 
315
  ql = query.lower()
316
  if any(tok in ql for tok in _NAMED_INSURER_TOKENS):
317
  return False
318
+ # KI-225 — knowledge-question carve-out. Definition-style queries
319
+ # ("what is co-pay?", "how does NCB work?", "explain pre-existing
320
+ # diseases") are policy-fact lookups that don't need user context.
321
+ # KI-154 over-corrected by force-routing these into fact_find — the
322
+ # user got "First, your age?" instead of a definition.
323
+ if _is_knowledge_question(query):
324
+ return False
325
  # Otherwise — empty-profile generic intent — route to fact_find so the
326
  # bot collects basic context before quoting policy specifics.
327
  return True
 
560
  faithfulness_reasons: list[str] = field(default_factory=list)
561
  blocked: bool = False
562
  profile_updates: dict = field(default_factory=dict)
563
+ # KI-228 — set when this turn was detected as a follow-up about a specific
564
+ # policy from the previous recommendation shortlist (e.g. "tell me more
565
+ # about #2"). The frontend uses this to highlight the matching card.
566
+ followup_policy_id: Optional[str] = None
567
 
568
 
569
  async def handle_turn(
 
709
  profile_updates={},
710
  )
711
 
 
 
 
 
 
 
 
 
712
  # KI-196 (ADR-041) — confirmation-gated profile recall. If the prior
713
  # turn staged a pending recall (matched name → on-disk profile), the
714
  # bot's last reply already asked the user whether to continue or start
 
718
  # - Other → leave pending_profile_recall in place; the brain will
719
  # re-ask one more time. (One re-ask cap is enforced by the
720
  # brain's prompt — see sales_brain._build_system_prompt.)
721
+ #
722
+ # KI-227 — MUST run BEFORE `should_route_to_fact_find` is computed. The
723
+ # prior ordering computed treat_as_fact_find against the EMPTY pre-merge
724
+ # profile, then merged the stored profile, then proceeded — so the user's
725
+ # "Continue" turn routed to fact_find with stale (empty) profile state.
726
+ # After the gate resolves, we recompute profile_is_empty /
727
+ # _required_profile_incomplete / in_fact_find_continuation so the routing
728
+ # decision below sees the merged profile.
729
  if session.pending_profile_recall is not None:
730
  _utxt = (user_text or "").strip().lower()
731
  _AFFIRM = (
 
768
  # If neither was detected, leave it staged — sales_brain will re-ask
769
  # via its system-prompt hook (see _build_system_prompt).
770
 
771
+ # KI-227 — recompute routing-decision inputs against the (possibly
772
+ # merged) profile so should_route_to_fact_find below sees the right
773
+ # state. Without this the "Continue" turn ran against pre-merge
774
+ # empty-profile values and force-routed back into fact_find.
775
+ _required_profile_incomplete = any(
776
+ not getattr(session.profile, slot, None)
777
+ for slot in _FACT_FIND_REQUIRED_SLOTS
778
+ )
779
+ in_fact_find_continuation = (
780
+ _required_profile_incomplete and not session.free_form_session
781
+ )
782
+ profile_is_empty = (
783
+ session.profile.age is None
784
+ and session.profile.dependents is None
785
+ and session.profile.income_band is None
786
+ )
787
+
788
+ treat_as_fact_find = should_route_to_fact_find(
789
+ intent,
790
+ profile_is_empty=profile_is_empty,
791
+ in_fact_find_continuation=in_fact_find_continuation,
792
+ free_form_session=session.free_form_session,
793
+ query=user_text,
794
+ )
795
+
796
+ # KI-228 — confirmation-answer routing. When all required slots are filled
797
+ # but the session hasn't flipped to free_form_session yet (KI-215 only
798
+ # flips on an explicit recommendation request), the user's natural
799
+ # confirmation reply ("yes, that's correct" / "yep, sounds good" / "no,
800
+ # change income") was routing to QA → faithfulness gate → "I'd rather
801
+ # not answer that without stronger evidence...". Treat short affirmations,
802
+ # negations, and brief acknowledgements as fact-find continuation so
803
+ # sales_brain handles the confirmation turn rather than the QA gate.
804
+ _AFFIRM_OR_SHORT = re.compile(
805
+ r"^\s*(yes|yeah|yep|yup|correct|right|sounds good|looks good|that.s right|confirmed|ok|okay|sure|alright)\b",
806
+ re.IGNORECASE,
807
+ )
808
+ _NEGATE_SHORT = re.compile(
809
+ r"^\s*(no|nope|nah|not.really|incorrect|wrong)\b",
810
+ re.IGNORECASE,
811
+ )
812
+ _slots_complete_routing = all(
813
+ getattr(session.profile, slot, None) not in (None, "", [])
814
+ for slot in _FACT_FIND_REQUIRED_SLOTS
815
+ )
816
+ if _slots_complete_routing and not session.free_form_session:
817
+ _utxt_routing = user_text or ""
818
+ if (
819
+ _AFFIRM_OR_SHORT.match(_utxt_routing)
820
+ or _NEGATE_SHORT.match(_utxt_routing)
821
+ or len(_utxt_routing.strip()) < 25
822
+ ):
823
+ treat_as_fact_find = True
824
+
825
  if treat_as_fact_find:
826
  # KI-167 (2026-05-15) — WS2: replaces drive_fact_find with
827
  # drive_sales_brain. The new brain owns conversation flow end-to-end
 
1178
  session_id, type(e).__name__, str(e)[:200],
1179
  )
1180
 
1181
+ # KI-228 — post-recommendation follow-up routing. When the previous turn
1182
+ # served a shortlist and persisted `session.last_recommendation_ids`, this
1183
+ # turn might be a follow-up like "tell me more about #2" / "the second one"
1184
+ # / "what about Policy X". Detect the reference, map to a specific
1185
+ # policy_id, and bias retrieval to surface ONLY that policy's chunks.
1186
+ #
1187
+ # Ordinal map: "#1"/"first"/"first one" → index 0, etc. Run BEFORE retrieval
1188
+ # so we can rewrite `policy_filter_ids` for this turn.
1189
+ followup_policy_id: Optional[str] = None
1190
+ if (
1191
+ getattr(session, "last_recommendation_ids", None)
1192
+ and not policy_filter_ids # don't override an explicit caller-supplied filter
1193
+ ):
1194
+ _shortlist = list(session.last_recommendation_ids)
1195
+ _utxt_followup = (user_text or "").lower().strip()
1196
+ # Ordinal: "#1" / "# 1" / "1st" / "first one" / "first" (word-boundary
1197
+ # to avoid eating "first buy" / "first time buyer").
1198
+ _ORDINAL_PATTERNS: list[tuple[re.Pattern, int]] = [
1199
+ (re.compile(r"(?:^|\s)#\s*1(?:\b|$)"), 0),
1200
+ (re.compile(r"(?:^|\s)#\s*2(?:\b|$)"), 1),
1201
+ (re.compile(r"(?:^|\s)#\s*3(?:\b|$)"), 2),
1202
+ (re.compile(r"\b1st\b"), 0),
1203
+ (re.compile(r"\b2nd\b"), 1),
1204
+ (re.compile(r"\b3rd\b"), 2),
1205
+ (re.compile(r"\b(?:the\s+)?first\s+(?:one|policy|option|recommendation)\b"), 0),
1206
+ (re.compile(r"\b(?:the\s+)?second\s+(?:one|policy|option|recommendation)?\b"), 1),
1207
+ (re.compile(r"\b(?:the\s+)?third\s+(?:one|policy|option|recommendation)?\b"), 2),
1208
+ # Detail-request prefix + bare ordinal ("explain the first",
1209
+ # "tell me about the first / second / third"). Disambiguates from
1210
+ # "first buy" / "first time" which are goal keywords, not ordinals.
1211
+ (re.compile(r"\b(?:tell\s+me\s+about|explain|details?\s+on|what\s+about|more\s+on)\s+the\s+first\b"), 0),
1212
+ (re.compile(r"\b(?:tell\s+me\s+about|explain|details?\s+on|what\s+about|more\s+on)\s+the\s+second\b"), 1),
1213
+ (re.compile(r"\b(?:tell\s+me\s+about|explain|details?\s+on|what\s+about|more\s+on)\s+the\s+third\b"), 2),
1214
+ (re.compile(r"\boption\s+1\b"), 0),
1215
+ (re.compile(r"\boption\s+2\b"), 1),
1216
+ (re.compile(r"\boption\s+3\b"), 2),
1217
+ (re.compile(r"\bpolicy\s+1\b"), 0),
1218
+ (re.compile(r"\bpolicy\s+2\b"), 1),
1219
+ (re.compile(r"\bpolicy\s+3\b"), 2),
1220
+ ]
1221
+ for _pat, _idx in _ORDINAL_PATTERNS:
1222
+ if _pat.search(_utxt_followup) and _idx < len(_shortlist):
1223
+ followup_policy_id = _shortlist[_idx]
1224
+ break
1225
+ # Policy-name match: look for any of the shortlist policy names in
1226
+ # user_text by scanning the most recent assistant message in
1227
+ # chat_history for ID-adjacent name strings. We don't have a direct
1228
+ # id→name map on the session, so fall back to a soft signal: if the
1229
+ # user says "tell me more about <X>" or "what about <X>", inject the
1230
+ # phrase into the retrieval query so the embedding bias toward <X>
1231
+ # naturally pulls its chunks (no filter — the brain still sees the
1232
+ # full shortlist context).
1233
+ if followup_policy_id is None and chat_history:
1234
+ _last_assistant = ""
1235
+ for _msg in reversed(chat_history):
1236
+ if isinstance(_msg, dict) and _msg.get("role") == "assistant":
1237
+ _last_assistant = str(_msg.get("content") or "")
1238
+ break
1239
+ # If user_text references "this" / "that" / "it" + an explicit
1240
+ # request-for-detail verb, AND we have a single-policy shortlist,
1241
+ # default to that one policy.
1242
+ _detail_request = bool(
1243
+ re.search(
1244
+ r"\b(?:tell\s+me\s+more|more\s+about|more\s+details?|"
1245
+ r"explain|details?\s+on|what\s+about|how\s+about)\b",
1246
+ _utxt_followup,
1247
+ )
1248
+ )
1249
+ if _detail_request and len(_shortlist) == 1:
1250
+ followup_policy_id = _shortlist[0]
1251
+ # Otherwise, scan the prior assistant turn for capitalized policy
1252
+ # names also present in the user's text. We don't try to be
1253
+ # exhaustive here — the retrieval embedding already handles loose
1254
+ # name matches; this is just for the explicit-filter case.
1255
+ elif _detail_request and _last_assistant:
1256
+ # Extract candidate names from the assistant turn (2-6 word
1257
+ # Title-Case sequences). Cross-check against user_text.
1258
+ _name_re = re.compile(r"\b(?:[A-Z][A-Za-z0-9&\-']+\s+){1,5}[A-Z][A-Za-z0-9&\-']+\b")
1259
+ _candidate_names = set(_name_re.findall(_last_assistant))
1260
+ _utxt_caseful = (user_text or "")
1261
+ for _cand in _candidate_names:
1262
+ if _cand.lower() in _utxt_caseful.lower() and len(_cand) >= 6:
1263
+ # Found a likely policy-name reference. Inject into
1264
+ # user_text for retrieval bias (matches the fallback
1265
+ # path described in the spec). Don't set filter — we
1266
+ # don't have a confident id mapping.
1267
+ if not user_text.lower().startswith(_cand.lower()):
1268
+ user_text = f"about {_cand}: {user_text}"
1269
+ break
1270
+ if followup_policy_id is not None:
1271
+ # Override the retrieval filter so this turn surfaces ONLY the
1272
+ # matched policy's chunks. Preserve any caller-supplied filter
1273
+ # (we already guarded against it above, but belt-and-braces).
1274
+ if not policy_filter_ids:
1275
+ policy_filter_ids = [followup_policy_id]
1276
+ logging.info(
1277
+ "KI-228 follow-up routing matched policy_id=%s (session=%s)",
1278
+ followup_policy_id, session_id,
1279
+ )
1280
+
1281
  # 2. Retrieve — pass session_id so the user's profile chunk (stored in
1282
  # Chroma at POST /api/profile time) gets boosted to the top of the
1283
  # context. Without session_id this path is dormant and the brain never
 
1333
  "show me a few", "show me some", "side by side", "side-by-side",
1334
  "three options", "few options", "some options", "compare options",
1335
  "shortlist", "give me options", "give me three",
1336
+ # KI-226 — broadened detection. Live captures showed users saying
1337
+ # "good for me", "which one fits", "help me pick" etc., which
1338
+ # routed through the QA + faithfulness path instead of the
1339
+ # recommendation lane.
1340
+ "good for me", "which one", "which fits", "fits",
1341
+ "list of polic", "give me a list", "help me pick",
1342
+ "help me choose", "narrow", "any suggestion", "any recommend",
1343
  )
1344
  )
1345
 
 
1351
  if _is_recommendation or intent == "comparison":
1352
  chunks = [c for c in chunks if (c.insurer_slug or "").lower() != "regulatory"]
1353
 
1354
+ # KI-229 — empty-retrieval recommendation guard. When the user explicitly
1355
+ # asks for a recommendation/comparison AND retrieval came back with zero
1356
+ # usable chunks (e.g. niche profile that doesn't match any indexed policy),
1357
+ # the brain has nothing to ground on and the faithfulness gate is skipped
1358
+ # for recommendations (KI-171). The result was hallucinated policy names
1359
+ # with no citations. Short-circuit BEFORE the brain call with a polite ask
1360
+ # to broaden the criteria.
1361
+ if (_is_recommendation or intent == "comparison") and len(chunks) == 0:
1362
+ reply = (
1363
+ "I don't have policy data that matches your specific profile right now — "
1364
+ "could you broaden the criteria (e.g., a different age band, dependents, "
1365
+ "or budget) so I can pull relevant options?"
1366
+ )
1367
+ return TurnResult(
1368
+ reply_text=reply,
1369
+ citations=[],
1370
+ retrieved_chunk_ids=[],
1371
+ brain_used="orchestrator::empty_retrieval_short_circuit",
1372
+ intent=intent,
1373
+ language=language,
1374
+ latency_ms=int((time.time() - t0) * 1000),
1375
+ raw_reply=reply,
1376
+ faithfulness_passed=True,
1377
+ blocked=False,
1378
+ profile_updates=profile_updates_applied,
1379
+ )
1380
+
1381
  context_str = format_for_llm_context(chunks)
1382
 
1383
  # 3. Pick brain
 
1573
  if (c.insurer_slug or "").lower() not in ("profile", "regulatory")
1574
  ]
1575
 
1576
+ # KI-224 — persist last_recommendation_ids on the session so a follow-up
1577
+ # ("tell me more about #2") can route against the same shortlist without
1578
+ # re-retrieving from scratch. Only populate on clean recommendation /
1579
+ # comparison replies (faithfulness must have passed or been legitimately
1580
+ # skipped, and the reply must not have been blocked). Empty citations → []
1581
+ # leaves the previous value intact only when faithfulness blocked the turn,
1582
+ # which is the right behavior (the user never saw a new shortlist).
1583
+ if (
1584
+ intent in ("recommendation", "comparison")
1585
+ and verdict.passed
1586
+ and not blocked
1587
+ and citations
1588
+ ):
1589
+ try:
1590
+ seen: set[str] = set()
1591
+ ids: list[str] = []
1592
+ for cite in citations:
1593
+ pid = cite.get("policy_id")
1594
+ if pid and pid not in seen:
1595
+ seen.add(pid)
1596
+ ids.append(pid)
1597
+ session.last_recommendation_ids = ids
1598
+ except Exception as _e:
1599
+ logging.warning(
1600
+ "KI-224 last_recommendation_ids persist failed (session=%s): %s",
1601
+ session_id, _e,
1602
+ )
1603
+
1604
  # 7. INDIC CASCADE — translate the English reply back into Hinglish/Hindi,
1605
  # then run THREE drift checks. If any catches drift, revert to the English
1606
  # reply (user sees correct facts even if not in their preferred language).
 
1722
  faithfulness_reasons=verdict.reasons,
1723
  blocked=blocked,
1724
  profile_updates=profile_updates_applied,
1725
+ followup_policy_id=followup_policy_id,
1726
  )
backend/providers/google_gemini_llm.py CHANGED
@@ -74,26 +74,121 @@ DEFAULT_MODEL = "gemini-2.5-flash-lite" # KI-183 — gemini-2.0-flash retired f
74
  # this is cheap insurance and keeps the contract honest if the module is ever
75
  # pulled into a thread pool.
76
  # ----------------------------------------------------------------------------
77
- _CACHE_REGISTRY: dict[tuple[str, str], dict] = {}
78
  _CACHE_REGISTRY_LOCK = threading.Lock()
79
 
 
 
 
 
 
 
 
80
 
81
- def _cache_key(model: str, system_text: str) -> tuple[str, str]:
82
- """Build the registry key for a (model, system_text) pair.
83
 
84
- Hashing the system text rather than storing the raw string keeps the
85
- registry footprint tiny even when the preamble is multi-KB.
 
 
 
 
 
 
 
 
 
86
  """
87
- return (model, hashlib.sha256(system_text.encode("utf-8")).hexdigest())
 
 
88
 
89
 
90
- def invalidate_cache(model: str, system_text: str) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  """Drop a cache registry entry — called by upstream after a 4xx response
92
  that names a stale `cachedContent`. The server-side cache may still be
93
  alive (it will lapse on TTL), but our reference is gone so the next
94
  chat() call provisions a fresh one.
 
 
 
 
95
  """
96
- key = _cache_key(model, system_text)
97
  with _CACHE_REGISTRY_LOCK:
98
  _CACHE_REGISTRY.pop(key, None)
99
 
@@ -162,6 +257,7 @@ class GoogleGeminiLLM(LLMProvider):
162
  self,
163
  system_text: str,
164
  ttl_seconds: int = 300,
 
165
  ) -> Optional[str]:
166
  """Create (or reuse) a Gemini `cachedContents` resource for `system_text`.
167
 
@@ -180,14 +276,26 @@ class GoogleGeminiLLM(LLMProvider):
180
  if not self.api_key or not system_text:
181
  return None
182
 
183
- key = _cache_key(self.model, system_text)
184
  now = time.time()
185
  with _CACHE_REGISTRY_LOCK:
186
  entry = _CACHE_REGISTRY.get(key)
187
- # Refresh ~10s before expiry so an in-flight request never lands
188
- # on a server-side cache that just rolled past its TTL.
189
- if entry and entry.get("expires_at", 0) > now + 10:
190
- return entry.get("name")
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
  # `model` must be the fully-qualified Gemini path "models/<id>".
193
  body: dict = {
@@ -234,12 +342,17 @@ class GoogleGeminiLLM(LLMProvider):
234
  return None
235
 
236
  with _CACHE_REGISTRY_LOCK:
 
237
  _CACHE_REGISTRY[key] = {
238
  "name": cache_name,
239
  # Store local expiry; the registered TTL is server-side
240
  # truth, but we shadow it locally so we self-evict before
241
  # the inevitable 4xx on an expired reference.
242
- "expires_at": time.time() + ttl_seconds,
 
 
 
 
243
  }
244
  logging.info(
245
  "gemini.create_cache OK (model=%s, name=%s, ttl=%ss)",
@@ -296,15 +409,40 @@ class GoogleGeminiLLM(LLMProvider):
296
  # Per-phase timeouts mirroring the NIM/OpenRouter pattern: a stuck
297
  # connection releases its slot on its own deadline rather than holding
298
  # past the outer wait_for cancellation.
 
 
 
 
 
 
 
 
299
  client_timeout = httpx.Timeout(
300
  connect=2.0,
301
- read=self.timeout,
302
  write=2.0,
303
  pool=2.0,
304
  )
305
 
306
  async with httpx.AsyncClient(timeout=client_timeout) as client:
307
- resp = await client.post(url, headers=headers, json=body)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  # KI-199 — graceful fallback when a cache reference is stale.
309
  # Symptoms: 400/404 with body mentioning "cachedContent" /
310
  # "cache" / "not found". Strip the reference, re-add the inline
@@ -331,21 +469,39 @@ class GoogleGeminiLLM(LLMProvider):
331
  body["systemInstruction"] = {
332
  "parts": [{"text": system_instruction}]
333
  }
334
- resp = await client.post(url, headers=headers, json=body)
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  if resp.status_code >= 400:
336
- # Surface the Google error body so the caller's log makes the
337
- # root cause visible (typical failure: 429 quota exceeded or
338
- # 400 prompt-block).
 
 
 
339
  detail = ""
340
  try:
341
  detail = resp.text[:500]
342
  except Exception:
343
  pass
344
- raise httpx.HTTPStatusError(
345
  f"Gemini API {resp.status_code}: {detail}",
346
  request=resp.request,
347
  response=resp,
348
  )
 
 
349
  payload = resp.json()
350
 
351
  # Response shape:
@@ -402,4 +558,6 @@ __all__ = [
402
  "get_gemini_llm",
403
  "DEFAULT_MODEL",
404
  "invalidate_cache",
 
 
405
  ]
 
74
  # this is cheap insurance and keeps the contract honest if the module is ever
75
  # pulled into a thread pool.
76
  # ----------------------------------------------------------------------------
77
+ _CACHE_REGISTRY: dict[tuple[str, str, str], dict] = {}
78
  _CACHE_REGISTRY_LOCK = threading.Lock()
79
 
80
+ # A4 (2026-05-15) — Gemini cachedContents server-side TTL ceiling. Google's
81
+ # `cachedContents` resources live up to ~60min on free tier; we refresh
82
+ # before that ceiling so an in-flight call never lands on an expired cache.
83
+ # `CACHE_REFRESH_AGE_SEC` is the wall-clock age at which we proactively
84
+ # re-create even if local `expires_at` has not yet elapsed — keeps us safely
85
+ # below the server-side TTL drift window observed in production.
86
+ CACHE_REFRESH_AGE_SEC = 50 * 60 # 50min — refresh BEFORE 60min server ceiling
87
 
 
 
88
 
89
+ def _cache_key(model: str, system_text: str, dynamic_prefix: str = "") -> tuple[str, str, str]:
90
+ """Build the registry key for a (model, system_text, dynamic_prefix) tuple.
91
+
92
+ A4 (2026-05-15) — Cache key collision fix: SHA256 of preamble alone is
93
+ insufficient when `_dynamic_profile_block` varies per persona. The key
94
+ now partitions on a separate `dynamic_prefix` hash so per-persona
95
+ caches don't collide on the same static preamble hash. `dynamic_prefix`
96
+ defaults to "" so existing callers retain prior behaviour.
97
+
98
+ Hashing inputs rather than storing the raw string keeps the registry
99
+ footprint tiny even when the preamble is multi-KB.
100
  """
101
+ static_h = hashlib.sha256(system_text.encode("utf-8")).hexdigest()
102
+ dyn_h = hashlib.sha256(dynamic_prefix.encode("utf-8")).hexdigest() if dynamic_prefix else ""
103
+ return (model, static_h, dyn_h)
104
 
105
 
106
+ # ---------------------------------------------------------------------------
107
+ # A4 (2026-05-15) — Normalized provider error class.
108
+ #
109
+ # Gemini's REST surface returns different error shapes for different failure
110
+ # modes (429 rate-limit, 400 BlockedReason / SafetyRating, 404 cache not
111
+ # found, 500/503 server errors). The tier wrapper needs a stable contract:
112
+ # `BrainProviderError(retryable=bool)` where `retryable=True` signals the
113
+ # tier should fall through to the next provider, and `retryable=False`
114
+ # signals a hard error (auth / content blocked) that should surface to the
115
+ # caller without burning fallback budget.
116
+ # ---------------------------------------------------------------------------
117
+ class BrainProviderError(RuntimeError):
118
+ """Stable provider-error envelope consumed by TieredBrainLLM.
119
+
120
+ Attributes:
121
+ provider: short name ("gemini" / "nim" / ...)
122
+ retryable: True if the tier wrapper should try the next provider.
123
+ False for auth / content-block / non-recoverable errors.
124
+ status: HTTP-like status code if applicable, else None.
125
+ raw: the original exception (kept as __cause__ via `raise from`).
126
+ """
127
+
128
+ def __init__(
129
+ self,
130
+ message: str,
131
+ *,
132
+ provider: str = "gemini",
133
+ retryable: bool = True,
134
+ status: Optional[int] = None,
135
+ ):
136
+ super().__init__(message)
137
+ self.provider = provider
138
+ self.retryable = retryable
139
+ self.status = status
140
+
141
+
142
+ def _classify_gemini_error(status_code: int, detail: str) -> BrainProviderError:
143
+ """Map a Gemini REST response into BrainProviderError(retryable=bool).
144
+
145
+ Routing rules (matches TieredBrainLLM expectations):
146
+ - 429 (rate-limit / quota) → retryable (next tier)
147
+ - 5xx (server errors) → retryable (next tier)
148
+ - 408 / 504 (timeout) → retryable
149
+ - 401 / 403 (auth, key revoked) → NOT retryable (surface)
150
+ - 400 with BlockedReason / SafetyRating → NOT retryable (content block)
151
+ - 400 other (malformed request) → NOT retryable (caller bug)
152
+ - 404 cachedContent → retryable (cache lapsed,
153
+ uncached retry already wired
154
+ in chat() but tier-level
155
+ fallback is still safe).
156
+ """
157
+ detail_l = (detail or "").lower()
158
+ retryable = False
159
+ if status_code == 429:
160
+ retryable = True
161
+ elif 500 <= status_code < 600:
162
+ retryable = True
163
+ elif status_code in (408, 504):
164
+ retryable = True
165
+ elif status_code == 404 and ("cache" in detail_l or "cachedcontent" in detail_l):
166
+ retryable = True
167
+ elif status_code == 400 and (
168
+ "blocked" in detail_l or "safety" in detail_l or "blockreason" in detail_l
169
+ ):
170
+ retryable = False
171
+ elif status_code in (401, 403):
172
+ retryable = False
173
+ return BrainProviderError(
174
+ f"Gemini API {status_code}: {detail[:300]}",
175
+ provider="gemini",
176
+ retryable=retryable,
177
+ status=status_code,
178
+ )
179
+
180
+
181
+ def invalidate_cache(model: str, system_text: str, dynamic_prefix: str = "") -> None:
182
  """Drop a cache registry entry — called by upstream after a 4xx response
183
  that names a stale `cachedContent`. The server-side cache may still be
184
  alive (it will lapse on TTL), but our reference is gone so the next
185
  chat() call provisions a fresh one.
186
+
187
+ A4 (2026-05-15) — `dynamic_prefix` is an optional partition arg; defaults
188
+ to "" so existing callers retain prior behaviour. When supplied, only the
189
+ matching (model, system_text, dynamic_prefix) entry is dropped.
190
  """
191
+ key = _cache_key(model, system_text, dynamic_prefix)
192
  with _CACHE_REGISTRY_LOCK:
193
  _CACHE_REGISTRY.pop(key, None)
194
 
 
257
  self,
258
  system_text: str,
259
  ttl_seconds: int = 300,
260
+ dynamic_prefix: str = "",
261
  ) -> Optional[str]:
262
  """Create (or reuse) a Gemini `cachedContents` resource for `system_text`.
263
 
 
276
  if not self.api_key or not system_text:
277
  return None
278
 
279
+ key = _cache_key(self.model, system_text, dynamic_prefix)
280
  now = time.time()
281
  with _CACHE_REGISTRY_LOCK:
282
  entry = _CACHE_REGISTRY.get(key)
283
+ # A4 (2026-05-15) TWO-stage refresh:
284
+ # (a) self-evict ~10s before LOCAL expires_at (was already here).
285
+ # (b) PROACTIVELY refresh if the entry is older than
286
+ # CACHE_REFRESH_AGE_SEC (50min) regardless of expires_at —
287
+ # guards against server-side TTL drift on long-lived
288
+ # caches and keeps every entry well below the ~60min
289
+ # cachedContents ceiling.
290
+ if entry:
291
+ created_at = entry.get("created_at", 0)
292
+ age = now - created_at if created_at else 0
293
+ if (
294
+ entry.get("expires_at", 0) > now + 10
295
+ and age < CACHE_REFRESH_AGE_SEC
296
+ ):
297
+ return entry.get("name")
298
+ # else: fall through and recreate
299
 
300
  # `model` must be the fully-qualified Gemini path "models/<id>".
301
  body: dict = {
 
342
  return None
343
 
344
  with _CACHE_REGISTRY_LOCK:
345
+ now_create = time.time()
346
  _CACHE_REGISTRY[key] = {
347
  "name": cache_name,
348
  # Store local expiry; the registered TTL is server-side
349
  # truth, but we shadow it locally so we self-evict before
350
  # the inevitable 4xx on an expired reference.
351
+ "expires_at": now_create + ttl_seconds,
352
+ # A4 (2026-05-15) — created_at lets us proactively refresh
353
+ # entries that have lived past CACHE_REFRESH_AGE_SEC even
354
+ # when caller set a longer TTL than Google honours.
355
+ "created_at": now_create,
356
  }
357
  logging.info(
358
  "gemini.create_cache OK (model=%s, name=%s, ttl=%ss)",
 
409
  # Per-phase timeouts mirroring the NIM/OpenRouter pattern: a stuck
410
  # connection releases its slot on its own deadline rather than holding
411
  # past the outer wait_for cancellation.
412
+ #
413
+ # A4 (2026-05-15) — Timeout binding: bind the SDK/httpx read timeout
414
+ # to `self.timeout - 2.0` so the underlying connection times out
415
+ # ~2s BEFORE the tier-wrapper's outer wait_for cancellation. This
416
+ # surfaces a clean BrainProviderError(retryable=True) instead of
417
+ # asyncio.CancelledError leaking up through the cancellation chain
418
+ # (which the tier wrapper has historically misclassified).
419
+ read_timeout = max(2.0, self.timeout - 2.0)
420
  client_timeout = httpx.Timeout(
421
  connect=2.0,
422
+ read=read_timeout,
423
  write=2.0,
424
  pool=2.0,
425
  )
426
 
427
  async with httpx.AsyncClient(timeout=client_timeout) as client:
428
+ try:
429
+ resp = await client.post(url, headers=headers, json=body)
430
+ except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
431
+ raise
432
+ except httpx.TimeoutException as e:
433
+ # A4 (2026-05-15) — TimeoutException → retryable
434
+ # BrainProviderError so the tier wrapper falls through
435
+ # cleanly instead of seeing asyncio.CancelledError.
436
+ raise BrainProviderError(
437
+ f"Gemini timeout after {read_timeout:.1f}s (model={self.model})",
438
+ provider="gemini", retryable=True, status=None,
439
+ ) from e
440
+ except httpx.HTTPError as e:
441
+ # Network / DNS / connection-refused etc. — retryable.
442
+ raise BrainProviderError(
443
+ f"Gemini transport error ({type(e).__name__}): {str(e)[:200]}",
444
+ provider="gemini", retryable=True, status=None,
445
+ ) from e
446
  # KI-199 — graceful fallback when a cache reference is stale.
447
  # Symptoms: 400/404 with body mentioning "cachedContent" /
448
  # "cache" / "not found". Strip the reference, re-add the inline
 
469
  body["systemInstruction"] = {
470
  "parts": [{"text": system_instruction}]
471
  }
472
+ try:
473
+ resp = await client.post(url, headers=headers, json=body)
474
+ except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
475
+ raise
476
+ except httpx.TimeoutException as e:
477
+ raise BrainProviderError(
478
+ f"Gemini retry timeout after {read_timeout:.1f}s (model={self.model})",
479
+ provider="gemini", retryable=True, status=None,
480
+ ) from e
481
+ except httpx.HTTPError as e:
482
+ raise BrainProviderError(
483
+ f"Gemini retry transport error ({type(e).__name__}): {str(e)[:200]}",
484
+ provider="gemini", retryable=True, status=None,
485
+ ) from e
486
  if resp.status_code >= 400:
487
+ # A4 (2026-05-15) Normalize the error into BrainProviderError
488
+ # so the tier wrapper sees a stable contract:
489
+ # retryable=True → fall through to next tier
490
+ # retryable=False → surface to caller (auth/content-block)
491
+ # The original httpx.HTTPStatusError is preserved as __cause__
492
+ # so logs still carry the full upstream trail.
493
  detail = ""
494
  try:
495
  detail = resp.text[:500]
496
  except Exception:
497
  pass
498
+ upstream_err = httpx.HTTPStatusError(
499
  f"Gemini API {resp.status_code}: {detail}",
500
  request=resp.request,
501
  response=resp,
502
  )
503
+ normalized = _classify_gemini_error(resp.status_code, detail)
504
+ raise normalized from upstream_err
505
  payload = resp.json()
506
 
507
  # Response shape:
 
558
  "get_gemini_llm",
559
  "DEFAULT_MODEL",
560
  "invalidate_cache",
561
+ "BrainProviderError",
562
+ "CACHE_REFRESH_AGE_SEC",
563
  ]
backend/providers/nvidia_nim_llm.py CHANGED
@@ -31,6 +31,7 @@ from __future__ import annotations
31
 
32
  import asyncio
33
  import json
 
34
  import time
35
  from pathlib import Path
36
  from typing import Optional
@@ -104,6 +105,48 @@ async def _append_usage(record: dict) -> None:
104
  _NIM_OUTBOUND_SEMAPHORE = asyncio.Semaphore(2)
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  NVIDIA_NIM_BASE_URL = "https://integrate.api.nvidia.com/v1"
108
  # 2026-05-14 brain swap (D-022): NIM's DeepSeek-V4 + Meta Llama inference pools
109
  # are repeatedly timing out (15-120s on chat completions, no response). Qwen
@@ -159,7 +202,17 @@ class NvidiaNimLLM(LLMProvider):
159
  "max_tokens": max_tokens,
160
  }
161
  if response_format:
 
 
 
 
 
 
162
  body["response_format"] = response_format
 
 
 
 
163
 
164
  headers = {
165
  "Authorization": f"Bearer {self.api_key}",
@@ -202,11 +255,18 @@ class NvidiaNimLLM(LLMProvider):
202
  # The semaphore wraps ONLY the HTTP round-trip — not response
203
  # parsing or usage logging — so we cap concurrent network
204
  # traffic without serialising the rest of the pipeline.
205
- async with httpx.AsyncClient(timeout=client_timeout) as client:
206
- async with _NIM_OUTBOUND_SEMAPHORE:
207
- resp = await client.post(url, headers=headers, json=body)
208
- resp.raise_for_status()
209
- payload = resp.json()
 
 
 
 
 
 
 
210
 
211
  choice = payload["choices"][0]
212
  msg = choice.get("message", {}) or {}
@@ -440,21 +500,48 @@ class NimChainLLM(LLMProvider):
440
  Two models in the SAME family must never be paired as brain ↔ judge
441
  because they share weights / training corpus / decision surface, so
442
  the judge would effectively grade its own siblings' output.
443
- Families: 'qwen', 'mistral', 'meta', 'openai', 'deepseek', 'moonshot',
444
- 'minimax', 'nvidia', 'unknown'.
 
 
 
 
 
 
 
445
  """
446
- m = model_id.lower()
 
 
 
 
 
 
 
447
  # Strip provider prefix first so 'groq:llama-3.3-70b' → 'meta' (it IS Meta Llama)
448
  if ":" in m:
449
  m = m.split(":", 1)[1]
 
 
 
 
 
450
  if "qwen" in m: return "qwen"
451
  if "mistral" in m: return "mistral"
 
 
 
 
452
  if "llama" in m or m.startswith("meta/"): return "meta"
453
- if "gpt-oss" in m or m.startswith("openai/"): return "openai"
 
 
 
 
454
  if "deepseek" in m: return "deepseek"
455
  if "kimi" in m or m.startswith("moonshot"): return "moonshot"
456
  if "minimax" in m: return "minimax"
457
- if "nemotron" in m or m.startswith("nvidia/"): return "nvidia"
458
  return "unknown"
459
 
460
  # KI-080 — per-call timeout used in the sticky-primary path. Each elected
@@ -800,6 +887,47 @@ def _balanced_brain_chain(base: list[str], *, groq_first_probability: float = 0.
800
  return [groq_model, *rotated]
801
 
802
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
803
  def get_brain_llm() -> NimChainLLM:
804
  """Heavy brain — KI-080 sticky-primary election over BRAIN_CHAIN.
805
 
 
31
 
32
  import asyncio
33
  import json
34
+ import threading
35
  import time
36
  from pathlib import Path
37
  from typing import Optional
 
105
  _NIM_OUTBOUND_SEMAPHORE = asyncio.Semaphore(2)
106
 
107
 
108
+ # A4 (2026-05-15) — Module-level httpx.AsyncClient singleton.
109
+ #
110
+ # Pre-A4: every NvidiaNimLLM.chat() call did `async with httpx.AsyncClient(...)
111
+ # as client: ...`, which constructed a fresh connection pool per request and
112
+ # tore it down on exit. Under brain/judge concurrency this churned TCP
113
+ # connections, dropped TLS sessions, and (per audit) leaked pool slots when
114
+ # an outer wait_for cancellation interrupted the async-with __aexit__ before
115
+ # the underlying transport finalised.
116
+ #
117
+ # Switch to a single shared client with explicit Limits so:
118
+ # - max_connections=10 caps total outbound (NIM + probes share)
119
+ # - max_keepalive_connections=5 keeps a warm pool for the hot path
120
+ # The client is constructed lazily (first .chat() call) so cold imports
121
+ # don't pay TLS/handshake cost on processes that never call NIM (e.g.
122
+ # CLI tools, eval harness).
123
+ _NIM_HTTPX_CLIENT_LOCK = threading.Lock()
124
+ _NIM_HTTPX_CLIENT: Optional[httpx.AsyncClient] = None
125
+
126
+
127
+ def _get_shared_nim_client() -> httpx.AsyncClient:
128
+ """Return the module-level shared httpx.AsyncClient, constructing it
129
+ lazily on first call. The client is process-wide; do NOT close it from
130
+ individual chat() calls — the OS reclaims sockets at process exit.
131
+
132
+ Per-request timeout is applied at .post() call time, not at construction,
133
+ so the same shared pool serves chat() (long timeouts) and probes
134
+ (short timeouts) without contention.
135
+ """
136
+ global _NIM_HTTPX_CLIENT
137
+ if _NIM_HTTPX_CLIENT is not None:
138
+ return _NIM_HTTPX_CLIENT
139
+ with _NIM_HTTPX_CLIENT_LOCK:
140
+ if _NIM_HTTPX_CLIENT is None:
141
+ _NIM_HTTPX_CLIENT = httpx.AsyncClient(
142
+ limits=httpx.Limits(
143
+ max_connections=10,
144
+ max_keepalive_connections=5,
145
+ ),
146
+ )
147
+ return _NIM_HTTPX_CLIENT
148
+
149
+
150
  NVIDIA_NIM_BASE_URL = "https://integrate.api.nvidia.com/v1"
151
  # 2026-05-14 brain swap (D-022): NIM's DeepSeek-V4 + Meta Llama inference pools
152
  # are repeatedly timing out (15-120s on chat completions, no response). Qwen
 
202
  "max_tokens": max_tokens,
203
  }
204
  if response_format:
205
+ # A4 (2026-05-15) — NIM forwards OpenAI-shape response_format to
206
+ # the upstream model. For models that support `nvext.guided_json`
207
+ # (NIM's stricter constrained-decoding surface), surface that
208
+ # too when the caller passes a JSON schema (response_format
209
+ # with `json_schema`). The plain `{"type": "json_object"}` flag
210
+ # works on most NIM models as-is; we just keep it forwarded.
211
  body["response_format"] = response_format
212
+ if response_format.get("type") == "json_schema":
213
+ schema = response_format.get("json_schema", {}).get("schema")
214
+ if schema:
215
+ body.setdefault("nvext", {})["guided_json"] = schema
216
 
217
  headers = {
218
  "Authorization": f"Bearer {self.api_key}",
 
255
  # The semaphore wraps ONLY the HTTP round-trip — not response
256
  # parsing or usage logging — so we cap concurrent network
257
  # traffic without serialising the rest of the pipeline.
258
+ # A4 (2026-05-15) Use the module-level shared httpx.AsyncClient
259
+ # (singleton with bounded connection pool) instead of constructing
260
+ # a fresh client per request. Per-request timeout is applied at
261
+ # .post() call time so the shared pool serves both long-timeout
262
+ # chat() and short-timeout probe() calls without contention.
263
+ client = _get_shared_nim_client()
264
+ async with _NIM_OUTBOUND_SEMAPHORE:
265
+ resp = await client.post(
266
+ url, headers=headers, json=body, timeout=client_timeout
267
+ )
268
+ resp.raise_for_status()
269
+ payload = resp.json()
270
 
271
  choice = payload["choices"][0]
272
  msg = choice.get("message", {}) or {}
 
500
  Two models in the SAME family must never be paired as brain ↔ judge
501
  because they share weights / training corpus / decision surface, so
502
  the judge would effectively grade its own siblings' output.
503
+ Families: 'qwen', 'mistral', 'meta', 'openai', 'openai_oss',
504
+ 'deepseek', 'moonshot', 'minimax', 'nvidia', 'nemotron', 'llama3',
505
+ 'google', 'unknown'.
506
+
507
+ A4 (2026-05-15) — EVERY model in BRAIN_CHAIN / FAST_BRAIN_CHAIN /
508
+ JUDGE_CHAIN must map to a known family. The `assert_family_coverage()`
509
+ helper below walks the chains at module-import time (or on demand
510
+ from tests / admin / probe loop) and raises if any candidate falls
511
+ through to "unknown".
512
  """
513
+ m = (model_id or "").lower()
514
+ # KI-220 — recognise Google's Gemini family BEFORE prefix-stripping so
515
+ # ids like "google/gemini-2.5-flash" and bare "gemini-..." both land in
516
+ # the "google" bucket. Without this branch the family lookup fell
517
+ # through to "unknown", which meant the judge-vs-brain cross-family
518
+ # invariant couldn't exclude Gemini brains from a Gemini judge.
519
+ if "gemini" in m or m.startswith("google/"):
520
+ return "google"
521
  # Strip provider prefix first so 'groq:llama-3.3-70b' → 'meta' (it IS Meta Llama)
522
  if ":" in m:
523
  m = m.split(":", 1)[1]
524
+ # A4 — nemotron is its own decision surface (NVIDIA fine-tuned on
525
+ # top of llama but with materially different post-training); keep
526
+ # it distinct from generic "nvidia/" so cross-family checks don't
527
+ # treat a Llama-4 vs Nemotron pairing as same-family.
528
+ if "nemotron" in m: return "nemotron"
529
  if "qwen" in m: return "qwen"
530
  if "mistral" in m: return "mistral"
531
+ # A4 — llama version-aware buckets. llama-3.x is materially distinct
532
+ # from llama-4 (different architecture + post-training). The wider
533
+ # "meta" bucket stays as a fallback for unknown-version llama ids.
534
+ if "llama-3" in m or "llama3" in m: return "llama3"
535
  if "llama" in m or m.startswith("meta/"): return "meta"
536
+ # A4 — gpt-oss (OpenAI's open-weights line) is distinct from
537
+ # closed-source GPT models. Keep them on separate buckets so a
538
+ # GPT-OSS judge over a GPT-OSS brain isn't accidentally allowed.
539
+ if "gpt-oss" in m: return "openai_oss"
540
+ if m.startswith("openai/") or "gpt-4" in m or "gpt-5" in m: return "openai"
541
  if "deepseek" in m: return "deepseek"
542
  if "kimi" in m or m.startswith("moonshot"): return "moonshot"
543
  if "minimax" in m: return "minimax"
544
+ if m.startswith("nvidia/"): return "nvidia"
545
  return "unknown"
546
 
547
  # KI-080 — per-call timeout used in the sticky-primary path. Each elected
 
887
  return [groq_model, *rotated]
888
 
889
 
890
+ def assert_family_coverage() -> dict[str, str]:
891
+ """A4 (2026-05-15) — Verify every model in every chain maps to a known
892
+ family (i.e. NOT 'unknown'). Returns a {model: family} dict on success;
893
+ raises RuntimeError listing any 'unknown'-mapped models on failure.
894
+
895
+ KI-175 — also verifies nemotron is the TAIL (last entry) of each brain
896
+ chain so the last-resort ordering invariant survives any chain edit.
897
+
898
+ Call from admin diagnostics or tests; not invoked on import to keep
899
+ cold-start cheap. Safe to run from a probe loop tick.
900
+ """
901
+ coverage: dict[str, str] = {}
902
+ unknown: list[str] = []
903
+ for chain in (BRAIN_CHAIN, FAST_BRAIN_CHAIN, JUDGE_CHAIN):
904
+ for m in chain:
905
+ fam = NimChainLLM._family_of(m)
906
+ coverage[m] = fam
907
+ if fam == "unknown":
908
+ unknown.append(m)
909
+ if unknown:
910
+ raise RuntimeError(
911
+ f"_family_of returned 'unknown' for: {unknown}. "
912
+ "Extend NimChainLLM._family_of so cross-family grading checks "
913
+ "have a stable bucket for every candidate."
914
+ )
915
+ # KI-175 tail-position invariant — nemotron MUST be the last entry of
916
+ # both brain chains (it's the last-resort fallback). Judge chain is
917
+ # exempt because Nemotron is a legitimate cross-family judge there.
918
+ for label, chain in (("BRAIN_CHAIN", BRAIN_CHAIN),
919
+ ("FAST_BRAIN_CHAIN", FAST_BRAIN_CHAIN)):
920
+ nemo_idx = next(
921
+ (i for i, m in enumerate(chain) if "nemotron" in m.lower()), None
922
+ )
923
+ if nemo_idx is not None and nemo_idx != len(chain) - 1:
924
+ raise RuntimeError(
925
+ f"KI-175 violation: nemotron must be the LAST entry of {label} "
926
+ f"(found at index {nemo_idx}, chain length {len(chain)})."
927
+ )
928
+ return coverage
929
+
930
+
931
  def get_brain_llm() -> NimChainLLM:
932
  """Heavy brain — KI-080 sticky-primary election over BRAIN_CHAIN.
933
 
backend/providers/openrouter_llm.py CHANGED
@@ -40,6 +40,57 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
40
  DEFAULT_MODEL = "openai/gpt-oss-120b"
41
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  class OpenRouterLLM(LLMProvider):
44
  name = "openrouter"
45
 
@@ -69,6 +120,7 @@ class OpenRouterLLM(LLMProvider):
69
  max_tokens: int = 1024,
70
  response_format: Optional[dict] = None,
71
  models: Optional[list[str]] = None,
 
72
  ) -> LLMResult:
73
  """Send a chat completion to OpenRouter.
74
 
@@ -78,7 +130,26 @@ class OpenRouterLLM(LLMProvider):
78
  set as the primary `model` field (OpenRouter requires both for
79
  routing; if `models` is omitted the lone `model` field is used).
80
  Reference: https://openrouter.ai/docs/features/model-routing
 
 
 
 
 
 
 
81
  """
 
 
 
 
 
 
 
 
 
 
 
 
82
  primary_model = models[0] if models else self.model
83
  body: dict = {
84
  "model": primary_model,
 
40
  DEFAULT_MODEL = "openai/gpt-oss-120b"
41
 
42
 
43
+ # A4 (2026-05-15) — OpenRouter free-pool helpers.
44
+ #
45
+ # `:free` suffix is OpenRouter's documented marker for zero-cost models.
46
+ # We use this both to:
47
+ # (a) order the free pool deterministically (smallest-id-first as a
48
+ # stable, cheapest-first proxy — small models are cheaper to serve
49
+ # and complete faster); and
50
+ # (b) reject any non-`:free` model that accidentally lands in the free
51
+ # pool, so a paid model can't get silently invoked under a "free"
52
+ # call path and burn the user's prepaid balance.
53
+ _FREE_SUFFIX = ":free"
54
+
55
+
56
+ def is_free_model(model_id: str) -> bool:
57
+ """True if `model_id` is in OpenRouter's free pool (`:free` suffix).
58
+ Used by `enforce_free_pool` + the chat-time cost guard.
59
+ """
60
+ return bool(model_id) and model_id.endswith(_FREE_SUFFIX)
61
+
62
+
63
+ def order_free_pool(models: list[str]) -> list[str]:
64
+ """A4 (2026-05-15) — Return `models` in a STABLE, deterministic order
65
+ suitable for OpenRouter's `models=[...]` server-side fallback list.
66
+
67
+ Sort key: (length-of-id ascending, id ascending). Shorter ids tend to
68
+ correlate with smaller / cheaper models on OpenRouter's catalogue
69
+ (e.g. `gemma-4-26b-a4b-it:free` < `gemma-4-31b-it:free` < ...).
70
+ Stable alphabetical secondary key removes the randomness that an
71
+ unsorted dict iteration could introduce across Python runs.
72
+
73
+ Inputs that contain non-free entries are NOT silently re-ordered —
74
+ use `enforce_free_pool` first if you need the cost guard.
75
+ """
76
+ return sorted(models, key=lambda m: (len(m), m))
77
+
78
+
79
+ def enforce_free_pool(models: list[str]) -> list[str]:
80
+ """A4 (2026-05-15) — Cost guard: reject paid models in a free-pool call.
81
+
82
+ Raises ValueError listing any non-`:free` entries. Returns the
83
+ deterministically-ordered free pool on success.
84
+ """
85
+ paid = [m for m in models if not is_free_model(m)]
86
+ if paid:
87
+ raise ValueError(
88
+ f"OpenRouter free-pool call rejected: non-free models {paid}. "
89
+ "All entries must end in ':free'. See get_openrouter_llm() docs."
90
+ )
91
+ return order_free_pool(models)
92
+
93
+
94
  class OpenRouterLLM(LLMProvider):
95
  name = "openrouter"
96
 
 
120
  max_tokens: int = 1024,
121
  response_format: Optional[dict] = None,
122
  models: Optional[list[str]] = None,
123
+ free_only: bool = False,
124
  ) -> LLMResult:
125
  """Send a chat completion to OpenRouter.
126
 
 
130
  set as the primary `model` field (OpenRouter requires both for
131
  routing; if `models` is omitted the lone `model` field is used).
132
  Reference: https://openrouter.ai/docs/features/model-routing
133
+
134
+ A4 (2026-05-15) — `free_only=True` activates the cost guard:
135
+ - rejects any non-`:free` model in `models` (raises ValueError)
136
+ - reorders the survivors via `order_free_pool` for stable, smaller-
137
+ first preference. The single-model `self.model` is checked too
138
+ when `models` is omitted, so a paid default can't sneak in via
139
+ the lone-model path.
140
  """
141
+ # A4 cost guard — enforce BEFORE constructing the request body so
142
+ # a paid model never reaches the wire.
143
+ if free_only:
144
+ if models:
145
+ models = enforce_free_pool(list(models))
146
+ elif not is_free_model(self.model):
147
+ raise ValueError(
148
+ f"OpenRouter free-pool call rejected: default model "
149
+ f"{self.model!r} is not in the :free pool. Pass "
150
+ f"`models=[...]` with :free suffix or unset free_only."
151
+ )
152
+
153
  primary_model = models[0] if models else self.model
154
  body: dict = {
155
  "model": primary_model,
backend/providers/tiered_brain_llm.py CHANGED
@@ -72,7 +72,7 @@ class TieredBrainLLM(LLMProvider):
72
  self,
73
  role: str = "brain",
74
  gemini_model: str = "gemini-2.5-flash",
75
- per_tier_timeout: float = 30.0,
76
  ):
77
  if role not in ("brain", "fast_brain"):
78
  raise ValueError(f"role must be 'brain' or 'fast_brain', got {role!r}")
 
72
  self,
73
  role: str = "brain",
74
  gemini_model: str = "gemini-2.5-flash",
75
+ per_tier_timeout: float = 15.0,
76
  ):
77
  if role not in ("brain", "fast_brain"):
78
  raise ValueError(f"role must be 'brain' or 'fast_brain', got {role!r}")
backend/retrieval_filters.py ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Retrieval-side filters + guards (A3 / A6 audit fixes).
2
+
3
+ This module is a SIDECAR to `rag/retrieve.py` and `backend/orchestrator.py`.
4
+ It is deliberately a separate file so concurrent edits to orchestrator.py
5
+ don't conflict with the retrieval-correctness work.
6
+
7
+ Public API:
8
+ apply_profile_filter(chunks, profile) -> list[RetrievedChunk]
9
+ Drop chunks for policies the user is demographically ineligible for.
10
+ bypass_cosine_for_exact_match(chunks, query) -> list[RetrievedChunk] | None
11
+ If the query contains an IRDAI UIN or an exact policy name, return a
12
+ substring-matched chunk list (caller can use this instead of cosine).
13
+ Returns None if no exact-match signal in the query.
14
+ empty_retrieval_guard(chunks, intent) -> dict | None
15
+ Return a structured "empty_retrieval" signal when filtered chunk count
16
+ is below the minimum for a recommendation intent.
17
+ enforce_citation_grounding(chunks) -> list[RetrievedChunk]
18
+ Reject chunks missing policy_id / policy_name / chunk_offset.
19
+ dedup_by_policy(chunks) -> list[RetrievedChunk]
20
+ Within a top-K, keep highest-score chunk per policy_id.
21
+
22
+ All functions are pure / side-effect-free; safe to call from the orchestrator
23
+ or from rag/retrieve.py without import-cycle risk.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from typing import Any, Iterable, Optional
30
+
31
+ # We avoid importing RetrievedChunk at module-import time to keep this file
32
+ # import-cycle-safe. Instead we type-duck on attributes.
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Constants — keep tunables here so they're greppable from one place.
36
+ # ---------------------------------------------------------------------------
37
+
38
+ # Age tolerance for the profile pre-filter. The catalog's min_entry_age /
39
+ # max_entry_age is the regulator-filed entry age, but real underwriters allow
40
+ # a small grace band; we mirror that grace here so we don't accidentally
41
+ # drop a perfectly fine policy because the user is one year off the edge.
42
+ PROFILE_AGE_TOLERANCE = 2
43
+
44
+ # A "senior-only" policy is one whose marketing/eligibility makes it
45
+ # inappropriate for adults under 50. We detect this from the policy name
46
+ # AND from the min_entry_age metadata (>=60 ⇒ senior-only).
47
+ SENIOR_ONLY_NAME_RE = re.compile(
48
+ r"\b(senior|red\s*carpet|silver|elder|varisht|varistha|"
49
+ r"sixty\s*plus|60\s*plus|seniority|golden\s*years)\b",
50
+ flags=re.IGNORECASE,
51
+ )
52
+
53
+ # Adult-only / young-adult-only plans typically cap at 50 or 55 and have
54
+ # no senior variant. If the user is >=60 these are inappropriate
55
+ # (they need a senior variant of the SAME insurer/family instead).
56
+ ADULT_ONLY_NAME_RE = re.compile(
57
+ r"\b(young\s*star|young\s*adult|millennial|gen\s*z|under\s*45|"
58
+ r"early\s*career|first\s*time)\b",
59
+ flags=re.IGNORECASE,
60
+ )
61
+
62
+ # Maternity-themed policies. If the profile has no female adult AND no
63
+ # maternity goal, these are noise.
64
+ MATERNITY_NAME_RE = re.compile(
65
+ r"\b(maternity|mother\s*&?\s*baby|mother\s*to\s*be|"
66
+ r"new\s*born|joy|stork|baby\s*shield|pregnancy)\b",
67
+ flags=re.IGNORECASE,
68
+ )
69
+
70
+ # Minimum chunk count to attempt a recommendation. Below this we ask for
71
+ # one more clarifier instead of letting the brain hallucinate.
72
+ MIN_CHUNKS_FOR_RECOMMENDATION = 3
73
+
74
+ # Recommendation-style intents — the empty-retrieval guard fires only on these.
75
+ # Other intents (faq, regulatory, smalltalk) tolerate sparse retrieval.
76
+ _RECOMMENDATION_INTENTS = {
77
+ "recommend",
78
+ "recommendation",
79
+ "compare",
80
+ "comparison",
81
+ "suggest",
82
+ "shortlist",
83
+ "best_policy",
84
+ "pick_for_me",
85
+ }
86
+
87
+ # IRDAI UIN pattern. Real UINs look like "IRDA/HLT/HDFC/V.I/188/14-15"
88
+ # or "IRDAI/HLT/HDFC/V.I/188/14-15" — we accept both.
89
+ UIN_RE = re.compile(
90
+ r"\b(?:IRDAI?|UIN)[/:]\s*[A-Z0-9./\-]{6,}",
91
+ flags=re.IGNORECASE,
92
+ )
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Helpers
97
+ # ---------------------------------------------------------------------------
98
+
99
+ def _meta(chunk: Any) -> dict:
100
+ """Pull metadata off a chunk regardless of whether the caller hands us a
101
+ RetrievedChunk dataclass or a raw dict. RetrievedChunk stores fields as
102
+ top-level attributes; ingestion-side code sometimes passes a dict."""
103
+ if isinstance(chunk, dict):
104
+ return chunk
105
+ out = {}
106
+ for k in (
107
+ "policy_id", "policy_name", "insurer_slug", "doc_type",
108
+ "chunk_idx", "chunk_offset", "min_entry_age", "max_entry_age",
109
+ "score", "text",
110
+ ):
111
+ if hasattr(chunk, k):
112
+ out[k] = getattr(chunk, k)
113
+ return out
114
+
115
+
116
+ def _profile_get(profile: Any, key: str, default=None):
117
+ """Profile may be a dataclass (needs_finder.Profile) OR a dict — accept either."""
118
+ if profile is None:
119
+ return default
120
+ if isinstance(profile, dict):
121
+ return profile.get(key, default)
122
+ return getattr(profile, key, default)
123
+
124
+
125
+ def _profile_has_female_adult(profile: Any) -> bool:
126
+ """Heuristic: profile covers a female adult if dependents includes spouse
127
+ (the user might be the female adult, or spouse may be). We treat
128
+ 'spouse' tokens as a positive signal and 'self+spouse', 'family' shapes
129
+ likewise. Conservative: return True when uncertain (better to keep
130
+ maternity chunks than to wrongly drop them)."""
131
+ dep = _profile_get(profile, "dependents") or ""
132
+ if not isinstance(dep, str):
133
+ return True
134
+ dep_lower = dep.lower()
135
+ if any(tok in dep_lower for tok in ("spouse", "wife", "family", "kids", "child")):
136
+ return True
137
+ # No spouse / no family signal AND explicit self-only → no female adult
138
+ if dep_lower in ("self", "self_only", "individual", "just_me"):
139
+ return False
140
+ return True # default permissive
141
+
142
+
143
+ def _profile_has_maternity_goal(profile: Any) -> bool:
144
+ goal = _profile_get(profile, "primary_goal") or ""
145
+ if not isinstance(goal, str):
146
+ return False
147
+ return "maternity" in goal.lower() or "pregnan" in goal.lower() or "baby" in goal.lower()
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # (1) Profile-fit pre-filter (A3 / A6)
152
+ # ---------------------------------------------------------------------------
153
+
154
+ def apply_profile_filter(chunks: Iterable[Any], profile: Any) -> list[Any]:
155
+ """Drop chunks that are demographically inappropriate for this user.
156
+
157
+ Rules:
158
+ - min_entry_age > age + 2 → drop
159
+ - max_entry_age < age - 2 → drop
160
+ - senior-only plan AND age < 50 → drop (user is too young)
161
+ - adult-only plan AND age >= 60 → drop (user needs senior variant)
162
+ - maternity plan AND no female adult / no maternity goal → drop
163
+
164
+ Profile chunks (doc_type == 'profile') and regulatory chunks
165
+ (doc_type == 'regulatory' / 'review') are NEVER dropped here — those
166
+ aren't policies and the demographic rules don't apply.
167
+
168
+ Conservative on missing data: if a chunk doesn't expose min/max entry
169
+ age metadata, we DO NOT drop it on those rules (we still apply the
170
+ name-based senior/adult/maternity rules where the name pattern matches).
171
+ """
172
+ chunks_list = list(chunks)
173
+ if not chunks_list:
174
+ return chunks_list
175
+
176
+ age = _profile_get(profile, "age")
177
+ # If age unknown, only the maternity rule can fire — keep everything else.
178
+ has_age = isinstance(age, int)
179
+
180
+ has_female_adult = _profile_has_female_adult(profile)
181
+ maternity_goal = _profile_has_maternity_goal(profile)
182
+
183
+ kept: list[Any] = []
184
+ for ch in chunks_list:
185
+ m = _meta(ch)
186
+ doc_type = (m.get("doc_type") or "").lower()
187
+ # Never drop non-policy chunks via demographic filter
188
+ if doc_type in ("profile", "regulatory", "review"):
189
+ kept.append(ch)
190
+ continue
191
+
192
+ name = (m.get("policy_name") or "").strip()
193
+ min_age = m.get("min_entry_age")
194
+ max_age = m.get("max_entry_age")
195
+
196
+ # Numeric age-range gate
197
+ if has_age:
198
+ try:
199
+ if isinstance(min_age, (int, float)) and min_age > age + PROFILE_AGE_TOLERANCE:
200
+ continue
201
+ if isinstance(max_age, (int, float)) and max_age < age - PROFILE_AGE_TOLERANCE:
202
+ continue
203
+ except TypeError:
204
+ pass # metadata corruption — fall through to name rules
205
+
206
+ # Senior-only inferred from name OR from min_entry_age >= 60
207
+ is_senior_only = bool(SENIOR_ONLY_NAME_RE.search(name)) or (
208
+ isinstance(min_age, (int, float)) and min_age >= 60
209
+ )
210
+ if is_senior_only and has_age and age < 50:
211
+ continue
212
+
213
+ # Adult-only inferred from name (no metadata signal exists for this).
214
+ # If user is 60+ AND policy looks adult-only AND has max_age < 60, drop.
215
+ is_adult_only_name = bool(ADULT_ONLY_NAME_RE.search(name))
216
+ if has_age and age >= 60 and is_adult_only_name:
217
+ continue
218
+ if has_age and age >= 60 and isinstance(max_age, (int, float)) and max_age < 60:
219
+ continue
220
+
221
+ # Maternity gate — only drop if BOTH conditions fail
222
+ if MATERNITY_NAME_RE.search(name) and not maternity_goal and not has_female_adult:
223
+ continue
224
+
225
+ kept.append(ch)
226
+
227
+ return kept
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # (2) Hybrid retrieval — exact-match bypass on UIN or policy name
232
+ # ---------------------------------------------------------------------------
233
+
234
+ def _extract_uin(query: str) -> Optional[str]:
235
+ if not query:
236
+ return None
237
+ m = UIN_RE.search(query)
238
+ return m.group(0) if m else None
239
+
240
+
241
+ def _extract_quoted_policy_name(query: str) -> Optional[str]:
242
+ """Pull a quoted policy name out of the query if present."""
243
+ if not query:
244
+ return None
245
+ # "..." or "..." or '...'
246
+ for pat in (r'"([^"]{6,})"', r"“([^”]{6,})”", r"'([^']{6,})'"):
247
+ m = re.search(pat, query)
248
+ if m:
249
+ return m.group(1).strip()
250
+ return None
251
+
252
+
253
+ def bypass_cosine_for_exact_match(
254
+ chunks: Iterable[Any],
255
+ query: str,
256
+ ) -> Optional[list[Any]]:
257
+ """If the query contains an exact UIN or an exact (quoted) policy name,
258
+ return a substring-matched subset of `chunks`. This is the lexical
259
+ "BM25-style" fallback — when the user clearly knows what they want, we
260
+ should not let cosine similarity reorder away from their literal target.
261
+
262
+ Returns None when no exact-match signal is present (caller falls back
263
+ to normal cosine results). Returns an empty list if a signal was present
264
+ but no chunk matched — that's a useful "we know what you mean, but our
265
+ catalog doesn't have it" signal for the orchestrator's empty-retrieval
266
+ guard.
267
+ """
268
+ chunks_list = list(chunks)
269
+ uin = _extract_uin(query)
270
+ quoted = _extract_quoted_policy_name(query)
271
+ if not uin and not quoted:
272
+ return None
273
+
274
+ needles: list[str] = []
275
+ if uin:
276
+ needles.append(uin.lower())
277
+ if quoted:
278
+ needles.append(quoted.lower())
279
+
280
+ matched: list[Any] = []
281
+ for ch in chunks_list:
282
+ m = _meta(ch)
283
+ haystack = " ".join(
284
+ str(m.get(k, "")) for k in ("policy_id", "policy_name", "insurer_slug", "text")
285
+ ).lower()
286
+ if any(n in haystack for n in needles):
287
+ matched.append(ch)
288
+
289
+ return matched
290
+
291
+
292
+ # ---------------------------------------------------------------------------
293
+ # (3) Empty-retrieval guard
294
+ # ---------------------------------------------------------------------------
295
+
296
+ def empty_retrieval_guard(
297
+ chunks: Iterable[Any],
298
+ intent: Optional[str] = None,
299
+ min_chunks: int = MIN_CHUNKS_FOR_RECOMMENDATION,
300
+ ) -> Optional[dict]:
301
+ """Return a structured signal if a recommendation intent has too few
302
+ chunks to ground an answer. The orchestrator should surface this to the
303
+ user as a clarifier question instead of calling the brain.
304
+
305
+ Returns None when the retrieval is healthy (or the intent doesn't need
306
+ much grounding).
307
+ """
308
+ chunks_list = list(chunks)
309
+ intent_norm = (intent or "").lower().strip()
310
+ if intent_norm and intent_norm not in _RECOMMENDATION_INTENTS:
311
+ return None # FAQ / regulatory / smalltalk intents are fine with sparse retrieval
312
+
313
+ if len(chunks_list) >= min_chunks:
314
+ return None
315
+
316
+ return {
317
+ "reason": "empty_retrieval",
318
+ "fallback": "Ask 1 more clarifier",
319
+ "chunk_count": len(chunks_list),
320
+ "min_required": min_chunks,
321
+ }
322
+
323
+
324
+ # ---------------------------------------------------------------------------
325
+ # (4) Citation grounding — require policy_id, policy_name, chunk_offset
326
+ # ---------------------------------------------------------------------------
327
+
328
+ def enforce_citation_grounding(chunks: Iterable[Any]) -> list[Any]:
329
+ """Drop chunks missing any of the three citation-critical fields.
330
+
331
+ The "chunk_offset" requirement maps onto either `chunk_offset` (newer
332
+ ingestions) or the existing `chunk_idx` (legacy + current). At least
333
+ one must be a non-negative int.
334
+ """
335
+ kept: list[Any] = []
336
+ for ch in chunks:
337
+ m = _meta(ch)
338
+ pid = m.get("policy_id")
339
+ pname = m.get("policy_name")
340
+ # accept either field name; chunk_idx is the existing schema in rag/retrieve.py
341
+ offset = m.get("chunk_offset")
342
+ if offset is None:
343
+ offset = m.get("chunk_idx")
344
+
345
+ if not pid or not isinstance(pid, str):
346
+ continue
347
+ if not pname or not isinstance(pname, str):
348
+ continue
349
+ if not isinstance(offset, int) or offset < 0:
350
+ continue
351
+ kept.append(ch)
352
+ return kept
353
+
354
+
355
+ # ---------------------------------------------------------------------------
356
+ # (5) Dedup by policy_id — keep highest-score chunk per policy
357
+ # ---------------------------------------------------------------------------
358
+
359
+ def dedup_by_policy(chunks: Iterable[Any]) -> list[Any]:
360
+ """Within top-K results, collapse to one chunk per policy_id, keeping
361
+ the highest-scoring chunk. Preserves the original ordering of the kept
362
+ chunks (i.e. ranks chunks by their best-in-policy score)."""
363
+ best: dict[str, Any] = {}
364
+ order: list[str] = []
365
+ for ch in chunks:
366
+ m = _meta(ch)
367
+ pid = m.get("policy_id") or ""
368
+ score = m.get("score")
369
+ if score is None:
370
+ # try attribute path
371
+ score = getattr(ch, "score", 0.0)
372
+ try:
373
+ score_f = float(score)
374
+ except (TypeError, ValueError):
375
+ score_f = 0.0
376
+
377
+ if pid not in best:
378
+ best[pid] = (ch, score_f)
379
+ order.append(pid)
380
+ continue
381
+ prev_chunk, prev_score = best[pid]
382
+ if score_f > prev_score:
383
+ best[pid] = (ch, score_f)
384
+
385
+ return [best[pid][0] for pid in order]
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # Convenience — compose the standard retrieval-side pipeline.
390
+ # ---------------------------------------------------------------------------
391
+
392
+ def filter_pipeline(
393
+ chunks: Iterable[Any],
394
+ profile: Any = None,
395
+ query: str = "",
396
+ intent: Optional[str] = None,
397
+ ) -> tuple[list[Any], Optional[dict]]:
398
+ """Run the standard A3/A6 retrieval-side pipeline:
399
+
400
+ 1. Citation grounding (reject malformed chunks)
401
+ 2. Profile pre-filter (drop demographically inappropriate policies)
402
+ 3. Exact-match bypass (if query has a UIN / quoted name, swap in)
403
+ 4. Dedup by policy_id
404
+ 5. Empty-retrieval guard
405
+
406
+ Returns (filtered_chunks, guard_signal_or_None). When guard_signal is
407
+ set, the caller should NOT pass `filtered_chunks` to the brain — they
408
+ should surface the clarifier instead.
409
+ """
410
+ grounded = enforce_citation_grounding(chunks)
411
+ fitted = apply_profile_filter(grounded, profile)
412
+
413
+ exact = bypass_cosine_for_exact_match(grounded, query)
414
+ if exact is not None and exact:
415
+ # Exact match wins over cosine when the user clearly named a target.
416
+ # Still apply profile filter to exact matches (the user might ask
417
+ # about a senior plan when they're 25 — show it, but log it).
418
+ fitted = exact
419
+
420
+ deduped = dedup_by_policy(fitted)
421
+ guard = empty_retrieval_guard(deduped, intent=intent)
422
+ return deduped, guard
backend/sales_brain_normalizer.py CHANGED
@@ -264,6 +264,12 @@ _NAME_BAD_FIRST_WORDS = {
264
  "don", "dont", "don't",
265
  "i", "we", "my", "this", "that", "the",
266
  "first", "still", "yet",
 
 
 
 
 
 
267
  }
268
 
269
 
@@ -296,12 +302,21 @@ def _normalize_name(value: Any) -> Optional[str]:
296
  break
297
  if not s:
298
  return None
 
 
 
 
299
  first = s.split()[0].lower().strip(".,!?")
300
  if first in _NAME_BAD_FIRST_WORDS:
301
  return None
302
  alpha = sum(1 for c in s if c.isalpha())
303
  if alpha < 2 or alpha / max(1, len(s)) < 0.5:
304
  return None
 
 
 
 
 
305
  # Capitalize if all-lower (LLM convention — names should be Title Case).
306
  if not any(c.isupper() for c in s):
307
  s = " ".join(w.capitalize() for w in s.split())
@@ -387,7 +402,25 @@ def _normalize_dependents(value: Any, schema: dict) -> Optional[str]:
387
  return "self+kids"
388
  if has_parents:
389
  return "self+parents"
390
- if s in ("self", "me", "just me", "only me", "myself", "only self"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  return "self"
392
  return None
393
 
@@ -399,14 +432,54 @@ def _normalize_income_band(value: Any, schema: dict) -> Optional[str]:
399
  s = value.strip()
400
  if s in schema["values"]:
401
  return s
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  # KI-149 — let the centralised parser handle free text.
403
  parsed = _parse_income_band(s)
404
  if parsed in schema["values"]:
405
  return parsed
406
  return None
407
  if isinstance(value, (int, float)):
408
- # Bare number → rupees → bucket
 
 
 
409
  amt = int(value)
 
 
410
  if amt < 500_000:
411
  return "under_5L"
412
  if amt < 1_000_000:
@@ -432,6 +505,20 @@ def _normalize_existing_cover(value: Any, schema: dict) -> Optional[int]:
432
  s = value.strip().lower()
433
  if not s:
434
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  # Negative answers → 0
436
  if re.search(
437
  r"\b(no|none|nothing|zero|nope|nah|haven'?t|don'?t|never|"
@@ -439,7 +526,22 @@ def _normalize_existing_cover(value: Any, schema: dict) -> Optional[int]:
439
  s,
440
  ):
441
  return 0
442
- amt = _parse_inr_amount(s)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  if amt is None:
444
  # Bare digit fallback (very small values OK here — user might
445
  # have ₹500 unused; still coerce within bounds).
@@ -479,15 +581,38 @@ def _normalize_primary_goal(value: Any, schema: dict) -> Optional[str]:
479
  # Direct hit on alias or canonical
480
  if s in _GOAL_ALIASES:
481
  return _GOAL_ALIASES[s]
482
- # Keyword fall-through
483
- if any(k in s for k in ("first policy", "first one", "first time", "first buy", "new policy", "buying my first")):
484
- return "first_buy"
485
- if any(k in s for k in ("upgrade", "upgrading", "better cover", "more cover", "increase cover")):
486
- return "upgrade"
487
- if any(k in s for k in ("compare", "comparison", " vs ", " vs.", "versus")):
488
- return "compare_specific"
489
- if any(k in s for k in (" tax ", "80d", "deduction", "tax planning", "tax saving")):
 
490
  return "tax_planning"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
  return None
492
 
493
 
@@ -554,6 +679,11 @@ def _normalize_budget_band(value: Any, schema: dict) -> Optional[str]:
554
  return None
555
  if isinstance(value, (int, float)):
556
  amt = int(value)
 
 
 
 
 
557
  if amt < 15_000:
558
  return "under_15k"
559
  if amt < 30_000:
@@ -577,12 +707,35 @@ _NO_PED_PATTERNS = (
577
  )
578
 
579
  _COND_KEYWORDS: dict[str, tuple] = {
580
- "diabetes": ("diabetes", "diabetic", "sugar"),
581
- "hypertension": ("hypertension", "blood pressure", "high bp"),
582
- "thyroid": ("thyroid", "hypothyroid", "hyperthyroid"),
583
- "asthma": ("asthma",),
584
- "heart": ("heart problem", "heart disease", "cardiac"),
585
- "cancer": ("cancer", "tumor", "tumour"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  }
587
  # "bp" as a free-standing token (case-insensitive word-boundary). Kept
588
  # separate from `_COND_KEYWORDS` so it doesn't collide with substrings
@@ -609,12 +762,27 @@ def _normalize_health_conditions(value: Any) -> Optional[list]:
609
  if s in seen:
610
  continue
611
  seen.add(s)
 
 
 
612
  # Map keyword hits to canonical names
613
  mapped = None
614
  for canon, kws in _COND_KEYWORDS.items():
615
  if s == canon or any(k.strip() == s for k in kws):
616
  mapped = canon
617
  break
 
 
 
 
 
 
 
 
 
 
 
 
618
  cleaned.append(mapped if mapped else s)
619
  # Dedup again after canonicalisation
620
  return list(dict.fromkeys(cleaned))
 
264
  "don", "dont", "don't",
265
  "i", "we", "my", "this", "that", "the",
266
  "first", "still", "yet",
267
+ # KI-221 — additional status / metadata words the LLM occasionally emits
268
+ # as a "name". These were observed live (e.g. "last", "surname", "hi")
269
+ # routing through as candidate names.
270
+ "last", "first", "sur", "lastname", "firstname", "surname", "name",
271
+ "hi", "hey", "ok", "hello", "okay", "yo", "hola",
272
+ "user", "person", "someone", "me", "myself",
273
  }
274
 
275
 
 
302
  break
303
  if not s:
304
  return None
305
+ # KI-221 — minimum length floor. Single-char "names" (e.g. "a", "x")
306
+ # are never real names and were leaking through the alpha-density check.
307
+ if len(s) < 2:
308
+ return None
309
  first = s.split()[0].lower().strip(".,!?")
310
  if first in _NAME_BAD_FIRST_WORDS:
311
  return None
312
  alpha = sum(1 for c in s if c.isalpha())
313
  if alpha < 2 or alpha / max(1, len(s)) < 0.5:
314
  return None
315
+ # KI-221 — every real name has at least one vowel. Rejects scraps like
316
+ # "Mr", "Dr", "St" (which slipped past the polite-prefix stripper when
317
+ # not followed by a space, e.g. the LLM emitting just "Dr").
318
+ if not any(c in "aeiouy" for c in s.lower()):
319
+ return None
320
  # Capitalize if all-lower (LLM convention — names should be Title Case).
321
  if not any(c.isupper() for c in s):
322
  s = " ".join(w.capitalize() for w in s.split())
 
402
  return "self+kids"
403
  if has_parents:
404
  return "self+parents"
405
+ # KI-222 expand the "self" alias set. Live captures showed users
406
+ # answering "single", "unmarried", "no dependents", "just myself" etc.,
407
+ # which previously fell through to None and got silently dropped — the
408
+ # bot then re-asked the same slot on the next turn.
409
+ _SELF_ALIASES = (
410
+ "self", "me", "just me", "only me", "myself", "only self",
411
+ "single", "unmarried", "alone", "bachelor", "no dependents",
412
+ "just myself", "nobody else", "no one else", "nobody",
413
+ "myself only", "by myself", "solo",
414
+ )
415
+ if s in _SELF_ALIASES:
416
+ return "self"
417
+ # Substring fall-through for the same intents when wrapped in extra prose
418
+ # (e.g. "i'm single right now", "just myself for now").
419
+ if any(alias in s for alias in (
420
+ "single", "unmarried", "no dependents", "just myself",
421
+ "by myself", "nobody else", "no one else", "myself only",
422
+ "bachelor", "solo",
423
+ )):
424
  return "self"
425
  return None
426
 
 
432
  s = value.strip()
433
  if s in schema["values"]:
434
  return s
435
+ # KI-225 — natural phrasing pre-pass (covers patterns the centralised
436
+ # `_parse_income_band` doesn't handle: "under 5 lakh", "between 5 and
437
+ # 10", "25 lakh plus", etc.). Lowercase + strip light punctuation, then
438
+ # match phrase patterns BEFORE delegating to the legacy parser.
439
+ s_norm = re.sub(r"[,\?\.]+$", "", s.lower()).strip()
440
+ s_norm = re.sub(r"\s+", " ", s_norm)
441
+ # under_5L family
442
+ if re.search(
443
+ r"\b(?:under|less\s+than|below|<)\s*(?:rs\.?\s*)?5\s*(?:l|lakh|lakhs|lac)\b",
444
+ s_norm,
445
+ ):
446
+ return "under_5L"
447
+ # 25L+ family — check BEFORE 10-25 so "above 25" doesn't match the
448
+ # 10-25 patterns by accident.
449
+ if re.search(
450
+ r"\b(?:25\s*l?\s*\+|25\s*(?:l|lakh|lakhs|lac)\s*plus|"
451
+ r"(?:above|over|more\s+than|>)\s*(?:rs\.?\s*)?25\s*(?:l|lakh|lakhs|lac)?|"
452
+ r"25\s*\+\s*(?:l|lakh|lakhs|lac))\b",
453
+ s_norm,
454
+ ):
455
+ return "25L+"
456
+ # 10L-25L family
457
+ if re.search(
458
+ r"\b(?:10\s*[-–to]+\s*25\s*(?:l|lakh|lakhs|lac)?|"
459
+ r"between\s+10\s+and\s+25(?:\s*(?:l|lakh|lakhs|lac))?)\b",
460
+ s_norm,
461
+ ):
462
+ return "10L-25L"
463
+ # 5L-10L family
464
+ if re.search(
465
+ r"\b(?:5\s*[-–to]+\s*10\s*(?:l|lakh|lakhs|lac)?|"
466
+ r"between\s+5\s+and\s+10(?:\s*(?:l|lakh|lakhs|lac))?)\b",
467
+ s_norm,
468
+ ):
469
+ return "5L-10L"
470
  # KI-149 — let the centralised parser handle free text.
471
  parsed = _parse_income_band(s)
472
  if parsed in schema["values"]:
473
  return parsed
474
  return None
475
  if isinstance(value, (int, float)):
476
+ # Bare number → rupees → bucket. KI-223 — require ≥1 lakh floor; bare
477
+ # numbers below that are almost always age / dependents-count noise
478
+ # (e.g. "I'm 29 years old" → LLM extracted 29 as "income"). Reject
479
+ # rather than mis-bucket as under_5L.
480
  amt = int(value)
481
+ if amt < 100_000:
482
+ return None
483
  if amt < 500_000:
484
  return "under_5L"
485
  if amt < 1_000_000:
 
505
  s = value.strip().lower()
506
  if not s:
507
  return None
508
+ # KI-225 — explicit no-cover phrasings (covers "no insurance",
509
+ # "no policy", "no existing", "first time so nothing", etc.). These
510
+ # mostly route through the original \b(no|none|...)\b pattern below
511
+ # but the more-specific multi-word phrasings need a dedicated pass so
512
+ # "no insurance currently" doesn't accidentally fall through to the
513
+ # digit fallback (which would extract '0' from no digits → None).
514
+ _no_cover_phrases = (
515
+ "no insurance", "no policy", "no policies", "no existing",
516
+ "no existing cover", "no existing policy", "no cover",
517
+ "first time so nothing", "nothing currently", "nothing right now",
518
+ "no pre-existing", "don't have any", "dont have any",
519
+ )
520
+ if any(p in s for p in _no_cover_phrases):
521
+ return 0
522
  # Negative answers → 0
523
  if re.search(
524
  r"\b(no|none|nothing|zero|nope|nah|haven'?t|don'?t|never|"
 
526
  s,
527
  ):
528
  return 0
529
+ # KI-225 — employer / corporate cover phrasings. The amount IS in the
530
+ # string; let `_parse_inr_amount` extract it (₹5L / 5 lakh / etc.).
531
+ # Phrases like "5L from work" / "5 lakh employer" don't trip the
532
+ # negative-answer regex above (they don't contain a no/none token).
533
+ # Spelled-out small numbers ("five lakh", "ten lakh") aren't covered
534
+ # by the centralised parser — do a light word-to-digit pre-pass here.
535
+ _SPELLED_DIGITS = {
536
+ "one": "1", "two": "2", "three": "3", "four": "4", "five": "5",
537
+ "six": "6", "seven": "7", "eight": "8", "nine": "9", "ten": "10",
538
+ "fifteen": "15", "twenty": "20", "twenty-five": "25", "fifty": "50",
539
+ }
540
+ _s_for_parse = s
541
+ for _word, _digit in _SPELLED_DIGITS.items():
542
+ # word-boundary substitution so "tense" doesn't become "10se"
543
+ _s_for_parse = re.sub(rf"\b{re.escape(_word)}\b", _digit, _s_for_parse)
544
+ amt = _parse_inr_amount(_s_for_parse)
545
  if amt is None:
546
  # Bare digit fallback (very small values OK here — user might
547
  # have ₹500 unused; still coerce within bounds).
 
581
  # Direct hit on alias or canonical
582
  if s in _GOAL_ALIASES:
583
  return _GOAL_ALIASES[s]
584
+ # KI-225 — natural phrasing keyword fall-through. Order matters: check the
585
+ # more-specific phrases (tax / compare) BEFORE the broader first_buy / upgrade
586
+ # buckets so e.g. "compare specific policies for tax savings" lands in
587
+ # tax_planning, not compare_specific, when both keyword sets match.
588
+ # tax_planning — most specific (section refs + explicit tax keywords)
589
+ if any(k in s for k in (
590
+ "tax planning", "for tax", "section 80d", "80d", "tax savings",
591
+ "tax saving", "tax benefit", "tax deduction", " tax ", "deduction",
592
+ )):
593
  return "tax_planning"
594
+ # compare_specific — explicit comparison phrasing
595
+ if any(k in s for k in (
596
+ "compare specific", "comparing", "compare", "comparison",
597
+ "looking at specific policies", "looking at specific polic",
598
+ " vs ", " vs.", "versus", "between policy", "between policies",
599
+ )):
600
+ return "compare_specific"
601
+ # upgrade — has existing cover, wants better
602
+ if any(k in s for k in (
603
+ "upgrade", "upgrading", "want to upgrade", "improve my existing",
604
+ "better than what i have", "replace my current", "replace my existing",
605
+ "better cover", "more cover", "increase cover",
606
+ )):
607
+ return "upgrade"
608
+ # first_buy — broadest bucket, check last
609
+ if any(k in s for k in (
610
+ "first time", "first policy", "first time buyer", "first one",
611
+ "first buy", "new policy", "buying my first", "buy my first",
612
+ "looking for my first", "looking for first",
613
+ "starting out", "new to insurance",
614
+ )):
615
+ return "first_buy"
616
  return None
617
 
618
 
 
679
  return None
680
  if isinstance(value, (int, float)):
681
  amt = int(value)
682
+ # KI-223 — require ≥₹5,000 floor; bare numbers below that are almost
683
+ # always age / dependents-count noise (e.g. age 29 leaking into the
684
+ # budget slot). Reject rather than mis-bucket as under_15k.
685
+ if amt < 5_000:
686
+ return None
687
  if amt < 15_000:
688
  return "under_15k"
689
  if amt < 30_000:
 
707
  )
708
 
709
  _COND_KEYWORDS: dict[str, tuple] = {
710
+ # KI-225 — alias coverage expanded per spec. Lay-terms ("sugar", "BP",
711
+ # "type 2 diabetes", "blood sugar", "respiratory") + clinical-adjacent
712
+ # phrasings ("cardiac", "heart problem", "tumor") all map onto the same
713
+ # 6 canonical buckets the downstream sales brain understands.
714
+ "diabetes": (
715
+ "diabetes", "diabetic", "sugar", "blood sugar",
716
+ "diabetes type 1", "diabetes type 2", "type 1 diabetes",
717
+ "type 2 diabetes", "type-1 diabetes", "type-2 diabetes",
718
+ ),
719
+ "hypertension": (
720
+ "hypertension", "blood pressure", "high blood pressure",
721
+ "high bp", "bp issue", "bp problem",
722
+ ),
723
+ "thyroid": (
724
+ "thyroid", "hypothyroid", "hyperthyroid",
725
+ "thyroid problem", "thyroid issue", "thyroid disorder",
726
+ ),
727
+ "asthma": (
728
+ "asthma", "asthmatic", "respiratory", "respiratory issue",
729
+ "respiratory problem",
730
+ ),
731
+ "heart": (
732
+ "heart problem", "heart disease", "heart issue", "heart condition",
733
+ "cardiac", "cardiac issue", "cardiac problem",
734
+ ),
735
+ "cancer": (
736
+ "cancer", "tumor", "tumour", "cancer history", "had cancer",
737
+ "cancer survivor",
738
+ ),
739
  }
740
  # "bp" as a free-standing token (case-insensitive word-boundary). Kept
741
  # separate from `_COND_KEYWORDS` so it doesn't collide with substrings
 
762
  if s in seen:
763
  continue
764
  seen.add(s)
765
+ # Skip explicit "no" markers if they leaked into a list shape.
766
+ if s in ("no", "none", "nothing", "nil", "negative"):
767
+ continue
768
  # Map keyword hits to canonical names
769
  mapped = None
770
  for canon, kws in _COND_KEYWORDS.items():
771
  if s == canon or any(k.strip() == s for k in kws):
772
  mapped = canon
773
  break
774
+ # KI-225 — also run the word-boundary BP regex so list items like
775
+ # ["BP"] / ["high BP"] map to "hypertension" (the kw tuple uses
776
+ # multi-word strings, none of which exact-match the bare token).
777
+ if mapped is None and _BP_REGEX.search(s):
778
+ mapped = "hypertension"
779
+ # Substring fallback for "type 2 diabetes" style values where the
780
+ # full string doesn't exact-match a kw but contains one.
781
+ if mapped is None:
782
+ for canon, kws in _COND_KEYWORDS.items():
783
+ if any(k in s for k in kws):
784
+ mapped = canon
785
+ break
786
  cleaned.append(mapped if mapped else s)
787
  # Dedup again after canonicalisation
788
  return list(dict.fromkeys(cleaned))
backend/session_state.py CHANGED
@@ -61,6 +61,12 @@ class SessionState:
61
  # "staged_at": <epoch-seconds>,
62
  # }
63
  pending_profile_recall: Optional[Dict[str, Any]] = None
 
 
 
 
 
 
64
 
65
  def _flush(self) -> None:
66
  """No-op since KI-118 (2026-05-15). Disk persistence was removed; the
 
61
  # "staged_at": <epoch-seconds>,
62
  # }
63
  pending_profile_recall: Optional[Dict[str, Any]] = None
64
+ # KI-224 — most-recent recommendation policy_ids the brain cited on the
65
+ # last user-visible recommendation/comparison turn. Populated by the
66
+ # orchestrator after a clean closer reply. Lets the NEXT turn route
67
+ # follow-ups like "tell me more about #2" without re-retrieving from
68
+ # scratch. Empty list = no active shortlist on this session.
69
+ last_recommendation_ids: list = field(default_factory=list)
70
 
71
  def _flush(self) -> None:
72
  """No-op since KI-118 (2026-05-15). Disk persistence was removed; the
backend/single_brain.py ADDED
@@ -0,0 +1,649 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single-brain conversation handler — Path B.
2
+
3
+ One Gemini Flash call per turn (with native function-calling) replaces
4
+ the previous sales_brain + qa_brain split. The LLM decides on each
5
+ iteration whether to:
6
+ - call `save_profile_field` to persist captured slots,
7
+ - call `retrieve_policies` to pull policy chunks from Chroma,
8
+ - call `mark_recommendation` to flag the policies just pitched,
9
+ - or emit a final text reply.
10
+
11
+ The loop iterates up to `MAX_ITERATIONS` (default 5) so the LLM can chain
12
+ multiple tool calls in a single user turn before responding. Beyond that
13
+ cap we synthesise a defensive reply and return.
14
+
15
+ Wire-up: /api/chat → main.py.chat() → if USE_SINGLE_BRAIN: single_brain.handle_turn(...)
16
+ On any SingleBrainError, the API caller is expected to fall through to the
17
+ legacy `orchestrator.handle_turn` so the user always gets a reply.
18
+
19
+ We call the Gemini REST API directly (httpx, like google_gemini_llm.py)
20
+ rather than using the `google.generativeai` SDK so we don't need to pin
21
+ an extra dependency. The function-calling DSL is well-documented at
22
+ https://ai.google.dev/api/generate-content#tools.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import json
29
+ import logging
30
+ import os
31
+ import time
32
+ from dataclasses import dataclass, field
33
+ from typing import Any, Optional
34
+
35
+ import httpx
36
+
37
+ from backend import brain_tools
38
+
39
+ _log = logging.getLogger(__name__)
40
+
41
+
42
+ # ---------- constants -------------------------------------------------------
43
+
44
+ # Model resolution: prefer `SINGLE_BRAIN_MODEL`, else copy the same default
45
+ # `google_gemini_llm.py` uses (DEFAULT_MODEL = "gemini-2.5-flash-lite"). We
46
+ # import lazily inside _resolve_model so importing this module does not
47
+ # require the provider to load (or its GOOGLE_API_KEY env var to be set).
48
+ _FALLBACK_MODEL = "gemini-2.5-flash-lite"
49
+
50
+ GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models"
51
+
52
+ # Per-call timeout (matches the legacy provider default of 25s).
53
+ PER_CALL_TIMEOUT_SEC = 25.0
54
+
55
+ # Max iterations of the tool-call loop. Prevents runaway tool-call cycles
56
+ # where the LLM keeps calling save_profile_field on the same value.
57
+ MAX_ITERATIONS = 5
58
+
59
+
60
+ SYSTEM_PROMPT = """You are an Indian health-insurance advisor speaking with a customer.
61
+
62
+ YOUR JOB:
63
+ 1. Have a natural conversation to learn the customer's profile.
64
+ 2. Once you have ALL required slots, call retrieve_policies, then recommend 2-4 options with policy citations.
65
+ 3. Help the customer choose one. Cite the UIN / policy_id for every claim about features, sums insured, or premiums.
66
+
67
+ REQUIRED before recommending: name, age, dependents, location_tier, income_band, primary_goal, health_conditions.
68
+
69
+ TOOL USE RULES:
70
+ - Call save_profile_field every time you learn a new field. Don't keep captures only in your head.
71
+ - If the user says multiple facts in one turn ("Hi I'm Rohit, 29, Mumbai, first policy"), call save_profile_field once per field in that turn.
72
+ - BEFORE the final recommendation, briefly summarise what you understood and ASK the user to confirm.
73
+ - After confirmation, call retrieve_policies, then respond with recommendations citing each policy's id / UIN.
74
+ - After the recommendation, the user may ask follow-ups. For "tell me about #2", call retrieve_policies(query, policy_filter_ids=[the policy_id]) to narrow.
75
+ - Call mark_recommendation with the policy_ids whenever you put a ranked shortlist in your reply.
76
+
77
+ GROUND RULES:
78
+ - NEVER invent policies, UINs, premiums, or sums insured. Only cite what retrieve_policies returns.
79
+ - If retrieve_policies returns zero chunks, do NOT fabricate a recommendation — ask the user a clarifying question instead.
80
+ - Be concise: 2-3 sentence turns. No emoji unless the user used one first.
81
+ - Indian context: use lakh / crore, ₹, IRDAI, Section 80D. Don't say "dollars" or "$".
82
+ - Returning users may have a pre-populated profile — greet them by name, summarise what you remember, and ask them to confirm or update before recommending.
83
+ """
84
+
85
+
86
+ # ---------- exceptions ------------------------------------------------------
87
+
88
+
89
+ class SingleBrainError(Exception):
90
+ """Wraps any unrecoverable Gemini / single-brain error so the api.py
91
+ caller can fall through to the legacy orchestrator handler."""
92
+
93
+
94
+ # ---------- TurnResult — mirrors orchestrator.TurnResult --------------------
95
+
96
+
97
+ @dataclass
98
+ class TurnResult:
99
+ """Same shape as `orchestrator.TurnResult`. Kept local so single_brain
100
+ does not import the orchestrator and trip a circular dependency."""
101
+
102
+ reply_text: str
103
+ citations: list[dict]
104
+ retrieved_chunk_ids: list[str]
105
+ brain_used: str
106
+ intent: str
107
+ language: str
108
+ latency_ms: int
109
+ raw_reply: str
110
+ faithfulness_passed: bool = True
111
+ faithfulness_reasons: list[str] = field(default_factory=list)
112
+ blocked: bool = False
113
+ profile_updates: dict = field(default_factory=dict)
114
+ followup_policy_id: Optional[str] = None
115
+
116
+
117
+ # ---------- function-calling DSL (Gemini JSON schema) -----------------------
118
+
119
+ # Gemini "tools" are FunctionDeclarations. The schema is JSON-Schema-flavoured
120
+ # (subset, see https://ai.google.dev/api/caching#Schema). Parameters MUST use
121
+ # "OBJECT"/"STRING"/"INTEGER"/"ARRAY" (uppercase) — Google does NOT accept the
122
+ # lowercase JSON Schema form here.
123
+
124
+ TOOL_SCHEMAS: list[dict] = [
125
+ {
126
+ "name": "save_profile_field",
127
+ "description": (
128
+ "Persist a captured profile field on the live session. Call once "
129
+ "per field every time the user reveals something new (name, age, "
130
+ "dependents, location_tier, income_band, primary_goal, "
131
+ "health_conditions, existing_cover_inr, budget_band, gender)."
132
+ ),
133
+ "parameters": {
134
+ "type": "OBJECT",
135
+ "properties": {
136
+ "field": {
137
+ "type": "STRING",
138
+ "description": (
139
+ "Field name. One of: name, age, dependents, "
140
+ "location_tier, income_band, primary_goal, "
141
+ "health_conditions, existing_cover_inr, budget_band, "
142
+ "gender."
143
+ ),
144
+ },
145
+ "value": {
146
+ "type": "STRING",
147
+ "description": (
148
+ "Value as a string. Numbers (age, existing_cover_inr) "
149
+ "may be sent as a digit string; health_conditions may "
150
+ "be a comma-joined string of conditions."
151
+ ),
152
+ },
153
+ },
154
+ "required": ["field", "value"],
155
+ },
156
+ },
157
+ {
158
+ "name": "retrieve_policies",
159
+ "description": (
160
+ "Search the indexed Indian health-insurance policy corpus and "
161
+ "return the top-k most relevant policy chunks. Use this BEFORE "
162
+ "recommending or quoting any policy fact."
163
+ ),
164
+ "parameters": {
165
+ "type": "OBJECT",
166
+ "properties": {
167
+ "query": {
168
+ "type": "STRING",
169
+ "description": (
170
+ "Natural-language search query, e.g. 'family floater "
171
+ "₹10L Mumbai metro tier1 diabetes'."
172
+ ),
173
+ },
174
+ "top_k": {
175
+ "type": "INTEGER",
176
+ "description": "Number of chunks to return. Default 8.",
177
+ },
178
+ "policy_filter_ids": {
179
+ "type": "ARRAY",
180
+ "items": {"type": "STRING"},
181
+ "description": (
182
+ "Optional list of policy_ids to restrict retrieval to "
183
+ "(use for 'tell me more about #2' style follow-ups)."
184
+ ),
185
+ },
186
+ },
187
+ "required": ["query"],
188
+ },
189
+ },
190
+ {
191
+ "name": "mark_recommendation",
192
+ "description": (
193
+ "Record the policies you have just recommended so future turns "
194
+ "can resolve follow-up references like 'tell me about #2'. Call "
195
+ "this on the SAME turn you produce the ranked shortlist."
196
+ ),
197
+ "parameters": {
198
+ "type": "OBJECT",
199
+ "properties": {
200
+ "policy_ids": {
201
+ "type": "ARRAY",
202
+ "items": {"type": "STRING"},
203
+ "description": "Ordered list of policy_ids in your reply.",
204
+ },
205
+ "is_final": {
206
+ "type": "BOOLEAN",
207
+ "description": (
208
+ "True when this is the final closer (user picked / "
209
+ "confirmed). Optional, defaults to false."
210
+ ),
211
+ },
212
+ },
213
+ "required": ["policy_ids"],
214
+ },
215
+ },
216
+ ]
217
+
218
+
219
+ # ---------- helpers ---------------------------------------------------------
220
+
221
+
222
+ def _resolve_model() -> str:
223
+ """Read the Gemini model id. Env override wins; otherwise mirror the
224
+ google_gemini_llm.py default. Import is lazy so module load does not
225
+ touch the provider (which itself fails noisily on missing env vars)."""
226
+ override = os.environ.get("SINGLE_BRAIN_MODEL", "").strip()
227
+ if override:
228
+ return override
229
+ try:
230
+ from backend.providers.google_gemini_llm import DEFAULT_MODEL as _DM
231
+
232
+ return _DM or _FALLBACK_MODEL
233
+ except Exception: # noqa: BLE001
234
+ return _FALLBACK_MODEL
235
+
236
+
237
+ def _profile_to_snapshot(profile) -> dict:
238
+ """Compact JSON-safe dict of all currently-known profile slots — for
239
+ the system prompt so the LLM doesn't keep re-asking the user for
240
+ fields it already has access to.
241
+ """
242
+ snap: dict[str, Any] = {}
243
+ for fld in (
244
+ "name", "age", "dependents", "location_tier", "income_band",
245
+ "primary_goal", "health_conditions", "existing_cover_inr",
246
+ "budget_band",
247
+ ):
248
+ try:
249
+ v = getattr(profile, fld, None)
250
+ except Exception:
251
+ v = None
252
+ if v not in (None, "", []):
253
+ snap[fld] = v
254
+ return snap
255
+
256
+
257
+ def _build_contents(
258
+ chat_history: Optional[list[dict]],
259
+ user_text: str,
260
+ ) -> list[dict]:
261
+ """Translate the orchestrator-style chat_history ({role, content})
262
+ plus the current user_text into Gemini's `contents` payload.
263
+
264
+ Gemini wants alternating user/model turns with `parts[].text`.
265
+ `assistant` → `model`; everything else → `user`.
266
+ """
267
+ out: list[dict] = []
268
+ for msg in chat_history or []:
269
+ role = (msg.get("role") or "user").lower()
270
+ content = (msg.get("content") or "").strip()
271
+ if not content:
272
+ continue
273
+ gem_role = "model" if role in ("assistant", "model", "bot") else "user"
274
+ out.append({"role": gem_role, "parts": [{"text": content}]})
275
+ out.append({"role": "user", "parts": [{"text": user_text}]})
276
+ return out
277
+
278
+
279
+ def _system_instruction(profile) -> dict:
280
+ """Bake the profile snapshot into the system prompt so each turn the
281
+ LLM knows what's already captured. Returned in Gemini's expected
282
+ `systemInstruction` shape."""
283
+ snapshot = _profile_to_snapshot(profile)
284
+ extra = ""
285
+ if snapshot:
286
+ extra = (
287
+ "\n\nKNOWN PROFILE (already captured this session; do NOT re-ask):\n"
288
+ + json.dumps(snapshot, ensure_ascii=False, sort_keys=True)
289
+ )
290
+ text = SYSTEM_PROMPT + extra
291
+ return {"parts": [{"text": text}]}
292
+
293
+
294
+ def _detect_language(user_text: str) -> str:
295
+ """Mirror orchestrator.detect_language at a coarse level so the
296
+ TurnResult.language field stays useful for logging. Devanagari /
297
+ Hinglish → 'indic', else 'en'."""
298
+ if not user_text:
299
+ return "en"
300
+ for ch in user_text:
301
+ # Devanagari range
302
+ if "ऀ" <= ch <= "ॿ":
303
+ return "indic"
304
+ return "en"
305
+
306
+
307
+ def _classify_intent(user_text: str, tool_calls_made: list[str]) -> str:
308
+ """Best-effort intent label for logging only. Single-brain doesn't
309
+ route on intent — but the legacy `TurnResult.intent` field is logged
310
+ by main.py and emitted to the frontend."""
311
+ if "retrieve_policies" in tool_calls_made and "mark_recommendation" in tool_calls_made:
312
+ return "recommendation"
313
+ if "retrieve_policies" in tool_calls_made:
314
+ return "qa"
315
+ if "save_profile_field" in tool_calls_made:
316
+ return "fact_find"
317
+ return "qa"
318
+
319
+
320
+ # ---------- Gemini round-trip ----------------------------------------------
321
+
322
+
323
+ async def _gemini_call(
324
+ api_key: str,
325
+ model: str,
326
+ system_instruction: dict,
327
+ contents: list[dict],
328
+ tools: list[dict],
329
+ timeout_sec: float,
330
+ ) -> dict:
331
+ """Single non-streaming Gemini generateContent call. Returns the raw
332
+ JSON payload. Raises SingleBrainError on any 4xx/5xx/transport error.
333
+ """
334
+ url = f"{GEMINI_BASE_URL}/{model}:generateContent?key={api_key}"
335
+ body: dict = {
336
+ "systemInstruction": system_instruction,
337
+ "contents": contents,
338
+ "tools": [{"functionDeclarations": tools}],
339
+ "toolConfig": {"functionCallingConfig": {"mode": "AUTO"}},
340
+ "generationConfig": {
341
+ "temperature": 0.4,
342
+ "maxOutputTokens": 1024,
343
+ },
344
+ }
345
+ headers = {"Content-Type": "application/json"}
346
+ client_timeout = httpx.Timeout(
347
+ connect=2.0,
348
+ read=max(2.0, timeout_sec - 2.0),
349
+ write=2.0,
350
+ pool=2.0,
351
+ )
352
+
353
+ async with httpx.AsyncClient(timeout=client_timeout) as client:
354
+ try:
355
+ resp = await client.post(url, headers=headers, json=body)
356
+ except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
357
+ raise
358
+ except httpx.TimeoutException as e:
359
+ raise SingleBrainError(
360
+ f"Gemini timeout after {timeout_sec:.1f}s (model={model})"
361
+ ) from e
362
+ except httpx.HTTPError as e:
363
+ raise SingleBrainError(
364
+ f"Gemini transport error ({type(e).__name__}): {str(e)[:200]}"
365
+ ) from e
366
+
367
+ if resp.status_code >= 400:
368
+ detail = ""
369
+ try:
370
+ detail = resp.text[:500]
371
+ except Exception:
372
+ pass
373
+ raise SingleBrainError(
374
+ f"Gemini HTTP {resp.status_code}: {detail}"
375
+ )
376
+
377
+ try:
378
+ return resp.json()
379
+ except Exception as e: # noqa: BLE001
380
+ raise SingleBrainError(f"Gemini malformed JSON: {e}") from e
381
+
382
+
383
+ def _extract_parts(payload: dict) -> list[dict]:
384
+ """Pull the `parts` list out of the first candidate. Empty list on
385
+ any missing-key path so the caller decides what to do."""
386
+ try:
387
+ candidates = payload.get("candidates") or []
388
+ if not candidates:
389
+ return []
390
+ content = candidates[0].get("content") or {}
391
+ parts = content.get("parts") or []
392
+ if isinstance(parts, list):
393
+ return parts
394
+ return []
395
+ except Exception: # noqa: BLE001
396
+ return []
397
+
398
+
399
+ def _parts_text(parts: list[dict]) -> str:
400
+ """Concatenate every text part. Empty string when none present."""
401
+ return "".join(
402
+ p.get("text", "")
403
+ for p in parts
404
+ if isinstance(p, dict) and "text" in p
405
+ )
406
+
407
+
408
+ def _parts_function_calls(parts: list[dict]) -> list[dict]:
409
+ """Pull every functionCall block out of parts. Each entry is
410
+ {"name": "...", "args": {...}}."""
411
+ out: list[dict] = []
412
+ for p in parts:
413
+ if not isinstance(p, dict):
414
+ continue
415
+ fc = p.get("functionCall")
416
+ if isinstance(fc, dict) and fc.get("name"):
417
+ out.append(
418
+ {
419
+ "name": fc.get("name"),
420
+ "args": fc.get("args") or {},
421
+ }
422
+ )
423
+ return out
424
+
425
+
426
+ async def _execute_tool(session, name: str, args: dict) -> dict:
427
+ """Dispatch a single function call to the matching brain_tools function.
428
+ Returns the JSON-serialisable response dict that gets fed back to Gemini
429
+ on the next turn."""
430
+ try:
431
+ if name == "save_profile_field":
432
+ return brain_tools.save_profile_field(
433
+ session,
434
+ field=args.get("field", ""),
435
+ value=args.get("value"),
436
+ )
437
+ if name == "retrieve_policies":
438
+ return await brain_tools.retrieve_policies(
439
+ query=args.get("query", ""),
440
+ top_k=int(args.get("top_k") or 8),
441
+ policy_filter_ids=args.get("policy_filter_ids") or None,
442
+ profile=getattr(session, "profile", None),
443
+ intent="recommendation",
444
+ )
445
+ if name == "mark_recommendation":
446
+ return brain_tools.mark_recommendation(
447
+ session,
448
+ policy_ids=args.get("policy_ids") or [],
449
+ is_final=bool(args.get("is_final") or False),
450
+ )
451
+ return {"ok": False, "error": f"unknown_tool:{name}"}
452
+ except Exception as e: # noqa: BLE001 — never crash the loop
453
+ _log.warning(
454
+ "tool=%s args=%r raised %s: %s",
455
+ name, args, type(e).__name__, str(e)[:200],
456
+ )
457
+ return {"ok": False, "error": f"{type(e).__name__}:{str(e)[:200]}"}
458
+
459
+
460
+ # ---------- main entrypoint ------------------------------------------------
461
+
462
+
463
+ async def handle_turn(
464
+ session,
465
+ user_text: str,
466
+ chat_history: Optional[list[dict]] = None,
467
+ ) -> TurnResult:
468
+ """Single-LLM turn handler — replaces orchestrator.handle_turn behaviour
469
+ when USE_SINGLE_BRAIN is enabled.
470
+
471
+ Returns a TurnResult whose shape matches orchestrator.TurnResult.
472
+ Raises SingleBrainError on unrecoverable Gemini failure so the api.py
473
+ caller falls through to the legacy orchestrator.
474
+ """
475
+ t0 = time.time()
476
+
477
+ api_key = os.environ.get("GOOGLE_API_KEY", "").strip()
478
+ if not api_key:
479
+ raise SingleBrainError("GOOGLE_API_KEY not set")
480
+
481
+ model = _resolve_model()
482
+ language = _detect_language(user_text)
483
+ system_instruction = _system_instruction(session.profile)
484
+
485
+ # The running `contents` list — we append model turns + function
486
+ # responses to it across loop iterations so Gemini sees the entire
487
+ # tool-call thread when emitting its final text.
488
+ contents = _build_contents(chat_history, user_text)
489
+
490
+ # Track each tool call we serve so we can populate citations + the
491
+ # `intent`/`brain_used` log fields at the end.
492
+ tool_calls_made: list[str] = []
493
+ retrieved_chunks_all: list[dict] = []
494
+ last_marked_policy_ids: list[str] = []
495
+ profile_updates: dict[str, Any] = {}
496
+
497
+ # Defensive counter to break runaway loops.
498
+ last_text: str = ""
499
+ last_payload: dict = {}
500
+
501
+ for it in range(MAX_ITERATIONS):
502
+ try:
503
+ payload = await _gemini_call(
504
+ api_key=api_key,
505
+ model=model,
506
+ system_instruction=system_instruction,
507
+ contents=contents,
508
+ tools=TOOL_SCHEMAS,
509
+ timeout_sec=PER_CALL_TIMEOUT_SEC,
510
+ )
511
+ except SingleBrainError:
512
+ raise
513
+ except Exception as e: # noqa: BLE001 — defensive
514
+ raise SingleBrainError(
515
+ f"gemini_call unexpected error: {type(e).__name__}: {e}"
516
+ ) from e
517
+
518
+ last_payload = payload
519
+ parts = _extract_parts(payload)
520
+ function_calls = _parts_function_calls(parts)
521
+ text = _parts_text(parts).strip()
522
+
523
+ # CASE A — no function calls: this is the final text reply.
524
+ # Includes the "Gemini just chats on turn 1" path the spec
525
+ # called out — completely valid, return immediately.
526
+ if not function_calls:
527
+ last_text = text
528
+ break
529
+
530
+ # CASE B — one or more function calls. Append the model turn
531
+ # verbatim so Gemini sees its own previous tool-call request,
532
+ # then execute every call and append a single user turn with
533
+ # the matching functionResponse parts.
534
+ contents.append(
535
+ {
536
+ "role": "model",
537
+ "parts": parts,
538
+ }
539
+ )
540
+
541
+ response_parts: list[dict] = []
542
+ for fc in function_calls:
543
+ name = fc["name"]
544
+ args = fc.get("args") or {}
545
+ tool_calls_made.append(name)
546
+ result = await _execute_tool(session, name, args)
547
+
548
+ # Bookkeeping for the TurnResult fields.
549
+ if name == "save_profile_field" and result.get("saved"):
550
+ fld = result.get("field")
551
+ if fld:
552
+ profile_updates[fld] = result.get("value")
553
+ elif name == "retrieve_policies":
554
+ for c in result.get("chunks") or []:
555
+ retrieved_chunks_all.append(c)
556
+ elif name == "mark_recommendation" and result.get("recorded"):
557
+ last_marked_policy_ids = list(result.get("policy_ids") or [])
558
+
559
+ response_parts.append(
560
+ {
561
+ "functionResponse": {
562
+ "name": name,
563
+ "response": {"content": result},
564
+ }
565
+ }
566
+ )
567
+
568
+ contents.append({"role": "user", "parts": response_parts})
569
+ # And loop — Gemini gets another shot to either call more
570
+ # tools or emit a final text reply.
571
+ last_text = text # in case loop hits MAX_ITERATIONS with no text
572
+ else:
573
+ # Hit MAX_ITERATIONS without break — synthesise a defensive reply.
574
+ _log.warning(
575
+ "single_brain hit MAX_ITERATIONS=%d (tool_calls=%s)",
576
+ MAX_ITERATIONS, tool_calls_made,
577
+ )
578
+ last_text = (
579
+ last_text
580
+ or "Let me pause for a second — could you tell me a bit more about "
581
+ "what you're looking for, so I can give you a clean recommendation?"
582
+ )
583
+
584
+ # Build TurnResult.
585
+ reply_text = last_text or (
586
+ "Sorry — I lost my train of thought there. Could you say that again?"
587
+ )
588
+
589
+ # Citations: deduped by chunk_id; same shape orchestrator emits.
590
+ seen_ids: set[str] = set()
591
+ citations: list[dict] = []
592
+ retrieved_chunk_ids: list[str] = []
593
+ for c in retrieved_chunks_all:
594
+ cid = c.get("chunk_id") or ""
595
+ if not cid or cid in seen_ids:
596
+ continue
597
+ seen_ids.add(cid)
598
+ retrieved_chunk_ids.append(cid)
599
+ citations.append(
600
+ {
601
+ "chunk_id": cid,
602
+ "policy_id": c.get("policy_id", ""),
603
+ "policy_name": c.get("policy_name", ""),
604
+ "insurer_slug": c.get("insurer_slug", ""),
605
+ "doc_type": c.get("doc_type", ""),
606
+ "source_url": c.get("source_url", ""),
607
+ "score": c.get("score", 0.0),
608
+ }
609
+ )
610
+
611
+ intent = _classify_intent(user_text, tool_calls_made)
612
+ brain_used = f"single_brain::{model}"
613
+ if tool_calls_made:
614
+ brain_used += f"::tools={'+'.join(sorted(set(tool_calls_made)))}"
615
+
616
+ # follow-up policy id: if the LLM marked exactly one policy this turn,
617
+ # surface it so the frontend can highlight the matching card.
618
+ followup_policy_id = (
619
+ last_marked_policy_ids[0]
620
+ if len(last_marked_policy_ids) == 1
621
+ else None
622
+ )
623
+
624
+ return TurnResult(
625
+ reply_text=reply_text,
626
+ citations=citations,
627
+ retrieved_chunk_ids=retrieved_chunk_ids,
628
+ brain_used=brain_used,
629
+ intent=intent,
630
+ language=language,
631
+ latency_ms=int((time.time() - t0) * 1000),
632
+ raw_reply=json.dumps(last_payload)[:4000] if last_payload else reply_text,
633
+ faithfulness_passed=True,
634
+ faithfulness_reasons=[],
635
+ blocked=False,
636
+ profile_updates=profile_updates,
637
+ followup_policy_id=followup_policy_id,
638
+ )
639
+
640
+
641
+ __all__ = [
642
+ "SingleBrainError",
643
+ "TurnResult",
644
+ "handle_turn",
645
+ "SYSTEM_PROMPT",
646
+ "TOOL_SCHEMAS",
647
+ "MAX_ITERATIONS",
648
+ "PER_CALL_TIMEOUT_SEC",
649
+ ]
frontend/public/admin/llm-control.html CHANGED
@@ -575,6 +575,98 @@
575
  .profile-table td.num-cell { text-align: right; font-variant-numeric: tabular-nums; }
576
  .profile-table td.time-cell { white-space: nowrap; font-size: 12px; }
577
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
578
  /* Field completeness bar */
579
  .field-bar {
580
  display: inline-flex;
@@ -648,7 +740,36 @@
648
  <div id="profiles-body">
649
  <div class="empty-state">Loading…</div>
650
  </div>
 
651
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  </section>
653
 
654
  <!-- Tab 2: Performance -->
@@ -663,6 +784,7 @@
663
  <div id="performance-body">
664
  <div class="empty-state">Loading…</div>
665
  </div>
 
666
  </div>
667
  </section>
668
 
@@ -673,9 +795,10 @@
673
  may live inside this section: the h2 header (with snapshot timestamp
674
  span), the refresh button, and the chains container that holds the
675
  two generated tables (Currently In Use + All Eligible Models). -->
676
- <h2>LLM health <span class="small muted" id="llm-health-snapshot-ts"></span></h2>
677
  <button id="btn-refresh-llm-health">Refresh</button>
678
  <div id="llm-health-chains" class="llm-simple-tables"></div>
 
679
  </section>
680
  </div>
681
 
@@ -697,15 +820,26 @@
697
  // Shape: { chains: [...], candidates: [...], recent_turns: [...], snapshot_ts }
698
  llmHealth: null,
699
  llmHealthPollTimer: null,
 
700
  lastUpdatedAt: null,
701
  chainLoaded: false,
702
  // Profiles tab
703
  profiles: null,
704
  profilesLoaded: false,
 
705
  profilesSort: { key: 'last_seen', dir: 'desc' },
 
 
 
 
 
 
 
 
706
  // Performance tab
707
  performance: null,
708
  performanceLoaded: false,
 
709
  // Active tab
710
  activeTab: 'profiles'
711
  };
@@ -1340,6 +1474,7 @@
1340
  function fetchLlmHealth() {
1341
  return apiGetTolerant('/api/admin/llm-health').then(function (data) {
1342
  STATE.llmHealth = data;
 
1343
  }).catch(function (err) {
1344
  if (err && err.notDeployed) {
1345
  STATE.llmHealth = { __notDeployed: true };
@@ -1904,6 +2039,7 @@
1904
  function fetchProfiles() {
1905
  return apiGetTolerant('/api/admin/profiles').then(function (data) {
1906
  STATE.profiles = data;
 
1907
  });
1908
  }
1909
 
@@ -2076,6 +2212,7 @@
2076
  function fetchPerformance() {
2077
  return apiGetTolerant('/api/admin/performance').then(function (data) {
2078
  STATE.performance = data;
 
2079
  });
2080
  }
2081
 
@@ -2382,6 +2519,262 @@
2382
  return wrap;
2383
  }
2384
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2385
  // ----- Tab switching -----
2386
  function switchTab(tab) {
2387
  STATE.activeTab = tab;
@@ -2402,6 +2795,10 @@
2402
  // Lazy-load this tab's data
2403
  if (tab === 'profiles') {
2404
  loadProfiles(false);
 
 
 
 
2405
  stopLlmHealthPolling();
2406
  } else if (tab === 'performance') {
2407
  loadPerformance(false);
@@ -2485,10 +2882,20 @@
2485
  STATE.chains = null;
2486
  STATE.usage = null;
2487
  STATE.llmHealth = null;
 
2488
  STATE.profiles = null;
2489
  STATE.profilesLoaded = false;
 
2490
  STATE.performance = null;
2491
  STATE.performanceLoaded = false;
 
 
 
 
 
 
 
 
2492
  STATE.chainLoaded = false;
2493
  stopLlmHealthPolling();
2494
  try { localStorage.removeItem(STORAGE_KEY); } catch (_) {}
@@ -2512,6 +2919,37 @@
2512
  .then(function () { btn.disabled = false; btn.textContent = 'Refresh'; });
2513
  };
2514
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2515
  // Performance refresh
2516
  $('btn-refresh-performance').onclick = function () {
2517
  var btn = this;
 
575
  .profile-table td.num-cell { text-align: right; font-variant-numeric: tabular-nums; }
576
  .profile-table td.time-cell { white-space: nowrap; font-size: 12px; }
577
 
578
+ /* A5 — stale-data indicator + table footer */
579
+ .table-footer {
580
+ margin-top: 6px;
581
+ font-size: 11px;
582
+ color: var(--muted);
583
+ display: flex;
584
+ justify-content: flex-end;
585
+ gap: 6px;
586
+ font-variant-numeric: tabular-nums;
587
+ }
588
+ .stale-badge {
589
+ display: inline-block;
590
+ padding: 1px 7px;
591
+ border-radius: 4px;
592
+ font-size: 10px;
593
+ font-weight: 700;
594
+ letter-spacing: 0.04em;
595
+ vertical-align: middle;
596
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, monospace;
597
+ background: rgba(210, 153, 34, 0.18);
598
+ color: var(--yellow);
599
+ border: 1px solid rgba(210, 153, 34, 0.40);
600
+ margin-left: 6px;
601
+ }
602
+ .stale-badge.fresh {
603
+ background: rgba(63, 185, 80, 0.12);
604
+ color: var(--green);
605
+ border-color: rgba(63, 185, 80, 0.35);
606
+ }
607
+ .stale-badge.down {
608
+ background: rgba(248, 81, 73, 0.18);
609
+ color: var(--red);
610
+ border-color: rgba(248, 81, 73, 0.40);
611
+ }
612
+
613
+ /* A5 — Persona drift panel + recommendation history panel */
614
+ .collapsible {
615
+ border: 1px solid var(--border);
616
+ border-radius: 8px;
617
+ margin-top: 16px;
618
+ background: var(--card);
619
+ }
620
+ .collapsible > summary {
621
+ list-style: none;
622
+ cursor: pointer;
623
+ padding: 12px 16px;
624
+ font-size: 14px;
625
+ font-weight: 600;
626
+ display: flex;
627
+ align-items: center;
628
+ justify-content: space-between;
629
+ gap: 10px;
630
+ user-select: none;
631
+ }
632
+ .collapsible > summary::-webkit-details-marker { display: none; }
633
+ .collapsible > summary::before {
634
+ content: '▸';
635
+ color: var(--muted);
636
+ transition: transform 0.12s ease;
637
+ font-size: 12px;
638
+ margin-right: 8px;
639
+ }
640
+ .collapsible[open] > summary::before { transform: rotate(90deg); }
641
+ .collapsible .panel-body {
642
+ padding: 0 16px 16px;
643
+ }
644
+ .drift-row-bad td { background: rgba(248, 81, 73, 0.08); }
645
+ .drift-pct {
646
+ display: inline-block;
647
+ min-width: 48px;
648
+ text-align: right;
649
+ font-variant-numeric: tabular-nums;
650
+ font-weight: 600;
651
+ }
652
+ .drift-pct.bad { color: var(--red); }
653
+ .drift-pct.warn { color: var(--yellow); }
654
+ .drift-pct.ok { color: var(--green); }
655
+ .outcome-pill {
656
+ display: inline-block;
657
+ padding: 2px 8px;
658
+ border-radius: 4px;
659
+ font-size: 10px;
660
+ font-weight: 700;
661
+ letter-spacing: 0.04em;
662
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, monospace;
663
+ }
664
+ .outcome-pill.selected { background: rgba(63, 185, 80, 0.15); color: var(--green); border: 1px solid rgba(63, 185, 80, 0.35); }
665
+ .outcome-pill.rejected { background: rgba(248, 81, 73, 0.15); color: var(--red); border: 1px solid rgba(248, 81, 73, 0.35); }
666
+ .outcome-pill.shown { background: rgba(88, 166, 255, 0.15); color: var(--blue); border: 1px solid rgba(88, 166, 255, 0.35); }
667
+ .outcome-pill.abandoned{ background: rgba(139, 149, 164, 0.15); color: var(--muted); border: 1px solid rgba(139, 149, 164, 0.35); }
668
+ .outcome-pill.error { background: rgba(210, 153, 34, 0.15); color: var(--yellow); border: 1px solid rgba(210, 153, 34, 0.35); }
669
+
670
  /* Field completeness bar */
671
  .field-bar {
672
  display: inline-flex;
 
740
  <div id="profiles-body">
741
  <div class="empty-state">Loading…</div>
742
  </div>
743
+ <div id="profiles-footer" class="table-footer"></div>
744
  </div>
745
+
746
+ <!-- A5 fix #4: Persona Drift panel (collapsible) -->
747
+ <details class="collapsible" id="drift-panel">
748
+ <summary>
749
+ <span>Persona Drift <span class="muted small" id="drift-summary" style="margin-left:8px;"></span></span>
750
+ <button id="btn-refresh-drift" type="button">Refresh</button>
751
+ </summary>
752
+ <div class="panel-body">
753
+ <div id="drift-body">
754
+ <div class="empty-state">Loading…</div>
755
+ </div>
756
+ <div id="drift-footer" class="table-footer"></div>
757
+ </div>
758
+ </details>
759
+
760
+ <!-- A5 fix #5: Recommendation History panel (collapsible) -->
761
+ <details class="collapsible" id="rec-panel">
762
+ <summary>
763
+ <span>Recommendation History <span class="muted small" id="rec-summary" style="margin-left:8px;"></span></span>
764
+ <button id="btn-refresh-rec" type="button">Refresh</button>
765
+ </summary>
766
+ <div class="panel-body">
767
+ <div id="rec-body">
768
+ <div class="empty-state">Loading…</div>
769
+ </div>
770
+ <div id="rec-footer" class="table-footer"></div>
771
+ </div>
772
+ </details>
773
  </section>
774
 
775
  <!-- Tab 2: Performance -->
 
784
  <div id="performance-body">
785
  <div class="empty-state">Loading…</div>
786
  </div>
787
+ <div id="performance-footer" class="table-footer"></div>
788
  </div>
789
  </section>
790
 
 
795
  may live inside this section: the h2 header (with snapshot timestamp
796
  span), the refresh button, and the chains container that holds the
797
  two generated tables (Currently In Use + All Eligible Models). -->
798
+ <h2>LLM health <span class="small muted" id="llm-health-snapshot-ts"></span> <span id="llm-health-stale-badge"></span></h2>
799
  <button id="btn-refresh-llm-health">Refresh</button>
800
  <div id="llm-health-chains" class="llm-simple-tables"></div>
801
+ <div id="llm-health-footer" class="table-footer"></div>
802
  </section>
803
  </div>
804
 
 
820
  // Shape: { chains: [...], candidates: [...], recent_turns: [...], snapshot_ts }
821
  llmHealth: null,
822
  llmHealthPollTimer: null,
823
+ llmHealthLoadedAt: null,
824
  lastUpdatedAt: null,
825
  chainLoaded: false,
826
  // Profiles tab
827
  profiles: null,
828
  profilesLoaded: false,
829
+ profilesLoadedAt: null,
830
  profilesSort: { key: 'last_seen', dir: 'desc' },
831
+ // A5 — Persona Drift panel
832
+ drift: null,
833
+ driftLoaded: false,
834
+ driftLoadedAt: null,
835
+ // A5 — Recommendation History panel
836
+ recHistory: null,
837
+ recHistoryLoaded: false,
838
+ recHistoryLoadedAt: null,
839
  // Performance tab
840
  performance: null,
841
  performanceLoaded: false,
842
+ performanceLoadedAt: null,
843
  // Active tab
844
  activeTab: 'profiles'
845
  };
 
1474
  function fetchLlmHealth() {
1475
  return apiGetTolerant('/api/admin/llm-health').then(function (data) {
1476
  STATE.llmHealth = data;
1477
+ STATE.llmHealthLoadedAt = Date.now();
1478
  }).catch(function (err) {
1479
  if (err && err.notDeployed) {
1480
  STATE.llmHealth = { __notDeployed: true };
 
2039
  function fetchProfiles() {
2040
  return apiGetTolerant('/api/admin/profiles').then(function (data) {
2041
  STATE.profiles = data;
2042
+ STATE.profilesLoadedAt = Date.now();
2043
  });
2044
  }
2045
 
 
2212
  function fetchPerformance() {
2213
  return apiGetTolerant('/api/admin/performance').then(function (data) {
2214
  STATE.performance = data;
2215
+ STATE.performanceLoadedAt = Date.now();
2216
  });
2217
  }
2218
 
 
2519
  return wrap;
2520
  }
2521
 
2522
+ // ----- A5 — Persona Drift panel -----
2523
+ function fetchDrift() {
2524
+ return apiGetTolerant('/api/admin/persona-drift').then(function (data) {
2525
+ STATE.drift = data;
2526
+ STATE.driftLoadedAt = Date.now();
2527
+ });
2528
+ }
2529
+
2530
+ function renderDrift() {
2531
+ var body = $('drift-body');
2532
+ var summary = $('drift-summary');
2533
+ clearChildren(body);
2534
+ if (summary) summary.textContent = '';
2535
+ if (!STATE.drift) {
2536
+ body.appendChild(createEl('div', { className: 'empty-state', text: 'Loading…' }));
2537
+ return;
2538
+ }
2539
+ if (STATE.drift.__notDeployed) {
2540
+ body.appendChild(buildPendingMessage('/api/admin/persona-drift'));
2541
+ return;
2542
+ }
2543
+ var personas = STATE.drift.personas || [];
2544
+ if (summary) {
2545
+ summary.textContent = personas.length + ' persona' + (personas.length === 1 ? '' : 's')
2546
+ + ' · ' + ((STATE.drift.slots || []).length || 6) + ' slots';
2547
+ }
2548
+ if (!personas.length) {
2549
+ body.appendChild(createEl('div', { className: 'empty-state',
2550
+ text: 'No personas captured yet.' }));
2551
+ return;
2552
+ }
2553
+ var table = createEl('table');
2554
+ var thead = createEl('thead');
2555
+ var hr = createEl('tr');
2556
+ ['Persona', 'Last seen', 'Captured', 'Missing', 'Completeness'].forEach(function (h, i) {
2557
+ var th = createEl('th', { text: h });
2558
+ if (i >= 4) th.style.textAlign = 'right';
2559
+ hr.appendChild(th);
2560
+ });
2561
+ thead.appendChild(hr);
2562
+ table.appendChild(thead);
2563
+ var tbody = createEl('tbody');
2564
+ personas.forEach(function (r) {
2565
+ var tr = createEl('tr');
2566
+ if (r.completeness_pct < 50) tr.classList.add('drift-row-bad');
2567
+ var nameCell = createEl('td');
2568
+ var nameSpan = createEl('span', { text: r.name_display || '—' });
2569
+ nameCell.appendChild(nameSpan);
2570
+ var idSpan = createEl('span', { className: 'small muted',
2571
+ text: ' ' + (r.persona_id || '') });
2572
+ idSpan.style.fontFamily = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, monospace';
2573
+ idSpan.style.marginLeft = '8px';
2574
+ nameCell.appendChild(idSpan);
2575
+ tr.appendChild(nameCell);
2576
+ tr.appendChild(createEl('td', { className: 'time-cell', text: fmtIST(r.last_seen) }));
2577
+ tr.appendChild(createEl('td', { className: 'mono small',
2578
+ text: (r.captured_slots || []).join(', ') || '—' }));
2579
+ var missCell = createEl('td', { className: 'mono small',
2580
+ text: (r.missing_slots || []).join(', ') || '—' });
2581
+ if ((r.missing_slots || []).length) missCell.style.color = 'var(--yellow)';
2582
+ tr.appendChild(missCell);
2583
+ var pctCell = createEl('td');
2584
+ pctCell.style.textAlign = 'right';
2585
+ var pctSpan = createEl('span', { className: 'drift-pct' });
2586
+ pctSpan.textContent = (r.completeness_pct != null ? r.completeness_pct.toFixed(0) : '—') + '%';
2587
+ if (r.completeness_pct < 50) pctSpan.classList.add('bad');
2588
+ else if (r.completeness_pct < 80) pctSpan.classList.add('warn');
2589
+ else pctSpan.classList.add('ok');
2590
+ pctCell.appendChild(pctSpan);
2591
+ tr.appendChild(pctCell);
2592
+ tbody.appendChild(tr);
2593
+ });
2594
+ table.appendChild(tbody);
2595
+ body.appendChild(table);
2596
+ }
2597
+
2598
+ function loadDrift(force) {
2599
+ if (STATE.driftLoaded && !force) {
2600
+ renderDrift();
2601
+ return Promise.resolve();
2602
+ }
2603
+ return fetchDrift().then(function () {
2604
+ STATE.driftLoaded = true;
2605
+ renderDrift();
2606
+ }).catch(function (err) {
2607
+ if (err && err.notDeployed) {
2608
+ STATE.drift = { __notDeployed: true };
2609
+ renderDrift();
2610
+ } else {
2611
+ handleFetchErr(err);
2612
+ }
2613
+ });
2614
+ }
2615
+
2616
+ // ----- A5 — Recommendation History panel -----
2617
+ function fetchRecHistory() {
2618
+ return apiGetTolerant('/api/admin/recommendation-history').then(function (data) {
2619
+ STATE.recHistory = data;
2620
+ STATE.recHistoryLoadedAt = Date.now();
2621
+ });
2622
+ }
2623
+
2624
+ function renderRecHistory() {
2625
+ var body = $('rec-body');
2626
+ var summary = $('rec-summary');
2627
+ clearChildren(body);
2628
+ if (summary) summary.textContent = '';
2629
+ if (!STATE.recHistory) {
2630
+ body.appendChild(createEl('div', { className: 'empty-state', text: 'Loading…' }));
2631
+ return;
2632
+ }
2633
+ if (STATE.recHistory.__notDeployed) {
2634
+ body.appendChild(buildPendingMessage('/api/admin/recommendation-history'));
2635
+ return;
2636
+ }
2637
+ var events = STATE.recHistory.events || [];
2638
+ if (summary) {
2639
+ summary.textContent = events.length + ' event' + (events.length === 1 ? '' : 's');
2640
+ }
2641
+ if (!events.length) {
2642
+ body.appendChild(createEl('div', { className: 'empty-state',
2643
+ text: 'No policy events logged yet.' }));
2644
+ return;
2645
+ }
2646
+ var table = createEl('table');
2647
+ var thead = createEl('thead');
2648
+ var hr = createEl('tr');
2649
+ ['When', 'Persona', 'Policy', 'Insurer', 'Outcome', 'Session', 'Turn'].forEach(function (h, i) {
2650
+ var th = createEl('th', { text: h });
2651
+ if (i === 4 || i === 6) th.style.textAlign = 'center';
2652
+ hr.appendChild(th);
2653
+ });
2654
+ thead.appendChild(hr);
2655
+ table.appendChild(thead);
2656
+ var tbody = createEl('tbody');
2657
+ events.forEach(function (e) {
2658
+ var tr = createEl('tr');
2659
+ tr.appendChild(createEl('td', { className: 'time-cell', text: fmtIST(e.event_at) }));
2660
+ var pCell = createEl('td');
2661
+ pCell.appendChild(createEl('span', { text: e.name_display || '—' }));
2662
+ var idSpan = createEl('span', { className: 'small muted',
2663
+ text: ' ' + (e.persona_id || '') });
2664
+ idSpan.style.fontFamily = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, monospace';
2665
+ idSpan.style.marginLeft = '8px';
2666
+ pCell.appendChild(idSpan);
2667
+ tr.appendChild(pCell);
2668
+ tr.appendChild(createEl('td', { className: 'mono small', text: e.policy_slug || '—' }));
2669
+ tr.appendChild(createEl('td', { text: e.insurer || '—' }));
2670
+ var oCell = createEl('td');
2671
+ oCell.style.textAlign = 'center';
2672
+ var outcome = (e.outcome || e.event_type || 'shown').toLowerCase();
2673
+ var pill = createEl('span', { className: 'outcome-pill ' + outcome, text: outcome.toUpperCase() });
2674
+ oCell.appendChild(pill);
2675
+ tr.appendChild(oCell);
2676
+ var sCell = createEl('td', { className: 'mono small',
2677
+ text: e.session_id ? String(e.session_id).slice(0, 10) + '…' : '—' });
2678
+ if (e.session_id) sCell.title = e.session_id;
2679
+ tr.appendChild(sCell);
2680
+ // Conversation turn — not stamped on the event today; show '—'
2681
+ // until the orchestrator records turn_idx into the policy event.
2682
+ var tCell = createEl('td', { text: e.conversation_turn != null ? String(e.conversation_turn) : '—' });
2683
+ tCell.style.textAlign = 'center';
2684
+ tCell.classList.add('muted');
2685
+ tr.appendChild(tCell);
2686
+ tbody.appendChild(tr);
2687
+ });
2688
+ table.appendChild(tbody);
2689
+ body.appendChild(table);
2690
+ }
2691
+
2692
+ function loadRecHistory(force) {
2693
+ if (STATE.recHistoryLoaded && !force) {
2694
+ renderRecHistory();
2695
+ return Promise.resolve();
2696
+ }
2697
+ return fetchRecHistory().then(function () {
2698
+ STATE.recHistoryLoaded = true;
2699
+ renderRecHistory();
2700
+ }).catch(function (err) {
2701
+ if (err && err.notDeployed) {
2702
+ STATE.recHistory = { __notDeployed: true };
2703
+ renderRecHistory();
2704
+ } else {
2705
+ handleFetchErr(err);
2706
+ }
2707
+ });
2708
+ }
2709
+
2710
+ // ----- A5 — Stale-data footer (every table) -----
2711
+ // 90s threshold: probe age > 90s ⇒ "stale" (yellow). Threshold matches
2712
+ // the audit's "freshness" definition. Updated live via setInterval.
2713
+ var STALE_THRESHOLD_MS = 90 * 1000;
2714
+
2715
+ function fmtAgo(ms) {
2716
+ if (ms == null || isNaN(ms) || ms < 0) return '—';
2717
+ var s = Math.floor(ms / 1000);
2718
+ if (s < 60) return s + 's ago';
2719
+ var m = Math.floor(s / 60);
2720
+ if (m < 60) return m + 'm ' + (s % 60) + 's ago';
2721
+ var h = Math.floor(m / 60);
2722
+ return h + 'h ' + (m % 60) + 'm ago';
2723
+ }
2724
+
2725
+ function setFooter(elId, fetchedAtMs, opts) {
2726
+ var el = $(elId);
2727
+ if (!el) return;
2728
+ clearChildren(el);
2729
+ if (!fetchedAtMs) return;
2730
+ var ageMs = Date.now() - fetchedAtMs;
2731
+ var label = createEl('span', { text: 'Last updated: ' + fmtAgo(ageMs) });
2732
+ el.appendChild(label);
2733
+ var badge = createEl('span');
2734
+ var stale = ageMs > STALE_THRESHOLD_MS;
2735
+ badge.className = 'stale-badge ' + (stale ? '' : 'fresh');
2736
+ badge.textContent = stale ? 'STALE' : 'FRESH';
2737
+ el.appendChild(badge);
2738
+ if (opts && opts.extraText) {
2739
+ var ex = createEl('span', { text: ' · ' + opts.extraText });
2740
+ ex.classList.add('muted');
2741
+ el.appendChild(ex);
2742
+ }
2743
+ }
2744
+
2745
+ // Per-tab footers ticked every second. Cheap — just DOM text updates.
2746
+ function tickFooters() {
2747
+ setFooter('profiles-footer', STATE.profilesLoadedAt);
2748
+ setFooter('drift-footer', STATE.driftLoadedAt);
2749
+ setFooter('rec-footer', STATE.recHistoryLoadedAt);
2750
+ setFooter('performance-footer', STATE.performanceLoadedAt);
2751
+ // LLM health uses probe timestamp from the snapshot — falls back to
2752
+ // the local fetch time when the payload doesn't carry snapshot_ts.
2753
+ var probeAt = null;
2754
+ if (STATE.llmHealth && STATE.llmHealth.snapshot_ts) {
2755
+ var t = Date.parse(STATE.llmHealth.snapshot_ts);
2756
+ if (!isNaN(t)) probeAt = t;
2757
+ }
2758
+ if (!probeAt) probeAt = STATE.llmHealthLoadedAt;
2759
+ setFooter('llm-health-footer', probeAt);
2760
+ // LLM-health stale badge sits next to the section header — render
2761
+ // separately so it's visible even when the body is empty.
2762
+ var badgeHost = $('llm-health-stale-badge');
2763
+ if (badgeHost) {
2764
+ clearChildren(badgeHost);
2765
+ if (probeAt) {
2766
+ var ageMs = Date.now() - probeAt;
2767
+ var stale = ageMs > STALE_THRESHOLD_MS;
2768
+ var badge = createEl('span', {
2769
+ className: 'stale-badge ' + (stale ? '' : 'fresh'),
2770
+ text: stale ? 'STALE' : 'FRESH'
2771
+ });
2772
+ badgeHost.appendChild(badge);
2773
+ }
2774
+ }
2775
+ }
2776
+ setInterval(tickFooters, 1000);
2777
+
2778
  // ----- Tab switching -----
2779
  function switchTab(tab) {
2780
  STATE.activeTab = tab;
 
2795
  // Lazy-load this tab's data
2796
  if (tab === 'profiles') {
2797
  loadProfiles(false);
2798
+ // A5 — load drift + rec history alongside the visitors table.
2799
+ // Cheap; both endpoints walk the same profiles dir.
2800
+ loadDrift(false);
2801
+ loadRecHistory(false);
2802
  stopLlmHealthPolling();
2803
  } else if (tab === 'performance') {
2804
  loadPerformance(false);
 
2882
  STATE.chains = null;
2883
  STATE.usage = null;
2884
  STATE.llmHealth = null;
2885
+ STATE.llmHealthLoadedAt = null;
2886
  STATE.profiles = null;
2887
  STATE.profilesLoaded = false;
2888
+ STATE.profilesLoadedAt = null;
2889
  STATE.performance = null;
2890
  STATE.performanceLoaded = false;
2891
+ STATE.performanceLoadedAt = null;
2892
+ // A5 — clear drift + rec history state on lock
2893
+ STATE.drift = null;
2894
+ STATE.driftLoaded = false;
2895
+ STATE.driftLoadedAt = null;
2896
+ STATE.recHistory = null;
2897
+ STATE.recHistoryLoaded = false;
2898
+ STATE.recHistoryLoadedAt = null;
2899
  STATE.chainLoaded = false;
2900
  stopLlmHealthPolling();
2901
  try { localStorage.removeItem(STORAGE_KEY); } catch (_) {}
 
2919
  .then(function () { btn.disabled = false; btn.textContent = 'Refresh'; });
2920
  };
2921
 
2922
+ // A5 — Persona Drift refresh (stop bubbling so <summary> doesn't
2923
+ // collapse the panel when the button is clicked).
2924
+ var driftBtn = $('btn-refresh-drift');
2925
+ if (driftBtn) {
2926
+ driftBtn.onclick = function (e) {
2927
+ e.stopPropagation();
2928
+ e.preventDefault();
2929
+ var btn = this;
2930
+ btn.disabled = true;
2931
+ btn.textContent = 'Refreshing…';
2932
+ loadDrift(true).then(function () { toast('Persona drift refreshed', 'success'); })
2933
+ .catch(handleFetchErr)
2934
+ .then(function () { btn.disabled = false; btn.textContent = 'Refresh'; });
2935
+ };
2936
+ }
2937
+
2938
+ // A5 — Recommendation history refresh
2939
+ var recBtn = $('btn-refresh-rec');
2940
+ if (recBtn) {
2941
+ recBtn.onclick = function (e) {
2942
+ e.stopPropagation();
2943
+ e.preventDefault();
2944
+ var btn = this;
2945
+ btn.disabled = true;
2946
+ btn.textContent = 'Refreshing…';
2947
+ loadRecHistory(true).then(function () { toast('Recommendation history refreshed', 'success'); })
2948
+ .catch(handleFetchErr)
2949
+ .then(function () { btn.disabled = false; btn.textContent = 'Refresh'; });
2950
+ };
2951
+ }
2952
+
2953
  // Performance refresh
2954
  $('btn-refresh-performance').onclick = function () {
2955
  var btn = this;
frontend/src/app/layout.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { Metadata } from "next";
2
  import "./globals.css";
3
 
4
  export const metadata: Metadata = {
@@ -7,6 +7,16 @@ export const metadata: Metadata = {
7
  "Voice-first AI advisor for Indian health insurance. Built for Sarvam AI.",
8
  };
9
 
 
 
 
 
 
 
 
 
 
 
10
  export default function RootLayout({
11
  children,
12
  }: Readonly<{ children: React.ReactNode }>) {
 
1
+ import type { Metadata, Viewport } from "next";
2
  import "./globals.css";
3
 
4
  export const metadata: Metadata = {
 
7
  "Voice-first AI advisor for Indian health insurance. Built for Sarvam AI.",
8
  };
9
 
10
+ // V4 #5 — iOS soft-keyboard handling. `viewport-fit=cover` lets the page
11
+ // extend under safe-area insets (notch / home indicator) so the chat scroll
12
+ // container can use `env(safe-area-inset-bottom)` and `100dvh` to avoid
13
+ // being pushed behind the soft keyboard.
14
+ export const viewport: Viewport = {
15
+ width: "device-width",
16
+ initialScale: 1,
17
+ viewportFit: "cover",
18
+ };
19
+
20
  export default function RootLayout({
21
  children,
22
  }: Readonly<{ children: React.ReactNode }>) {
frontend/src/app/page.tsx CHANGED
@@ -206,6 +206,13 @@ export default function Page() {
206
  // re-rendering / re-subscribing.
207
  const isTextRequestPendingRef = useRef(false);
208
 
 
 
 
 
 
 
 
209
  // Compatibility surface: the rest of the component (PTT path, UI pill, mic
210
  // blocked indicator) still references live.live / live.setLive /
211
  // live.recording / live.micPermissionDenied — preserve that shape so the
@@ -213,6 +220,31 @@ export default function Page() {
213
  const [voiceEnabled, setVoiceEnabled] = useState(false);
214
  const [voiceListening, setVoiceListening] = useState(false);
215
  const [voicePermDenied, setVoicePermDenied] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  // Submit handler — bound to the latest send() via a ref so the hook
217
  // doesn't need to re-subscribe on every closure change.
218
  const voiceSubmitRef = useRef<((text: string) => void) | null>(null);
@@ -222,7 +254,9 @@ export default function Page() {
222
  isTextRequestPendingRef,
223
  onInterimTranscript: (text) => {
224
  // Show the running transcript in the chat input area as the user speaks.
225
- setInput(text);
 
 
226
  },
227
  onFinalTranscript: (text) => {
228
  // Browser detected end-of-speech — auto-submit through the regular
@@ -303,14 +337,106 @@ export default function Page() {
303
  localStorage.removeItem("insurance_live_pref");
304
  }, []);
305
  // Persist + sync to the live hook whenever the user toggles preference.
 
 
 
 
306
  useEffect(() => {
307
  if (typeof window !== "undefined") {
308
  localStorage.setItem("insurance_live_pref", userPrefersLive ? "on" : "off");
309
  }
 
 
 
310
  live.setLive(userPrefersLive);
311
  // eslint-disable-next-line react-hooks/exhaustive-deps
312
  }, [userPrefersLive]);
313
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  function pushUser(text: string) {
315
  setMessages((m) => [...m, { id: `u_${Date.now()}`, role: "user", content: text }]);
316
  }
@@ -373,22 +499,32 @@ export default function Page() {
373
  }
374
 
375
  async function send(text: string) {
 
 
 
 
376
  if (!text.trim() || busy) return;
377
  // KI-204 (2026-05-15) — silence any prior bot TTS BEFORE submitting.
378
  // User starting a new turn always takes precedence over the bot's
379
  // current reply audio. Covers typed sends, voice barge-in, manual Send
380
  // button, programmatic submits — every path through send() gets this.
381
- if (typeof document !== "undefined") {
382
- document.querySelectorAll("audio").forEach((el) => {
383
- try {
384
- (el as HTMLAudioElement).pause();
385
- (el as HTMLAudioElement).currentTime = 0;
386
- } catch {
387
- // ignore — element may be in a state that disallows pause
388
- }
389
- });
390
- }
391
  setBusy(true);
 
 
 
 
 
 
 
 
 
 
 
 
392
  // KI-165 (2026-05-15) — flip the text-in-flight flag so the voice hook
393
  // (useLiveConversation) discards any captures that close during this
394
  // request. Prevents background notification dings from opening the mic,
@@ -424,12 +560,26 @@ export default function Page() {
424
  showProfile ? "profile" :
425
  showPremium ? "premium" :
426
  "chat";
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  const res = await postChat({
428
  user_text: actualText,
429
  session_id: sessionId,
430
  chat_history: history,
431
  return_audio: returnAudio,
432
  tts_language_code: ttsLang,
 
433
  view_context: {
434
  active_view,
435
  active_policy_id: openPolicy?.policy_id,
@@ -455,7 +605,12 @@ export default function Page() {
455
  getProfileCompleteness(res.session_id)
456
  .then(setProfileCompleteness)
457
  .catch(() => { /* keep prior on transient error */ });
458
- const audioUrl = res.audio_base64 ? audioBlobURLFromBase64(res.audio_base64) : undefined;
 
 
 
 
 
459
  pushAssistant(res.reply_text, {
460
  citations: res.citations,
461
  audioUrl,
@@ -494,6 +649,12 @@ export default function Page() {
494
  // KI-165 (2026-05-15) — clear the text-in-flight flag so subsequent
495
  // genuine voice captures can be submitted again.
496
  isTextRequestPendingRef.current = false;
 
 
 
 
 
 
497
  }
498
  }
499
 
@@ -506,10 +667,16 @@ export default function Page() {
506
  voiceSubmitRef.current = (text: string) => {
507
  const t = text.trim();
508
  if (t.length < 2) return;
 
 
 
 
 
509
  // Mirror the typed-input flow: drop transcript into the input
510
  // (so the user sees their final words land in the box for a frame
511
  // before send() clears it) then submit.
512
- setInput(t);
 
513
  void send(t);
514
  };
515
  // send() reads `messages` / `sessionId` / `ttsLang` / view flags via
@@ -518,6 +685,15 @@ export default function Page() {
518
  }, [messages, sessionId, ttsLang, openPolicy, showMarketplace, showProfile, showPremium]);
519
 
520
  async function startRecording() {
 
 
 
 
 
 
 
 
 
521
  // KI-027 — Push-to-talk briefly SUSPENDS Live mode (which is otherwise
522
  // always on). This avoids the duplicate-mic / duplicate-/api/chat bug
523
  // from the 2026-05-14 screenshot: only one path captures + dispatches
@@ -556,6 +732,15 @@ export default function Page() {
556
  // the user speaks). Best-effort: if SR is unsupported or start() throws
557
  // we silently continue with the existing Sarvam-only flow.
558
  pttFinalTranscriptRef.current = "";
 
 
 
 
 
 
 
 
 
559
  try {
560
  const w = window as unknown as {
561
  SpeechRecognition?: PTTSpeechRecognitionCtor;
@@ -582,9 +767,30 @@ export default function Page() {
582
  if (r.isFinal) final += alt.transcript;
583
  else interim += alt.transcript;
584
  }
585
- if (final) pttFinalTranscriptRef.current = final;
 
 
 
 
 
 
 
 
 
586
  const display = (final + interim).trim();
587
- if (display) setInput(display);
 
 
 
 
 
 
 
 
 
 
 
 
588
  };
589
  rec.onerror = () => { /* best-effort — Sarvam is the source of truth */ };
590
  rec.onend = () => { /* nothing — recorder.onstop drives the submit */ };
@@ -608,6 +814,16 @@ export default function Page() {
608
  if (sr) {
609
  try { sr.abort(); } catch { /* already stopped */ }
610
  }
 
 
 
 
 
 
 
 
 
 
611
  const srFallback = pttFinalTranscriptRef.current.trim();
612
  const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
613
  setRecording(false);
@@ -638,14 +854,15 @@ export default function Page() {
638
  // KI-213 — replace the interim SR transcript with Sarvam's
639
  // authoritative version, then submit. send() clears the input
640
  // itself so the brief flash here is intentional UX feedback.
641
- setInput(text);
 
642
  // send() flips voicePhase to "thinking" itself; no need to set here
643
  await send(text);
644
  } else if (srFallback) {
645
  // KI-213 — Sarvam returned empty but the browser caught
646
  // something. Better than telling the user "couldn't hear that
647
  // clearly" when we actually have a usable transcript.
648
- setInput(srFallback);
649
  await send(srFallback);
650
  } else {
651
  setInput("");
@@ -655,7 +872,7 @@ export default function Page() {
655
  // KI-213 — Sarvam failed (network / 5xx / rate limit). Fall back to
656
  // the SR transcript if we have one rather than dropping the turn.
657
  if (srFallback) {
658
- setInput(srFallback);
659
  try { await send(srFallback); } catch { /* send handles its own errors */ }
660
  } else {
661
  setInput("");
@@ -758,8 +975,13 @@ export default function Page() {
758
  }
759
  }
760
 
 
 
 
 
 
761
  return (
762
- <div className="min-h-screen flex flex-col bg-[var(--background)] text-[var(--foreground)]">
763
  <header className="border-b border-[var(--border)] bg-[var(--card)]">
764
  <div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
765
  <div className="flex items-center gap-3">
@@ -930,12 +1152,62 @@ export default function Page() {
930
  </div>
931
  )}
932
 
933
- <div className="border border-[var(--border)] rounded-2xl bg-[var(--card)] p-3 shadow-sm">
 
 
 
 
 
 
 
 
934
  <div className="flex items-end gap-2">
935
  <textarea
936
  value={input}
937
- onChange={(e) => setInput(e.target.value)}
938
- onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(input); } }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
939
  placeholder="Ask about coverage, waiting periods, exclusions, or compare policies…"
940
  rows={1}
941
  className="flex-1 resize-none bg-transparent outline-none text-sm sm:text-base px-2 py-2 min-h-[40px] max-h-32"
@@ -993,6 +1265,20 @@ export default function Page() {
993
  Send
994
  </button>
995
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
996
  <div className="flex items-center justify-between gap-3 mt-2 pt-2 px-2 text-xs text-[var(--muted-foreground)]">
997
  <div className="flex items-center gap-3">
998
  {/* KI-028 — Clickable Live toggle. Green = always-on listening,
@@ -1634,7 +1920,16 @@ function stripInlineCitations(text: string): string {
1634
 
1635
  function Message({ m }: { m: DisplayMessage }) {
1636
  const isUser = m.role === "user";
1637
- const displayContent = isUser ? m.content : stripInlineCitations(m.content);
 
 
 
 
 
 
 
 
 
1638
  const audioRef = useRef<HTMLAudioElement | null>(null);
1639
 
1640
  // KI-030 — Auto-play the bot's TTS reply when the message first mounts.
@@ -1648,12 +1943,27 @@ function Message({ m }: { m: DisplayMessage }) {
1648
  // Played only on mount (one-shot) so chat-history rehydration doesn't
1649
  // replay every old reply. (audioUrl is also stripped from localStorage on
1650
  // persist, so old messages don't have URLs to replay anyway.)
 
 
 
 
 
 
 
1651
  useEffect(() => {
1652
- if (m.audioUrl && audioRef.current) {
1653
- audioRef.current.play().catch(() => {
 
 
1654
  /* autoplay blocked — user can click the inline control to listen */
1655
  });
 
 
 
 
1656
  }
 
 
1657
  // eslint-disable-next-line react-hooks/exhaustive-deps
1658
  }, []);
1659
 
@@ -1662,7 +1972,14 @@ function Message({ m }: { m: DisplayMessage }) {
1662
  <div className={`max-w-[85%] sm:max-w-[75%] rounded-2xl px-4 py-3 ${
1663
  isUser ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--card)] border border-[var(--border)]"
1664
  }`}>
1665
- <div className="text-sm sm:text-base whitespace-pre-wrap leading-relaxed">{displayContent}</div>
 
 
 
 
 
 
 
1666
  {m.audioUrl && (
1667
  <audio
1668
  ref={audioRef}
 
206
  // re-rendering / re-subscribing.
207
  const isTextRequestPendingRef = useRef(false);
208
 
209
+ // KI-222 FIX 2 (2026-05-15) — AbortController for the in-flight /api/chat
210
+ // call inside send(). triggerBargeIn() (or any code path that wants to
211
+ // cancel a pending bot turn) can fire a `barge-in-abort` window event and
212
+ // the useEffect below will call .abort() on this controller. The signal
213
+ // is not yet plumbed through postChat()/api.ts — see TODO below in send().
214
+ const currentSendAbortRef = useRef<AbortController | null>(null);
215
+
216
  // Compatibility surface: the rest of the component (PTT path, UI pill, mic
217
  // blocked indicator) still references live.live / live.setLive /
218
  // live.recording / live.micPermissionDenied — preserve that shape so the
 
220
  const [voiceEnabled, setVoiceEnabled] = useState(false);
221
  const [voiceListening, setVoiceListening] = useState(false);
222
  const [voicePermDenied, setVoicePermDenied] = useState(false);
223
+
224
+ // V4 FIX 1 — Live PTT interim transcript. Mirrors the running browser-SR
225
+ // transcript so the user can see what's being captured BELOW the mic
226
+ // button in gray italic (rather than only inside the chat input).
227
+ // Throttled to ~200ms via pttInterimTimerRef so we don't thrash React on
228
+ // every SR partial. Cleared atomically by V4 FIX 3 when the final
229
+ // transcript arrives.
230
+ const [pttInterim, setPttInterim] = useState<string>("");
231
+ const pttInterimTimerRef = useRef<number | null>(null);
232
+ const pttInterimLatestRef = useRef<string>("");
233
+ // V4 FIX 2 — dedup window for final transcripts. Some browsers fire the
234
+ // final SpeechRecognition result twice (Safari quirk). Suppress
235
+ // identical strings arriving within 500ms.
236
+ const lastFinalTextRef = useRef<{ text: string; at: number }>({ text: "", at: 0 });
237
+ // V4 FIX 4 — when the input contains a freshly-committed transcript
238
+ // fragment (set programmatically by the voice path, not typed by the
239
+ // user), Backspace should erase the last WORD instead of one character.
240
+ // Tracks whether the current input contents originated from voice;
241
+ // cleared as soon as the user types or sends.
242
+ const inputFromTranscriptRef = useRef<boolean>(false);
243
+ const setInputFromTranscript = (text: string) => {
244
+ inputFromTranscriptRef.current = !!text;
245
+ setInput(text);
246
+ };
247
+
248
  // Submit handler — bound to the latest send() via a ref so the hook
249
  // doesn't need to re-subscribe on every closure change.
250
  const voiceSubmitRef = useRef<((text: string) => void) | null>(null);
 
254
  isTextRequestPendingRef,
255
  onInterimTranscript: (text) => {
256
  // Show the running transcript in the chat input area as the user speaks.
257
+ // V4 FIX 4 — mark the input as transcript-sourced so Backspace
258
+ // erases the last word, not one character.
259
+ setInputFromTranscript(text);
260
  },
261
  onFinalTranscript: (text) => {
262
  // Browser detected end-of-speech — auto-submit through the regular
 
337
  localStorage.removeItem("insurance_live_pref");
338
  }, []);
339
  // Persist + sync to the live hook whenever the user toggles preference.
340
+ // V3 FIX 3 — if the user clicks the toggle while the bot is mid-sentence,
341
+ // run the interrupt cleanup so the audio stops, the blob is revoked, and
342
+ // the half-painted message gets the "⏸ paused" suffix. Only fires on
343
+ // OFF — toggling ON shouldn't pause anything (there's nothing to pause).
344
  useEffect(() => {
345
  if (typeof window !== "undefined") {
346
  localStorage.setItem("insurance_live_pref", userPrefersLive ? "on" : "off");
347
  }
348
+ if (!userPrefersLive) {
349
+ try { interruptBotAudio("user-toggle"); } catch { /* ignore */ }
350
+ }
351
  live.setLive(userPrefersLive);
352
  // eslint-disable-next-line react-hooks/exhaustive-deps
353
  }, [userPrefersLive]);
354
 
355
+ // V3 FIX 2 + FIX 3 — Hardened TTS interrupt cleanup. When the bot is
356
+ // mid-sentence and the user starts speaking / toggles voice off / clicks
357
+ // PTT, we need to:
358
+ // (a) pause the currently-mounted <audio> elements,
359
+ // (b) clear their `src` so the element releases the underlying decoder
360
+ // and stops buffering further data (just .pause() leaves the blob
361
+ // attached and Safari can resume autonomously after a tab refocus),
362
+ // (c) URL.revokeObjectURL() the blob URL so the in-memory blob is GC'd
363
+ // — without this, every interrupted reply leaks a multi-second WAV.
364
+ // (d) tag the last assistant message with a gray italic "⏸ paused"
365
+ // suffix so the user can see WHICH reply they cut off (V3 #3).
366
+ // Safe to call even when nothing is playing — every step is wrapped.
367
+ function interruptBotAudio(reason: "barge-in" | "user-toggle" | "ptt-start") {
368
+ let didPause = false;
369
+ if (typeof document !== "undefined") {
370
+ document.querySelectorAll("audio").forEach((el) => {
371
+ const audioEl = el as HTMLAudioElement;
372
+ const wasPlaying = !audioEl.paused && !audioEl.ended;
373
+ try {
374
+ audioEl.pause();
375
+ } catch { /* ignore */ }
376
+ const src = audioEl.src;
377
+ try {
378
+ if (src && src.startsWith("blob:")) URL.revokeObjectURL(src);
379
+ } catch { /* ignore */ }
380
+ try {
381
+ audioEl.removeAttribute("src");
382
+ // setting empty string makes some browsers attempt a refetch;
383
+ // load() after removing the attribute fully resets the element.
384
+ audioEl.load();
385
+ } catch { /* ignore */ }
386
+ if (wasPlaying) didPause = true;
387
+ });
388
+ }
389
+ // V3 FIX 3 — append "⏸ paused" suffix to the most recent assistant
390
+ // message ONLY if we actually paused mid-playback. We don't want to
391
+ // mark every reply as paused just because the user clicked the toggle
392
+ // before any audio existed. Guard with `didPause` and the existence of
393
+ // a trailing assistant bubble that still has its blob URL.
394
+ if (!didPause) return;
395
+ setMessages((prev) => {
396
+ if (prev.length === 0) return prev;
397
+ const lastIdx = prev.length - 1;
398
+ const last = prev[lastIdx];
399
+ if (last.role !== "assistant") return prev;
400
+ // Idempotent — don't double-append the suffix if the user fires
401
+ // multiple barge-ins back to back.
402
+ if (last.content.endsWith("⏸ paused")) return prev;
403
+ const updated = [...prev];
404
+ updated[lastIdx] = {
405
+ ...last,
406
+ content: `${last.content} ⏸ paused`,
407
+ // Drop the audioUrl so the inline player no longer offers replay
408
+ // of a blob URL we just revoked.
409
+ audioUrl: undefined,
410
+ };
411
+ void reason; // reserved for future telemetry
412
+ return updated;
413
+ });
414
+ }
415
+
416
+ // KI-222 FIX 2 (2026-05-15) — listen for the custom "barge-in-abort" DOM
417
+ // event so useStreamingVoice's triggerBargeIn (or any other code path)
418
+ // can cancel an in-flight send() turn. Hook dispatches via
419
+ // window.dispatchEvent(new CustomEvent("barge-in-abort"))
420
+ // Idempotent: if no request is in-flight, the call is a no-op.
421
+ // V3 FIX 2 — also runs the audio cleanup helper so any in-flight TTS
422
+ // blob is released, not just paused.
423
+ useEffect(() => {
424
+ if (typeof window === "undefined") return;
425
+ const onAbort = () => {
426
+ try {
427
+ currentSendAbortRef.current?.abort();
428
+ } catch { /* ignore — controller may already be released */ }
429
+ try {
430
+ interruptBotAudio("barge-in");
431
+ } catch { /* ignore */ }
432
+ };
433
+ window.addEventListener("barge-in-abort", onAbort);
434
+ return () => window.removeEventListener("barge-in-abort", onAbort);
435
+ // interruptBotAudio is referentially stable enough — closures over
436
+ // setMessages (stable) and DOM globals; safe to omit from deps.
437
+ // eslint-disable-next-line react-hooks/exhaustive-deps
438
+ }, []);
439
+
440
  function pushUser(text: string) {
441
  setMessages((m) => [...m, { id: `u_${Date.now()}`, role: "user", content: text }]);
442
  }
 
499
  }
500
 
501
  async function send(text: string) {
502
+ // V4 FIX 6 — empty-message guard. The trim()-check below already
503
+ // covers Enter-on-empty + the disabled Send button; keeping the
504
+ // explicit early-return here so the guard survives any future change
505
+ // that bypasses the input-clear path.
506
  if (!text.trim() || busy) return;
507
  // KI-204 (2026-05-15) — silence any prior bot TTS BEFORE submitting.
508
  // User starting a new turn always takes precedence over the bot's
509
  // current reply audio. Covers typed sends, voice barge-in, manual Send
510
  // button, programmatic submits — every path through send() gets this.
511
+ // V3 FIX 2 route through interruptBotAudio so the previous reply's
512
+ // blob URL is revoked (not just paused) and the half-painted message
513
+ // gets the "⏸ paused" suffix.
514
+ try { interruptBotAudio("barge-in"); } catch { /* ignore */ }
 
 
 
 
 
 
515
  setBusy(true);
516
+ // KI-222 FIX 2 (2026-05-15) — create an AbortController for this turn so
517
+ // a subsequent barge-in (or any external cancel) can interrupt the
518
+ // in-flight /api/chat call. The controller is exposed on
519
+ // currentSendAbortRef; a window-level "barge-in-abort" event listener
520
+ // (see useEffect below) calls .abort() on it.
521
+ // TODO: thread `signal: controller.signal` through postChat() →
522
+ // frontend/src/lib/api.ts so the abort actually reaches fetch. Until
523
+ // then, the controller is wired up but the abort() call has no effect
524
+ // on the in-flight request — the infrastructure is in place for the
525
+ // follow-up fix.
526
+ const controller = new AbortController();
527
+ currentSendAbortRef.current = controller;
528
  // KI-165 (2026-05-15) — flip the text-in-flight flag so the voice hook
529
  // (useLiveConversation) discards any captures that close during this
530
  // request. Prevents background notification dings from opening the mic,
 
560
  showProfile ? "profile" :
561
  showPremium ? "premium" :
562
  "chat";
563
+ // V3 FIX 4 — Safari has no webm/opus playback. Detect MediaSource
564
+ // codec support up front and ask the backend for audio/mp4 when the
565
+ // default opus would fail. Falls back to audio/wav (the historical
566
+ // default) when MediaSource isn't available at all (very old
567
+ // browsers / test environments).
568
+ const preferredCodec = (() => {
569
+ if (typeof window === "undefined") return undefined;
570
+ const MS = (window as unknown as { MediaSource?: { isTypeSupported: (t: string) => boolean } }).MediaSource;
571
+ if (!MS || typeof MS.isTypeSupported !== "function") return "audio/wav";
572
+ if (MS.isTypeSupported("audio/webm; codecs=opus")) return "audio/webm; codecs=opus";
573
+ if (MS.isTypeSupported("audio/mp4")) return "audio/mp4";
574
+ return "audio/wav";
575
+ })();
576
  const res = await postChat({
577
  user_text: actualText,
578
  session_id: sessionId,
579
  chat_history: history,
580
  return_audio: returnAudio,
581
  tts_language_code: ttsLang,
582
+ preferred_codec: preferredCodec,
583
  view_context: {
584
  active_view,
585
  active_policy_id: openPolicy?.policy_id,
 
605
  getProfileCompleteness(res.session_id)
606
  .then(setProfileCompleteness)
607
  .catch(() => { /* keep prior on transient error */ });
608
+ // V3 FIX 4 honour the actual mime the backend produced when present
609
+ // (Safari refuses to play mp4 bytes wrapped in a wav-typed Blob).
610
+ // Falls back to wav for legacy backends that don't echo audio_mime.
611
+ const audioUrl = res.audio_base64
612
+ ? audioBlobURLFromBase64(res.audio_base64, res.audio_mime || "audio/wav")
613
+ : undefined;
614
  pushAssistant(res.reply_text, {
615
  citations: res.citations,
616
  audioUrl,
 
649
  // KI-165 (2026-05-15) — clear the text-in-flight flag so subsequent
650
  // genuine voice captures can be submitted again.
651
  isTextRequestPendingRef.current = false;
652
+ // KI-222 FIX 2 — release the abort controller now that the request
653
+ // has resolved (or thrown). If a later barge-in event fires after
654
+ // this point there's no in-flight turn to cancel.
655
+ if (currentSendAbortRef.current === controller) {
656
+ currentSendAbortRef.current = null;
657
+ }
658
  }
659
  }
660
 
 
667
  voiceSubmitRef.current = (text: string) => {
668
  const t = text.trim();
669
  if (t.length < 2) return;
670
+ // V4 FIX 2 — dedup repeated finals within 500ms.
671
+ const { text: prevText, at: prevAt } = lastFinalTextRef.current;
672
+ const now = Date.now();
673
+ if (t === prevText && now - prevAt < 500) return;
674
+ lastFinalTextRef.current = { text: t, at: now };
675
  // Mirror the typed-input flow: drop transcript into the input
676
  // (so the user sees their final words land in the box for a frame
677
  // before send() clears it) then submit.
678
+ // V4 FIX 4 — flag the input as transcript-sourced.
679
+ setInputFromTranscript(t);
680
  void send(t);
681
  };
682
  // send() reads `messages` / `sessionId` / `ttsLang` / view flags via
 
685
  }, [messages, sessionId, ttsLang, openPolicy, showMarketplace, showProfile, showPremium]);
686
 
687
  async function startRecording() {
688
+ // KI-222 FIX 1 — silence any prior bot TTS BEFORE PTT recording starts.
689
+ // Mirrors the same pause-all-audio block from send() (KI-204). Without
690
+ // this, the previous reply's <audio> element keeps playing after the
691
+ // user clicks Push-to-talk, and Sarvam transcribes the bot's own voice
692
+ // as user input.
693
+ // V3 FIX 2 — use the unified interrupt helper so the blob URL is
694
+ // revoked and the half-painted message picks up the "⏸ paused" suffix
695
+ // when PTT cuts the bot off mid-sentence.
696
+ try { interruptBotAudio("ptt-start"); } catch { /* ignore */ }
697
  // KI-027 — Push-to-talk briefly SUSPENDS Live mode (which is otherwise
698
  // always on). This avoids the duplicate-mic / duplicate-/api/chat bug
699
  // from the 2026-05-14 screenshot: only one path captures + dispatches
 
732
  // the user speaks). Best-effort: if SR is unsupported or start() throws
733
  // we silently continue with the existing Sarvam-only flow.
734
  pttFinalTranscriptRef.current = "";
735
+ // V4 FIX 1 / FIX 3 — reset both the visible interim strip AND the
736
+ // throttle ref each new PTT cycle so a stale gray-italic transcript
737
+ // from the previous turn doesn't leak through.
738
+ pttInterimLatestRef.current = "";
739
+ setPttInterim("");
740
+ if (pttInterimTimerRef.current !== null) {
741
+ clearTimeout(pttInterimTimerRef.current);
742
+ pttInterimTimerRef.current = null;
743
+ }
744
  try {
745
  const w = window as unknown as {
746
  SpeechRecognition?: PTTSpeechRecognitionCtor;
 
767
  if (r.isFinal) final += alt.transcript;
768
  else interim += alt.transcript;
769
  }
770
+ // V4 FIX 2 — dedup repeated finals within 500ms.
771
+ if (final) {
772
+ const trimmedFinal = final.trim();
773
+ const { text: prevText, at: prevAt } = lastFinalTextRef.current;
774
+ const now = Date.now();
775
+ if (trimmedFinal && (trimmedFinal !== prevText || now - prevAt > 500)) {
776
+ pttFinalTranscriptRef.current = final;
777
+ lastFinalTextRef.current = { text: trimmedFinal, at: now };
778
+ }
779
+ }
780
  const display = (final + interim).trim();
781
+ // V4 FIX 4 — interim transcript flows into the input as a
782
+ // transcript-sourced fragment so Backspace can word-erase.
783
+ if (display) setInputFromTranscript(display);
784
+ // V4 FIX 1 — feed the below-mic ghost-italic display. Throttle
785
+ // to 200ms so very chatty SR engines (Chrome fires ~20 partials/s
786
+ // on fast speakers) don't thrash React.
787
+ pttInterimLatestRef.current = display;
788
+ if (pttInterimTimerRef.current === null) {
789
+ pttInterimTimerRef.current = window.setTimeout(() => {
790
+ setPttInterim(pttInterimLatestRef.current);
791
+ pttInterimTimerRef.current = null;
792
+ }, 200);
793
+ }
794
  };
795
  rec.onerror = () => { /* best-effort — Sarvam is the source of truth */ };
796
  rec.onend = () => { /* nothing — recorder.onstop drives the submit */ };
 
814
  if (sr) {
815
  try { sr.abort(); } catch { /* already stopped */ }
816
  }
817
+ // V4 FIX 3 — atomically clear the interim ghost text (both the
818
+ // pending throttled update AND any visible state). Without this,
819
+ // the gray-italic strip below the mic can keep showing the last
820
+ // partial transcript after the final has already been committed.
821
+ if (pttInterimTimerRef.current !== null) {
822
+ clearTimeout(pttInterimTimerRef.current);
823
+ pttInterimTimerRef.current = null;
824
+ }
825
+ pttInterimLatestRef.current = "";
826
+ setPttInterim("");
827
  const srFallback = pttFinalTranscriptRef.current.trim();
828
  const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
829
  setRecording(false);
 
854
  // KI-213 — replace the interim SR transcript with Sarvam's
855
  // authoritative version, then submit. send() clears the input
856
  // itself so the brief flash here is intentional UX feedback.
857
+ // V4 FIX 4 — transcript-sourced.
858
+ setInputFromTranscript(text);
859
  // send() flips voicePhase to "thinking" itself; no need to set here
860
  await send(text);
861
  } else if (srFallback) {
862
  // KI-213 — Sarvam returned empty but the browser caught
863
  // something. Better than telling the user "couldn't hear that
864
  // clearly" when we actually have a usable transcript.
865
+ setInputFromTranscript(srFallback);
866
  await send(srFallback);
867
  } else {
868
  setInput("");
 
872
  // KI-213 — Sarvam failed (network / 5xx / rate limit). Fall back to
873
  // the SR transcript if we have one rather than dropping the turn.
874
  if (srFallback) {
875
+ setInputFromTranscript(srFallback);
876
  try { await send(srFallback); } catch { /* send handles its own errors */ }
877
  } else {
878
  setInput("");
 
975
  }
976
  }
977
 
978
+ // V4 FIX 5 — `min-h-[100dvh]` uses the dynamic viewport unit so the
979
+ // layout shrinks correctly when the iOS soft keyboard opens (vs the
980
+ // legacy `100vh` which stays fixed and pushes the composer behind the
981
+ // keyboard). `min-h-screen` is kept as a fallback for browsers that
982
+ // don't understand `dvh`.
983
  return (
984
+ <div className="min-h-screen min-h-[100dvh] flex flex-col bg-[var(--background)] text-[var(--foreground)]">
985
  <header className="border-b border-[var(--border)] bg-[var(--card)]">
986
  <div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
987
  <div className="flex items-center gap-3">
 
1152
  </div>
1153
  )}
1154
 
1155
+ {/* V4 FIX 5 — pb-[env(safe-area-inset-bottom)] keeps the composer
1156
+ above the iOS home-indicator strip even when the soft keyboard
1157
+ is open. Combined with viewport-fit=cover on the meta + the
1158
+ min-h-0 wrapper above, the chat scroll container hands the
1159
+ keyboard its space instead of getting hidden behind it. */}
1160
+ <div
1161
+ className="border border-[var(--border)] rounded-2xl bg-[var(--card)] p-3 shadow-sm"
1162
+ style={{ paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))" }}
1163
+ >
1164
  <div className="flex items-end gap-2">
1165
  <textarea
1166
  value={input}
1167
+ onChange={(e) => {
1168
+ // V4 FIX 4 once the user starts typing, the input is no
1169
+ // longer "transcript-sourced", so the next Backspace should
1170
+ // behave normally (single-character erase).
1171
+ inputFromTranscriptRef.current = false;
1172
+ setInput(e.target.value);
1173
+ }}
1174
+ onKeyDown={(e) => {
1175
+ if (e.key === "Enter" && !e.shiftKey) {
1176
+ e.preventDefault();
1177
+ // V4 FIX 6 — guard against empty Enter.
1178
+ if (!input.trim()) return;
1179
+ send(input);
1180
+ return;
1181
+ }
1182
+ // V4 FIX 4 — if the current input is a freshly-committed
1183
+ // transcript fragment AND the caret is at the end of the
1184
+ // text, Backspace erases the last word. Once the user has
1185
+ // typed anything (or moved the caret mid-string), the
1186
+ // transcript flag was cleared by onChange — so this branch
1187
+ // no longer fires and Backspace behaves normally.
1188
+ if (
1189
+ e.key === "Backspace"
1190
+ && inputFromTranscriptRef.current
1191
+ && !e.metaKey
1192
+ && !e.ctrlKey
1193
+ && !e.altKey
1194
+ ) {
1195
+ const ta = e.currentTarget;
1196
+ const atEnd = ta.selectionStart === input.length && ta.selectionEnd === input.length;
1197
+ if (atEnd && input.length > 0) {
1198
+ e.preventDefault();
1199
+ // Strip trailing whitespace, then drop the last word.
1200
+ const stripped = input.replace(/\s+$/, "");
1201
+ const lastSpace = stripped.lastIndexOf(" ");
1202
+ const erased = lastSpace >= 0 ? stripped.slice(0, lastSpace) : "";
1203
+ setInput(erased);
1204
+ // Keep the transcript-sourced flag set so subsequent
1205
+ // Backspaces continue to erase by word until the box is
1206
+ // empty.
1207
+ inputFromTranscriptRef.current = erased.length > 0;
1208
+ }
1209
+ }
1210
+ }}
1211
  placeholder="Ask about coverage, waiting periods, exclusions, or compare policies…"
1212
  rows={1}
1213
  className="flex-1 resize-none bg-transparent outline-none text-sm sm:text-base px-2 py-2 min-h-[40px] max-h-32"
 
1265
  Send
1266
  </button>
1267
  </div>
1268
+ {/* V4 FIX 1 — live PTT interim transcript directly under the mic
1269
+ row, shown in gray italic. Updates ~5/sec via the throttled
1270
+ pttInterim state. Hidden when not recording or when there's
1271
+ no partial yet, so we don't render an empty 1-line strip. */}
1272
+ {recording && pttInterim && (
1273
+ <div
1274
+ className="mt-1 px-2 text-xs italic text-[var(--muted-foreground)] leading-snug truncate"
1275
+ aria-live="polite"
1276
+ aria-atomic="true"
1277
+ title={pttInterim}
1278
+ >
1279
+ {pttInterim}
1280
+ </div>
1281
+ )}
1282
  <div className="flex items-center justify-between gap-3 mt-2 pt-2 px-2 text-xs text-[var(--muted-foreground)]">
1283
  <div className="flex items-center gap-3">
1284
  {/* KI-028 — Clickable Live toggle. Green = always-on listening,
 
1920
 
1921
  function Message({ m }: { m: DisplayMessage }) {
1922
  const isUser = m.role === "user";
1923
+ // V3 FIX 3 split off the trailing "⏸ paused" marker (appended by
1924
+ // interruptBotAudio) so we can render it as gray italic instead of plain
1925
+ // body text. Only matches an exact-suffix; embedded "paused" in normal
1926
+ // prose is untouched.
1927
+ const rawContent = isUser ? m.content : stripInlineCitations(m.content);
1928
+ const PAUSED_SUFFIX = " ⏸ paused";
1929
+ const isPaused = !isUser && rawContent.endsWith(PAUSED_SUFFIX);
1930
+ const displayContent = isPaused
1931
+ ? rawContent.slice(0, -PAUSED_SUFFIX.length)
1932
+ : rawContent;
1933
  const audioRef = useRef<HTMLAudioElement | null>(null);
1934
 
1935
  // KI-030 — Auto-play the bot's TTS reply when the message first mounts.
 
1943
  // Played only on mount (one-shot) so chat-history rehydration doesn't
1944
  // replay every old reply. (audioUrl is also stripped from localStorage on
1945
  // persist, so old messages don't have URLs to replay anyway.)
1946
+ //
1947
+ // V3 FIX 1 — autoplay/observer race. An IntersectionObserver (or any
1948
+ // mount-time effect) may try to play() the element before its metadata is
1949
+ // ready, resulting in a NotSupportedError or a silent no-op. Wait for
1950
+ // `loadedmetadata` before calling play(); if the metadata has already
1951
+ // arrived by the time the effect runs, play() immediately. readyState ≥ 1
1952
+ // means HAVE_METADATA per the HTMLMediaElement spec.
1953
  useEffect(() => {
1954
+ const el = audioRef.current;
1955
+ if (!m.audioUrl || !el) return;
1956
+ const tryPlay = () => {
1957
+ el.play().catch(() => {
1958
  /* autoplay blocked — user can click the inline control to listen */
1959
  });
1960
+ };
1961
+ if (el.readyState >= 1 /* HAVE_METADATA */) {
1962
+ tryPlay();
1963
+ return;
1964
  }
1965
+ el.addEventListener("loadedmetadata", tryPlay, { once: true });
1966
+ return () => el.removeEventListener("loadedmetadata", tryPlay);
1967
  // eslint-disable-next-line react-hooks/exhaustive-deps
1968
  }, []);
1969
 
 
1972
  <div className={`max-w-[85%] sm:max-w-[75%] rounded-2xl px-4 py-3 ${
1973
  isUser ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--card)] border border-[var(--border)]"
1974
  }`}>
1975
+ <div className="text-sm sm:text-base whitespace-pre-wrap leading-relaxed">
1976
+ {displayContent}
1977
+ {isPaused && (
1978
+ <span className="ml-1 italic text-[var(--muted-foreground)] opacity-80">
1979
+ ⏸ paused
1980
+ </span>
1981
+ )}
1982
+ </div>
1983
  {m.audioUrl && (
1984
  <audio
1985
  ref={audioRef}
frontend/src/lib/api.ts CHANGED
@@ -26,6 +26,11 @@ export type ChatResponse = {
26
  latency_ms: number;
27
  session_id: string;
28
  audio_base64?: string | null;
 
 
 
 
 
29
  faithfulness_passed?: boolean;
30
  faithfulness_reasons?: string[];
31
  blocked?: boolean;
@@ -94,14 +99,21 @@ export async function postChat(args: {
94
  return_audio?: boolean;
95
  tts_language_code?: string;
96
  view_context?: ViewContext;
 
 
 
 
 
97
  signal?: AbortSignal;
98
  onRetry?: (attempt: number) => void;
99
  }): Promise<ChatResponse> {
 
 
100
  const resp = await _fetchWithRetry(
101
  `${BACKEND_URL}/api/chat`,
102
  {
103
  method: "POST",
104
- headers: { "Content-Type": "application/json" },
105
  body: JSON.stringify({
106
  user_text: args.user_text,
107
  session_id: args.session_id,
@@ -111,6 +123,7 @@ export async function postChat(args: {
111
  return_audio: args.return_audio ?? false,
112
  tts_language_code: args.tts_language_code ?? "en-IN",
113
  view_context: args.view_context,
 
114
  }),
115
  },
116
  args.signal,
 
26
  latency_ms: number;
27
  session_id: string;
28
  audio_base64?: string | null;
29
+ // V3 #4 — backend MAY echo the actual mime it produced (e.g. "audio/mp4")
30
+ // when the client requested a codec other than the wav default. The
31
+ // frontend uses this when constructing the playback Blob URL so Safari
32
+ // doesn't refuse to play an mp4 payload labelled as wav.
33
+ audio_mime?: string | null;
34
  faithfulness_passed?: boolean;
35
  faithfulness_reasons?: string[];
36
  blocked?: boolean;
 
99
  return_audio?: boolean;
100
  tts_language_code?: string;
101
  view_context?: ViewContext;
102
+ // V3 #4 — Safari has no webm/opus support. Caller passes its preferred
103
+ // codec ("audio/webm; codecs=opus" or "audio/mp4") and the backend SHOULD
104
+ // honour it on the TTS payload. Sent as a header (`X-Preferred-Codec`)
105
+ // AND included in the body for backends that ignore custom headers.
106
+ preferred_codec?: string;
107
  signal?: AbortSignal;
108
  onRetry?: (attempt: number) => void;
109
  }): Promise<ChatResponse> {
110
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
111
+ if (args.preferred_codec) headers["X-Preferred-Codec"] = args.preferred_codec;
112
  const resp = await _fetchWithRetry(
113
  `${BACKEND_URL}/api/chat`,
114
  {
115
  method: "POST",
116
+ headers,
117
  body: JSON.stringify({
118
  user_text: args.user_text,
119
  session_id: args.session_id,
 
123
  return_audio: args.return_audio ?? false,
124
  tts_language_code: args.tts_language_code ?? "en-IN",
125
  view_context: args.view_context,
126
+ preferred_codec: args.preferred_codec,
127
  }),
128
  },
129
  args.signal,
frontend/src/lib/useStreamingVoice.ts CHANGED
@@ -39,6 +39,15 @@
39
 
40
  import { useCallback, useEffect, useRef, useState } from "react";
41
  import { postTranscribe } from "./api";
 
 
 
 
 
 
 
 
 
42
 
43
  // KI-189 (2026-05-15) — live-speak barge-in tuning constants.
44
  // The MediaRecorder mic stream IS echo-cancelled by the browser (KI-185
@@ -85,6 +94,15 @@ const VOICE_MODE_TTS_VOLUME = 0.3;
85
  // never crosses the recognition threshold.
86
  const USER_SPEECH_RMS_INITIAL = 0.05; // typical quiet speech, used until calibrated
87
  const USER_SPEECH_DETECTION_THRESHOLD = 0.02; // mic RMS above this counts as "user speaking"
 
 
 
 
 
 
 
 
 
88
  const VOLUME_CALIB_TARGET_RATIO = 0.35; // bot_rms_at_mic should be ≤ user_rms × this
89
  const VOLUME_CALIB_TICK_MS = 300; // calibration sample period during TTS
90
  const VOLUME_CALIB_DUCK_FACTOR = 0.8; // multiply el.volume by this per tick if too loud
@@ -149,12 +167,34 @@ export interface UseStreamingVoiceOptions {
149
  onListening: (listening: boolean) => void;
150
  isTextRequestPendingRef: React.MutableRefObject<boolean>;
151
  language?: string;
 
 
 
 
 
152
  }
153
 
154
  export interface UseStreamingVoiceReturn {
155
  start: () => void;
156
  stop: () => void;
157
  isSupported: boolean;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  }
159
 
160
  function resolveCtor(): SpeechRecognitionCtor | null {
@@ -177,6 +217,7 @@ export function useStreamingVoice(
177
  onListening,
178
  isTextRequestPendingRef,
179
  language = "en-IN",
 
180
  } = opts;
181
 
182
  // Keep latest callback refs so the recognition handlers always call the
@@ -186,10 +227,16 @@ export function useStreamingVoice(
186
  const onFinalRef = useRef(onFinalTranscript);
187
  const onErrorRef = useRef(onError);
188
  const onListeningRef = useRef(onListening);
 
 
 
 
 
189
  useEffect(() => { onInterimRef.current = onInterimTranscript; }, [onInterimTranscript]);
190
  useEffect(() => { onFinalRef.current = onFinalTranscript; }, [onFinalTranscript]);
191
  useEffect(() => { onErrorRef.current = onError; }, [onError]);
192
  useEffect(() => { onListeningRef.current = onListening; }, [onListening]);
 
193
 
194
  const recognitionRef = useRef<SpeechRecognitionInstance | null>(null);
195
  const finalsRef = useRef<string[]>([]);
@@ -231,6 +278,20 @@ export function useStreamingVoice(
231
  const pendingUtteranceRef = useRef<string>("");
232
  const pendingChunksRef = useRef<Blob[]>([]);
233
  const pendingSubmitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
  // ----------------------------------------------------------------------
236
  // KI-168 PHASE 2 — Sarvam authoritative-transcript layer.
@@ -452,6 +513,11 @@ export function useStreamingVoice(
452
  if (code === "no-speech" || code === "aborted") return;
453
  if (code === "not-allowed" || code === "service-not-allowed") {
454
  wantRunningRef.current = false;
 
 
 
 
 
455
  onErrorRef.current(
456
  "Mic permission denied. Click the lock icon in your browser's URL bar to enable the microphone.",
457
  );
@@ -459,6 +525,8 @@ export function useStreamingVoice(
459
  }
460
  if (code === "audio-capture") {
461
  wantRunningRef.current = false;
 
 
462
  onErrorRef.current("No microphone detected. Check your audio device and try again.");
463
  return;
464
  }
@@ -486,6 +554,39 @@ export function useStreamingVoice(
486
  // Sarvam fetch we'd be throwing away.
487
  const textRacing = isTextRequestPendingRef.current;
488
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
489
  const scheduleRestart = () => {
490
  if (wantRunningRef.current && !isTextRequestPendingRef.current) {
491
  const backoff = errorBackoffRef.current;
@@ -657,11 +758,30 @@ export function useStreamingVoice(
657
  let authoritativeText = accumulatedText;
658
  if (allChunks.length > 0 && totalSize >= MIN_BLOB_BYTES) {
659
  const blob = new Blob(allChunks, { type: recorderMimeRef.current || "audio/webm" });
660
- const controller = new AbortController();
661
- const timeoutId = setTimeout(() => controller.abort(), 8000);
662
- try {
663
- console.debug("[useStreamingVoice] POST /api/transcribe", { bytes: blob.size, mime: blob.type, lang: language });
664
- const sarvam = await postTranscribe(blob, language, controller.signal);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665
  const sarvamText = (sarvam.text || "").trim();
666
  if (sarvamText) {
667
  authoritativeText = sarvamText;
@@ -673,10 +793,9 @@ export function useStreamingVoice(
673
  } else {
674
  console.debug("[useStreamingVoice] Sarvam returned empty; using Web Speech fallback");
675
  }
676
- } catch (err) {
677
- console.debug("[useStreamingVoice] Sarvam failed; using Web Speech fallback", err);
678
- } finally {
679
- clearTimeout(timeoutId);
680
  }
681
  }
682
 
@@ -734,10 +853,38 @@ export function useStreamingVoice(
734
  } catch {
735
  // ignore
736
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
737
  }
 
738
  teardownAudio();
739
  finalsRef.current = [];
740
  finalsConsumedRef.current = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
  // KI-202 — drop any pending utterance so toggling voice off mid-grace
742
  // doesn't auto-submit a stale half-sentence next time voice comes on.
743
  if (pendingSubmitTimerRef.current !== null) {
@@ -747,7 +894,7 @@ export function useStreamingVoice(
747
  pendingUtteranceRef.current = "";
748
  pendingChunksRef.current = [];
749
  onListeningRef.current(false);
750
- }, [clearRestartTimer, teardownAudio]);
751
 
752
  // Drive start/stop from the `enabled` prop so the hook is fire-and-forget
753
  // for the caller (mirrors useLiveConversation's `live` state semantics).
@@ -835,6 +982,14 @@ export function useStreamingVoice(
835
  const calibratedVolumes = new Map<HTMLAudioElement, number>();
836
  let userRmsRafId: number | null = null;
837
  let volumeCalibIntervalId: ReturnType<typeof setInterval> | null = null;
 
 
 
 
 
 
 
 
838
 
839
  const sampleUserRms = (): number => {
840
  if (!analyser || !rmsBuf) return 0;
@@ -870,6 +1025,9 @@ export function useStreamingVoice(
870
  // doesn't permanently raise the baseline.
871
  if (rms > USER_SPEECH_DETECTION_THRESHOLD) {
872
  userSpeechRms = Math.max(userSpeechRms * 0.95, rms);
 
 
 
873
  }
874
  userRmsRafId = requestAnimationFrame(userRmsTick);
875
  };
@@ -889,6 +1047,27 @@ export function useStreamingVoice(
889
  }
890
  };
891
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
892
  // KI-195 — volume calibration tick. Runs during TTS. Samples bot RMS
893
  // at the mic via botAnalysers. If bot is louder than target relative
894
  // to userSpeechRms, duck el.volume by 20% per tick down to the floor.
@@ -1025,6 +1204,41 @@ export function useStreamingVoice(
1025
  frames: sustainedFrames,
1026
  threshold: BARGE_IN_RMS_THRESHOLD,
1027
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1028
  // Pause + reset every TTS <audio>; the MutationObserver's pause
1029
  // listener will set isTtsPlayingRef = false and call safeStart().
1030
  ttsAudioElementsRef.current.forEach((el) => {
@@ -1054,19 +1268,46 @@ export function useStreamingVoice(
1054
  }
1055
  analyser.getFloatTimeDomainData(rmsBuf);
1056
  let sumSq = 0;
 
 
 
 
 
 
 
1057
  for (let i = 0; i < rmsBuf.length; i++) {
1058
  const v = rmsBuf[i];
1059
  sumSq += v * v;
 
 
 
 
 
1060
  }
1061
  const rms = Math.sqrt(sumSq / rmsBuf.length);
 
 
 
 
 
 
1062
  // KI-190 — adaptive threshold: bot_rms * 2 + 0.005, floored at the
1063
  // base BARGE_IN_RMS_THRESHOLD so we never set it absurdly low.
 
 
 
1064
  const botRms = computeBotRms();
1065
  const adaptiveThreshold = Math.max(
1066
  BARGE_IN_RMS_THRESHOLD,
 
1067
  botRms * BARGE_IN_BOT_RMS_MULTIPLIER + BARGE_IN_BASE_THRESHOLD,
1068
  );
1069
- if (rms >= adaptiveThreshold) {
 
 
 
 
 
1070
  sustainedFrames += 1;
1071
  if (sustainedFrames >= BARGE_IN_SUSTAINED_FRAMES) {
1072
  triggerBargeIn(rms);
@@ -1097,9 +1338,15 @@ export function useStreamingVoice(
1097
  audioCtx = new Ctor();
1098
  }
1099
  if (audioCtx.state === "suspended") {
1100
- // Best-effort resume; ignore failures (autoplay policy may block
1101
- // until next user gesture VAD simply won't fire).
1102
- void audioCtx.resume().catch(() => { /* ignore */ });
 
 
 
 
 
 
1103
  }
1104
  if (!analyser || attachedStream !== stream) {
1105
  try { sourceNode?.disconnect(); } catch { /* ignore */ }
@@ -1111,6 +1358,26 @@ export function useStreamingVoice(
1111
  sourceNode.connect(analyser);
1112
  attachedStream = stream;
1113
  rmsBuf = new Float32Array(new ArrayBuffer(analyser.fftSize * 4));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1114
  }
1115
  sustainedFrames = 0;
1116
  if (rafId !== null) cancelAnimationFrame(rafId);
@@ -1264,11 +1531,17 @@ export function useStreamingVoice(
1264
  // when conditions aren't met (no analyser / no stream / in TTS), so
1265
  // firing it unconditionally here is safe.
1266
  startUserRmsLoop();
 
 
 
 
1267
 
1268
  return () => {
1269
  // KI-195 — tear down adaptive volume calibration before clearing
1270
  // ducked-audio state so the calibration tick can't race a clear().
1271
  stopUserRmsLoop();
 
 
1272
  stopVolumeCalibration();
1273
  calibratedVolumes.clear();
1274
  observer.disconnect();
@@ -1360,5 +1633,16 @@ export function useStreamingVoice(
1360
  };
1361
  }, [clearRestartTimer, teardownAudio]);
1362
 
1363
- return { start, stop, isSupported };
 
 
 
 
 
 
 
 
 
 
 
1364
  }
 
39
 
40
  import { useCallback, useEffect, useRef, useState } from "react";
41
  import { postTranscribe } from "./api";
42
+ // KI-223..228 (2026-05-15) — additive resilience layer (V1.1/V1.3/V5.4/V6.8).
43
+ // Lives in a sibling module so the hook body stays under control and the
44
+ // retry / noise-floor / sample-rate helpers can be unit-tested in isolation.
45
+ import {
46
+ retryPostTranscribe,
47
+ scaleSpeechZcrBand,
48
+ AdaptiveNoiseFloor,
49
+ type VoiceError,
50
+ } from "./voice_resilience";
51
 
52
  // KI-189 (2026-05-15) — live-speak barge-in tuning constants.
53
  // The MediaRecorder mic stream IS echo-cancelled by the browser (KI-185
 
94
  // never crosses the recognition threshold.
95
  const USER_SPEECH_RMS_INITIAL = 0.05; // typical quiet speech, used until calibrated
96
  const USER_SPEECH_DETECTION_THRESHOLD = 0.02; // mic RMS above this counts as "user speaking"
97
+ // FIX 5 (HIGH) — hard ceiling on the rolling-peak userSpeechRms. Without
98
+ // this, a single shout pins userSpeechRms at 0.4+ for the entire session
99
+ // → adaptive barge-in threshold rises → normal-volume speech can't break
100
+ // through → user has to shout to barge in again. The userRmsTick is also
101
+ // gated on !isTtsPlaying, so during TTS playback there's NO decay path —
102
+ // the wall-clock decay interval below provides decay regardless of gating.
103
+ const USER_SPEECH_RMS_CEILING = 0.15;
104
+ const USER_SPEECH_RMS_WALL_CLOCK_DECAY_MS = 1000;
105
+ const USER_SPEECH_RMS_WALL_CLOCK_DECAY_FACTOR = 0.9;
106
  const VOLUME_CALIB_TARGET_RATIO = 0.35; // bot_rms_at_mic should be ≤ user_rms × this
107
  const VOLUME_CALIB_TICK_MS = 300; // calibration sample period during TTS
108
  const VOLUME_CALIB_DUCK_FACTOR = 0.8; // multiply el.volume by this per tick if too loud
 
167
  onListening: (listening: boolean) => void;
168
  isTextRequestPendingRef: React.MutableRefObject<boolean>;
169
  language?: string;
170
+ // KI-223 (2026-05-15) — V1.1 / V1.2 / V5.4. Optional structured error
171
+ // callback so page.tsx can react specifically to recoverable failures
172
+ // (e.g. show "tap to enable audio" when audio_context_suspended fires).
173
+ // Optional: existing consumers that don't pass this still work.
174
+ onVoiceError?: (err: VoiceError) => void;
175
  }
176
 
177
  export interface UseStreamingVoiceReturn {
178
  start: () => void;
179
  stop: () => void;
180
  isSupported: boolean;
181
+ /**
182
+ * FIX 3 (HIGH) — Barge-in signal. The hook flips an internal flag when
183
+ * `triggerBargeIn` fires (user spoke over bot TTS). The caller (page.tsx)
184
+ * should poll this method before/after every fetch tick during a /api/chat
185
+ * stream — if it returns true, abort the in-flight request and any pending
186
+ * audio assembly so the bot doesn't keep talking after the user
187
+ * interrupted. Reading clears the flag (one-shot semantics).
188
+ *
189
+ * Wire-up (caller side, OUT OF THIS HOOK'S SCOPE):
190
+ * - Before fetch, store an AbortController locally.
191
+ * - In the stream-reading loop, periodically check
192
+ * `streamingVoice.consumeBargeInSignal()` and call `controller.abort()`
193
+ * when it returns true.
194
+ * - Alternatively register a side-effect that polls every 100ms while a
195
+ * send() is in flight.
196
+ */
197
+ consumeBargeInSignal: () => boolean;
198
  }
199
 
200
  function resolveCtor(): SpeechRecognitionCtor | null {
 
217
  onListening,
218
  isTextRequestPendingRef,
219
  language = "en-IN",
220
+ onVoiceError,
221
  } = opts;
222
 
223
  // Keep latest callback refs so the recognition handlers always call the
 
227
  const onFinalRef = useRef(onFinalTranscript);
228
  const onErrorRef = useRef(onError);
229
  const onListeningRef = useRef(onListening);
230
+ // KI-223 — optional structured-error callback ref. Defaults to no-op so
231
+ // the rest of the hook can call it unconditionally without null checks.
232
+ const onVoiceErrorRef = useRef<(err: VoiceError) => void>(
233
+ onVoiceError ?? (() => { /* no-op */ }),
234
+ );
235
  useEffect(() => { onInterimRef.current = onInterimTranscript; }, [onInterimTranscript]);
236
  useEffect(() => { onFinalRef.current = onFinalTranscript; }, [onFinalTranscript]);
237
  useEffect(() => { onErrorRef.current = onError; }, [onError]);
238
  useEffect(() => { onListeningRef.current = onListening; }, [onListening]);
239
+ useEffect(() => { onVoiceErrorRef.current = onVoiceError ?? (() => { /* no-op */ }); }, [onVoiceError]);
240
 
241
  const recognitionRef = useRef<SpeechRecognitionInstance | null>(null);
242
  const finalsRef = useRef<string[]>([]);
 
278
  const pendingUtteranceRef = useRef<string>("");
279
  const pendingChunksRef = useRef<Blob[]>([]);
280
  const pendingSubmitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
281
+ // FIX 3 (HIGH) — one-shot barge-in signal. Flipped true by triggerBargeIn
282
+ // when the VAD detects sustained user speech over bot TTS. Read+cleared
283
+ // via consumeBargeInSignal() so the caller (page.tsx) can abort any
284
+ // in-flight /api/chat request that's still assembling more TTS audio.
285
+ const bargeInRequestedRef = useRef<boolean>(false);
286
+ // KI-228 (2026-05-15) — V6.8 adaptive noise floor. Persistent across the
287
+ // entire hook lifetime so a user's noise environment learned across the
288
+ // first 5 seconds carries through later TTS plays even if the audio
289
+ // effect tears down + rebuilds the analyser between turns.
290
+ const noiseFloorRef = useRef<AdaptiveNoiseFloor>(new AdaptiveNoiseFloor());
291
+ // KI-225 (2026-05-15) — V1.3 sample-rate-aware ZCR band, cached from the
292
+ // AudioContext at analyser-build time. Falls back to the 48 kHz reference
293
+ // band when the context isn't up yet.
294
+ const zcrBandRef = useRef<{ min: number; max: number }>({ min: 20, max: 250 });
295
 
296
  // ----------------------------------------------------------------------
297
  // KI-168 PHASE 2 — Sarvam authoritative-transcript layer.
 
513
  if (code === "no-speech" || code === "aborted") return;
514
  if (code === "not-allowed" || code === "service-not-allowed") {
515
  wantRunningRef.current = false;
516
+ // FIX 2 (HIGH) — Terminal-error mic leak. Without teardownAudio()
517
+ // here the MediaRecorder + MediaStream stay open even though
518
+ // recognition has shut down, so the browser's red-dot mic
519
+ // indicator stays lit and the OS thinks we're still recording.
520
+ teardownAudio();
521
  onErrorRef.current(
522
  "Mic permission denied. Click the lock icon in your browser's URL bar to enable the microphone.",
523
  );
 
525
  }
526
  if (code === "audio-capture") {
527
  wantRunningRef.current = false;
528
+ // FIX 2 (HIGH) — see above.
529
+ teardownAudio();
530
  onErrorRef.current("No microphone detected. Check your audio device and try again.");
531
  return;
532
  }
 
554
  // Sarvam fetch we'd be throwing away.
555
  const textRacing = isTextRequestPendingRef.current;
556
 
557
+ // FIX 7 (HIGH) — Silent onend early-return. Chrome's "no-speech"
558
+ // restart loop fires onend every ~5s with no content. Without this
559
+ // guard, every silent onend re-arms the 1500ms grace timer and the
560
+ // grace window extends forever — even when there's nothing pending
561
+ // to submit. Skip the grace-timer reset when:
562
+ // - no new Web Speech text in this cycle, AND
563
+ // - no audio chunks captured this cycle (chunksRef holds the
564
+ // undrained chunks that will become drainedThisEnd below), AND
565
+ // - no previously pending utterance text.
566
+ // We still call scheduleRestart() so the mic comes back online.
567
+ const hasNewChunksThisEnd = recorderActiveRef.current && chunksRef.current.length > 0;
568
+ if (!webSpeechText && !hasNewChunksThisEnd && pendingUtteranceRef.current === "") {
569
+ console.debug("[useStreamingVoice] KI-222 silent onend — skipping grace reset");
570
+ // Inline the restart-only path here so we don't need to refactor
571
+ // the scheduleRestart closure below it.
572
+ if (wantRunningRef.current && !isTextRequestPendingRef.current) {
573
+ const backoff = errorBackoffRef.current;
574
+ errorBackoffRef.current = 0;
575
+ clearRestartTimer();
576
+ restartTimerRef.current = setTimeout(() => {
577
+ restartTimerRef.current = null;
578
+ if (wantRunningRef.current) safeStart();
579
+ }, Math.max(50, backoff));
580
+ } else if (wantRunningRef.current && isTextRequestPendingRef.current) {
581
+ clearRestartTimer();
582
+ restartTimerRef.current = setTimeout(() => {
583
+ restartTimerRef.current = null;
584
+ if (wantRunningRef.current && !isTextRequestPendingRef.current) safeStart();
585
+ }, 250);
586
+ }
587
+ return;
588
+ }
589
+
590
  const scheduleRestart = () => {
591
  if (wantRunningRef.current && !isTextRequestPendingRef.current) {
592
  const backoff = errorBackoffRef.current;
 
758
  let authoritativeText = accumulatedText;
759
  if (allChunks.length > 0 && totalSize >= MIN_BLOB_BYTES) {
760
  const blob = new Blob(allChunks, { type: recorderMimeRef.current || "audio/webm" });
761
+ // KI-226 (2026-05-15) — V5.4. Wrap the Sarvam POST in an
762
+ // exponential-backoff retry (1s/2s/4s, max 3 attempts). The
763
+ // accumulatedText (Web Speech fallback) and accumulated chunks
764
+ // are already captured locally, so retries don't lose the
765
+ // partial transcript. Each attempt enforces its own 8s timeout
766
+ // via the controller signal passed in by retryPostTranscribe.
767
+ console.debug("[useStreamingVoice] POST /api/transcribe", { bytes: blob.size, mime: blob.type, lang: language });
768
+ const sarvam = await retryPostTranscribe(async (signal) => {
769
+ // Race per-attempt 8s timeout against the retry signal so a
770
+ // hung connection still surfaces as an attempt failure (and
771
+ // triggers the next backoff step) rather than blocking
772
+ // forever. signal aborts when the OUTER retry loop is killed.
773
+ const timeoutCtl = new AbortController();
774
+ const timer = setTimeout(() => timeoutCtl.abort(), 8000);
775
+ const onOuterAbort = () => timeoutCtl.abort();
776
+ signal.addEventListener("abort", onOuterAbort);
777
+ try {
778
+ return await postTranscribe(blob, language, timeoutCtl.signal);
779
+ } finally {
780
+ clearTimeout(timer);
781
+ signal.removeEventListener("abort", onOuterAbort);
782
+ }
783
+ });
784
+ if (sarvam) {
785
  const sarvamText = (sarvam.text || "").trim();
786
  if (sarvamText) {
787
  authoritativeText = sarvamText;
 
793
  } else {
794
  console.debug("[useStreamingVoice] Sarvam returned empty; using Web Speech fallback");
795
  }
796
+ } else {
797
+ console.debug("[useStreamingVoice] Sarvam failed after retries; using Web Speech fallback");
798
+ try { onVoiceErrorRef.current("transcribe_failed"); } catch { /* ignore */ }
 
799
  }
800
  }
801
 
 
853
  } catch {
854
  // ignore
855
  }
856
+ // FIX 1 (HIGH) — Unbind handlers and null the ref so any late
857
+ // onresult/onend events delivered by Chrome AFTER abort() can't
858
+ // mutate finalsRef / pendingUtteranceRef / pendingChunksRef. Without
859
+ // this, a stale recognition instance fires onend ~50-300ms after
860
+ // abort() and re-arms the grace timer on a torn-down session.
861
+ try {
862
+ rec.onresult = null;
863
+ rec.onerror = null;
864
+ rec.onend = null;
865
+ rec.onstart = null;
866
+ } catch {
867
+ // ignore — some browsers reject null assignment on EventTarget props
868
+ }
869
  }
870
+ recognitionRef.current = null;
871
  teardownAudio();
872
  finalsRef.current = [];
873
  finalsConsumedRef.current = 0;
874
+ // FIX 6 (HIGH) — Mid-utterance toggle-off flush. If the user finishes
875
+ // a complete sentence and toggles voice off within the 1.5s grace
876
+ // window, submit the pending utterance instead of silently dropping
877
+ // it. Only flush when no text request is racing; otherwise dropping
878
+ // is safer than colliding with an in-flight turn.
879
+ const finalPending = pendingUtteranceRef.current.trim();
880
+ if (finalPending && !isTextRequestPendingRef.current) {
881
+ console.debug("[useStreamingVoice] KI-222 flushing pending on stop", { len: finalPending.length });
882
+ try {
883
+ onFinalRef.current(finalPending);
884
+ } catch {
885
+ // never let a callback throw break stop()
886
+ }
887
+ }
888
  // KI-202 — drop any pending utterance so toggling voice off mid-grace
889
  // doesn't auto-submit a stale half-sentence next time voice comes on.
890
  if (pendingSubmitTimerRef.current !== null) {
 
894
  pendingUtteranceRef.current = "";
895
  pendingChunksRef.current = [];
896
  onListeningRef.current(false);
897
+ }, [clearRestartTimer, teardownAudio, isTextRequestPendingRef]);
898
 
899
  // Drive start/stop from the `enabled` prop so the hook is fire-and-forget
900
  // for the caller (mirrors useLiveConversation's `live` state semantics).
 
982
  const calibratedVolumes = new Map<HTMLAudioElement, number>();
983
  let userRmsRafId: number | null = null;
984
  let volumeCalibIntervalId: ReturnType<typeof setInterval> | null = null;
985
+ // FIX 5 (HIGH) — wall-clock decay interval. The rAF-driven userRmsTick
986
+ // is gated on `!isTtsPlaying`, so during bot TTS playback there is NO
987
+ // decay of userSpeechRms — a shout right before the bot starts speaking
988
+ // would pin userSpeechRms at 0.4 for the entire bot turn. This setInterval
989
+ // runs unconditionally while `enabled` is true, so the rolling peak
990
+ // decays toward USER_SPEECH_RMS_INITIAL on a wall-clock schedule that's
991
+ // independent of the rAF gate.
992
+ let userRmsWallClockIntervalId: ReturnType<typeof setInterval> | null = null;
993
 
994
  const sampleUserRms = (): number => {
995
  if (!analyser || !rmsBuf) return 0;
 
1025
  // doesn't permanently raise the baseline.
1026
  if (rms > USER_SPEECH_DETECTION_THRESHOLD) {
1027
  userSpeechRms = Math.max(userSpeechRms * 0.95, rms);
1028
+ // FIX 5 (HIGH) — clamp to ceiling so a single shout cannot pin
1029
+ // userSpeechRms permanently high and break subsequent barge-in.
1030
+ userSpeechRms = Math.min(userSpeechRms, USER_SPEECH_RMS_CEILING);
1031
  }
1032
  userRmsRafId = requestAnimationFrame(userRmsTick);
1033
  };
 
1047
  }
1048
  };
1049
 
1050
+ // FIX 5 (HIGH) — wall-clock decay. Runs every USER_SPEECH_RMS_WALL_CLOCK_DECAY_MS
1051
+ // regardless of TTS state so the rolling peak can't get permanently
1052
+ // pinned high during long TTS turns. Floors at USER_SPEECH_RMS_INITIAL
1053
+ // so we don't decay below the calibrated baseline.
1054
+ const startUserRmsWallClockDecay = () => {
1055
+ if (userRmsWallClockIntervalId !== null) return;
1056
+ userRmsWallClockIntervalId = setInterval(() => {
1057
+ userSpeechRms = Math.max(
1058
+ USER_SPEECH_RMS_INITIAL,
1059
+ userSpeechRms * USER_SPEECH_RMS_WALL_CLOCK_DECAY_FACTOR,
1060
+ );
1061
+ }, USER_SPEECH_RMS_WALL_CLOCK_DECAY_MS);
1062
+ };
1063
+
1064
+ const stopUserRmsWallClockDecay = () => {
1065
+ if (userRmsWallClockIntervalId !== null) {
1066
+ clearInterval(userRmsWallClockIntervalId);
1067
+ userRmsWallClockIntervalId = null;
1068
+ }
1069
+ };
1070
+
1071
  // KI-195 — volume calibration tick. Runs during TTS. Samples bot RMS
1072
  // at the mic via botAnalysers. If bot is louder than target relative
1073
  // to userSpeechRms, duck el.volume by 20% per tick down to the floor.
 
1204
  frames: sustainedFrames,
1205
  threshold: BARGE_IN_RMS_THRESHOLD,
1206
  });
1207
+ // KI-227 (2026-05-15) — V6.7. Flush any pending utterance that
1208
+ // accumulated during the bot's TTS window BEFORE the barge-in fires.
1209
+ // The grace-window timer (UTTERANCE_GRACE_MS) holds the user's
1210
+ // utterance for up to 1.5s waiting for more bursts — if the user
1211
+ // barges in over the bot before that timer fires, the pending text
1212
+ // would otherwise sit silently until the timer expires. Deliver it
1213
+ // now so page.tsx submits the user's actual question instead of
1214
+ // letting it die on the floor while a fresh recognition starts.
1215
+ try {
1216
+ const flushText = pendingUtteranceRef.current.trim();
1217
+ if (flushText && !isTextRequestPendingRef.current) {
1218
+ console.debug("[useStreamingVoice] V6.7 flushing pending utterance on barge-in", {
1219
+ len: flushText.length,
1220
+ });
1221
+ pendingUtteranceRef.current = "";
1222
+ pendingChunksRef.current = [];
1223
+ finalsRef.current = [];
1224
+ finalsConsumedRef.current = 0;
1225
+ if (pendingSubmitTimerRef.current !== null) {
1226
+ clearTimeout(pendingSubmitTimerRef.current);
1227
+ pendingSubmitTimerRef.current = null;
1228
+ }
1229
+ onFinalRef.current(flushText);
1230
+ }
1231
+ } catch (err) {
1232
+ // Never let the flush throw break the barge-in pipeline.
1233
+ console.debug("[useStreamingVoice] V6.7 pending flush threw", err);
1234
+ }
1235
+ // FIX 3 (HIGH) — flip the barge-in signal so the caller (page.tsx)
1236
+ // can abort the in-flight /api/chat request that's still assembling
1237
+ // more TTS audio. Without this, pausing the currently-mounted
1238
+ // <audio> elements only stops THIS chunk; the next TTS chunk that
1239
+ // arrives mounts a new <audio>, fires play, and the bot resumes
1240
+ // talking after the user has already interrupted.
1241
+ bargeInRequestedRef.current = true;
1242
  // Pause + reset every TTS <audio>; the MutationObserver's pause
1243
  // listener will set isTtsPlayingRef = false and call safeStart().
1244
  ttsAudioElementsRef.current.forEach((el) => {
 
1268
  }
1269
  analyser.getFloatTimeDomainData(rmsBuf);
1270
  let sumSq = 0;
1271
+ // FIX 4 (HIGH) — compute zero-crossing rate alongside RMS. Speech
1272
+ // ZCR sits in a specific band; keyboard typing has very high ZCR
1273
+ // (transients), HVAC / room rumble has very low ZCR (DC-like).
1274
+ // Rejecting frames outside the speech band cuts false-positive
1275
+ // barge-ins from typing and ambient noise.
1276
+ let zeroCrossings = 0;
1277
+ let prevSign = rmsBuf[0] >= 0 ? 1 : -1;
1278
  for (let i = 0; i < rmsBuf.length; i++) {
1279
  const v = rmsBuf[i];
1280
  sumSq += v * v;
1281
+ if (i > 0) {
1282
+ const sign = v >= 0 ? 1 : -1;
1283
+ if (sign !== prevSign) zeroCrossings += 1;
1284
+ prevSign = sign;
1285
+ }
1286
  }
1287
  const rms = Math.sqrt(sumSq / rmsBuf.length);
1288
+ // KI-228 (2026-05-15) — V6.8. Feed every frame into the adaptive
1289
+ // noise-floor estimator. It only updates the EMA when the frame is
1290
+ // below the CURRENT threshold (i.e. the frame looks like silence),
1291
+ // so speech bursts can't pollute the room baseline.
1292
+ noiseFloorRef.current.feed(rms);
1293
+ const noiseAdaptiveThreshold = noiseFloorRef.current.currentThreshold();
1294
  // KI-190 — adaptive threshold: bot_rms * 2 + 0.005, floored at the
1295
  // base BARGE_IN_RMS_THRESHOLD so we never set it absurdly low.
1296
+ // KI-228 (2026-05-15) — V6.8. ALSO floor at the noise-floor adaptive
1297
+ // threshold so a noisy room (HVAC, café) doesn't cause false-positive
1298
+ // barge-ins on the original static 0.008 threshold.
1299
  const botRms = computeBotRms();
1300
  const adaptiveThreshold = Math.max(
1301
  BARGE_IN_RMS_THRESHOLD,
1302
+ noiseAdaptiveThreshold,
1303
  botRms * BARGE_IN_BOT_RMS_MULTIPLIER + BARGE_IN_BASE_THRESHOLD,
1304
  );
1305
+ // FIX 4 / KI-225 (V1.3) — speech ZCR band scaled to the actual
1306
+ // AudioContext sampleRate. At 48 kHz that's the original 20..250;
1307
+ // at 16 kHz it's ~7..83.
1308
+ const band = zcrBandRef.current;
1309
+ const isSpeechBand = zeroCrossings >= band.min && zeroCrossings <= band.max;
1310
+ if (rms >= adaptiveThreshold && isSpeechBand) {
1311
  sustainedFrames += 1;
1312
  if (sustainedFrames >= BARGE_IN_SUSTAINED_FRAMES) {
1313
  triggerBargeIn(rms);
 
1338
  audioCtx = new Ctor();
1339
  }
1340
  if (audioCtx.state === "suspended") {
1341
+ // KI-223 (2026-05-15) — V1.1. Best-effort resume; if it rejects
1342
+ // (Chrome's autoplay policy requires a user gesture), surface a
1343
+ // structured error so the UI can prompt the user to tap. Without
1344
+ // this, the VAD silently never fires and barge-in appears broken
1345
+ // for the entire session.
1346
+ void audioCtx.resume().catch((err) => {
1347
+ console.debug("[useStreamingVoice] V1.1 AudioContext.resume failed", err);
1348
+ try { onVoiceErrorRef.current("audio_context_suspended"); } catch { /* ignore */ }
1349
+ });
1350
  }
1351
  if (!analyser || attachedStream !== stream) {
1352
  try { sourceNode?.disconnect(); } catch { /* ignore */ }
 
1358
  sourceNode.connect(analyser);
1359
  attachedStream = stream;
1360
  rmsBuf = new Float32Array(new ArrayBuffer(analyser.fftSize * 4));
1361
+ // KI-225 (2026-05-15) — V1.3. Compare the AudioContext's actual
1362
+ // sampleRate against the track's reported rate. If they disagree,
1363
+ // log a warning AND rescale the speech ZCR band so the VAD math
1364
+ // keeps meaning at 16 kHz / 24 kHz consumer mics (the static
1365
+ // 20..250 band from KI-189 was calibrated for 48 kHz).
1366
+ try {
1367
+ const trackRate = stream.getAudioTracks()[0]?.getSettings?.().sampleRate;
1368
+ const ctxRate = audioCtx.sampleRate;
1369
+ if (trackRate && Math.abs(trackRate - ctxRate) > 100) {
1370
+ console.debug(
1371
+ "[useStreamingVoice] V1.3 sample-rate mismatch",
1372
+ { trackRate, ctxRate },
1373
+ );
1374
+ }
1375
+ zcrBandRef.current = scaleSpeechZcrBand(ctxRate);
1376
+ } catch {
1377
+ // Older browsers without MediaTrackSettings.sampleRate — keep
1378
+ // the reference band.
1379
+ zcrBandRef.current = scaleSpeechZcrBand(audioCtx.sampleRate);
1380
+ }
1381
  }
1382
  sustainedFrames = 0;
1383
  if (rafId !== null) cancelAnimationFrame(rafId);
 
1531
  // when conditions aren't met (no analyser / no stream / in TTS), so
1532
  // firing it unconditionally here is safe.
1533
  startUserRmsLoop();
1534
+ // FIX 5 (HIGH) — start the wall-clock decay so userSpeechRms never
1535
+ // gets permanently pinned high (even during TTS playback when the
1536
+ // rAF loop is gated off).
1537
+ startUserRmsWallClockDecay();
1538
 
1539
  return () => {
1540
  // KI-195 — tear down adaptive volume calibration before clearing
1541
  // ducked-audio state so the calibration tick can't race a clear().
1542
  stopUserRmsLoop();
1543
+ // FIX 5 (HIGH) — clean up the wall-clock decay interval.
1544
+ stopUserRmsWallClockDecay();
1545
  stopVolumeCalibration();
1546
  calibratedVolumes.clear();
1547
  observer.disconnect();
 
1633
  };
1634
  }, [clearRestartTimer, teardownAudio]);
1635
 
1636
+ // FIX 3 (HIGH) one-shot read-and-clear of the barge-in flag. Returns
1637
+ // true exactly once after triggerBargeIn fires; subsequent calls return
1638
+ // false until the next barge-in event.
1639
+ const consumeBargeInSignal = useCallback((): boolean => {
1640
+ if (bargeInRequestedRef.current) {
1641
+ bargeInRequestedRef.current = false;
1642
+ return true;
1643
+ }
1644
+ return false;
1645
+ }, []);
1646
+
1647
+ return { start, stop, isSupported, consumeBargeInSignal };
1648
  }
frontend/src/lib/voice_resilience.ts ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * voice_resilience — KI-223..228 (2026-05-15).
3
+ *
4
+ * Companion module to useStreamingVoice. Holds pure helpers + small classes
5
+ * that don't need to live inside the hook's body. Keeping them out of the
6
+ * hook keeps the giant useStreamingVoice file readable, and makes the
7
+ * resilience logic independently unit-testable.
8
+ *
9
+ * Contents
10
+ * -------------------------------------------------------------------------
11
+ * - retryPostTranscribe — exponential-backoff wrapper around the
12
+ * Sarvam STT POST so a transient network
13
+ * blip / cold start / 502 doesn't drop the
14
+ * user's utterance (V5.4).
15
+ * - scaleSpeechZcrBand — derives the speech-band ZCR window for a
16
+ * given AudioContext sampleRate so the
17
+ * fftSize=2048 VAD math from KI-189 keeps
18
+ * meaning when the device delivers 16/24
19
+ * kHz instead of 48 kHz (V1.3).
20
+ * - AdaptiveNoiseFloor — rolling EMA of "silent" RMS frames. Used
21
+ * by the barge-in VAD to set a speech
22
+ * threshold that adapts to the actual room
23
+ * (quiet office vs. coffee shop). Replaces
24
+ * the static BARGE_IN_RMS_THRESHOLD on the
25
+ * noise-side; the bot-RMS adaptive piece
26
+ * from KI-190 still rides on top (V6.8).
27
+ * - VoiceError — string-union of new error states the hook
28
+ * can surface to page.tsx so the UI can
29
+ * prompt the user to interact (resume
30
+ * suspended AudioContext) etc. (V1.1).
31
+ */
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // V1.1 — AudioContext suspended / V1.2 — worklet failure error states.
35
+ // We don't use AudioWorklet in this hook (the Web Speech API replaced the
36
+ // custom PCM worklet path), but the type stays here so a future re-add
37
+ // has a slot.
38
+ // ---------------------------------------------------------------------------
39
+ export type VoiceError =
40
+ | "audio_context_suspended"
41
+ | "worklet_failed"
42
+ | "stream_stale"
43
+ | "transcribe_failed";
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // V5.4 — exponential-backoff transcribe retry.
47
+ // ---------------------------------------------------------------------------
48
+ export interface RetryOptions {
49
+ maxAttempts?: number;
50
+ baseDelayMs?: number;
51
+ signal?: AbortSignal;
52
+ }
53
+
54
+ /**
55
+ * Wraps an async transcribe call with up to `maxAttempts` retries on
56
+ * network errors. Backs off 1s → 2s → 4s. Aborts propagate immediately
57
+ * (we don't retry a user-initiated abort).
58
+ *
59
+ * The caller passes a thunk that performs the actual POST. The thunk MUST
60
+ * accept its own AbortSignal so each attempt can be individually timed
61
+ * out — we wire one in via the per-attempt controller, while still
62
+ * honouring the outer `opts.signal` so a global cancel kills everything.
63
+ *
64
+ * Returns null when all attempts are exhausted (so the hook can fall back
65
+ * to the Web Speech transcript instead of crashing the utterance).
66
+ */
67
+ export async function retryPostTranscribe<T>(
68
+ thunk: (signal: AbortSignal) => Promise<T>,
69
+ opts: RetryOptions = {},
70
+ ): Promise<T | null> {
71
+ const maxAttempts = opts.maxAttempts ?? 3;
72
+ const baseDelayMs = opts.baseDelayMs ?? 1000;
73
+ const outerSignal = opts.signal;
74
+
75
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
76
+ if (outerSignal?.aborted) return null;
77
+ const perAttempt = new AbortController();
78
+ const onOuterAbort = () => perAttempt.abort();
79
+ if (outerSignal) outerSignal.addEventListener("abort", onOuterAbort);
80
+ try {
81
+ const result = await thunk(perAttempt.signal);
82
+ if (outerSignal) outerSignal.removeEventListener("abort", onOuterAbort);
83
+ return result;
84
+ } catch (err) {
85
+ if (outerSignal) outerSignal.removeEventListener("abort", onOuterAbort);
86
+ // User-initiated abort — don't retry.
87
+ if (outerSignal?.aborted) return null;
88
+ // Last attempt — surface null so caller can fall back.
89
+ if (attempt === maxAttempts) {
90
+ console.debug("[voice_resilience] retryPostTranscribe exhausted", {
91
+ attempt,
92
+ err: (err as Error)?.message,
93
+ });
94
+ return null;
95
+ }
96
+ const delay = baseDelayMs * Math.pow(2, attempt - 1);
97
+ console.debug("[voice_resilience] retryPostTranscribe attempt failed, backing off", {
98
+ attempt,
99
+ nextDelayMs: delay,
100
+ err: (err as Error)?.message,
101
+ });
102
+ await new Promise<void>((resolve, reject) => {
103
+ const t = setTimeout(resolve, delay);
104
+ if (outerSignal) {
105
+ outerSignal.addEventListener("abort", () => {
106
+ clearTimeout(t);
107
+ reject(new Error("aborted"));
108
+ }, { once: true });
109
+ }
110
+ }).catch(() => { /* outer abort — fall out of loop */ });
111
+ if (outerSignal?.aborted) return null;
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // V1.3 — sample-rate-aware ZCR band.
119
+ // The original VAD assumes fftSize=2048 @ 48 kHz, where speech ZCR sits in
120
+ // ~20..250 zero crossings per buffer. At 16 kHz the same 2048-sample window
121
+ // covers 3x as long in time → ZCR counts scale by sampleRate/48000.
122
+ //
123
+ // We expose a helper so the hook can compute the band at AudioContext init.
124
+ // ---------------------------------------------------------------------------
125
+ const REFERENCE_SAMPLE_RATE = 48000;
126
+ const REFERENCE_ZCR_MIN = 20;
127
+ const REFERENCE_ZCR_MAX = 250;
128
+
129
+ export function scaleSpeechZcrBand(actualSampleRate: number): { min: number; max: number } {
130
+ if (!actualSampleRate || actualSampleRate <= 0) {
131
+ return { min: REFERENCE_ZCR_MIN, max: REFERENCE_ZCR_MAX };
132
+ }
133
+ // ZCR scales linearly with the time-window length per buffer at fixed
134
+ // fftSize, which scales inversely with sampleRate. So a SHORTER window
135
+ // (higher rate) sees PROPORTIONALLY fewer crossings — but the per-second
136
+ // speech crossing rate is roughly constant. Net: the per-buffer count
137
+ // scales linearly with sampleRate.
138
+ const ratio = actualSampleRate / REFERENCE_SAMPLE_RATE;
139
+ return {
140
+ min: Math.max(1, Math.round(REFERENCE_ZCR_MIN * ratio)),
141
+ max: Math.max(REFERENCE_ZCR_MIN + 1, Math.round(REFERENCE_ZCR_MAX * ratio)),
142
+ };
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // V6.8 — adaptive noise-floor estimator.
147
+ // Maintains a 5-second EMA of "silent" RMS values. The hook samples this
148
+ // every VAD frame; when RMS is below the current speech threshold we treat
149
+ // the frame as silent and feed it into the EMA. The current speech
150
+ // threshold is `noiseFloor * 4 + 0.005`, clamped to [0.02, 0.15].
151
+ //
152
+ // Recompute cadence: caller decides. We expose a `currentThreshold()` getter
153
+ // + a `feed(rms)` setter. The hook will call feed() every frame and read
154
+ // the threshold whenever it needs to compare. Both are O(1).
155
+ // ---------------------------------------------------------------------------
156
+ const NOISE_EMA_WINDOW_SECONDS = 5;
157
+ const NOISE_EMA_ASSUMED_FPS = 60; // rAF default
158
+ const NOISE_EMA_ALPHA = 1 / (NOISE_EMA_WINDOW_SECONDS * NOISE_EMA_ASSUMED_FPS);
159
+ const NOISE_THRESHOLD_MULTIPLIER = 4;
160
+ const NOISE_THRESHOLD_BASE = 0.005;
161
+ const NOISE_THRESHOLD_MIN = 0.02;
162
+ const NOISE_THRESHOLD_MAX = 0.15;
163
+
164
+ export class AdaptiveNoiseFloor {
165
+ private ema: number;
166
+ // Track the "current threshold" inline so currentThreshold() stays O(1)
167
+ // without re-running the clamp each call.
168
+ private threshold: number;
169
+
170
+ constructor(initialEma: number = 0.005) {
171
+ this.ema = initialEma;
172
+ this.threshold = this.computeThreshold(this.ema);
173
+ }
174
+
175
+ /** Feed every VAD-frame RMS. We update the EMA only when the frame is
176
+ * below the CURRENT threshold (i.e. it looks like silence). This keeps
177
+ * speech bursts from polluting the noise floor. */
178
+ feed(rms: number): void {
179
+ if (rms < this.threshold) {
180
+ this.ema = (1 - NOISE_EMA_ALPHA) * this.ema + NOISE_EMA_ALPHA * rms;
181
+ this.threshold = this.computeThreshold(this.ema);
182
+ }
183
+ }
184
+
185
+ /** Force-reseed (used on session start). */
186
+ reset(initialEma: number = 0.005): void {
187
+ this.ema = initialEma;
188
+ this.threshold = this.computeThreshold(this.ema);
189
+ }
190
+
191
+ currentThreshold(): number {
192
+ return this.threshold;
193
+ }
194
+
195
+ currentNoiseFloor(): number {
196
+ return this.ema;
197
+ }
198
+
199
+ private computeThreshold(ema: number): number {
200
+ const raw = ema * NOISE_THRESHOLD_MULTIPLIER + NOISE_THRESHOLD_BASE;
201
+ if (raw < NOISE_THRESHOLD_MIN) return NOISE_THRESHOLD_MIN;
202
+ if (raw > NOISE_THRESHOLD_MAX) return NOISE_THRESHOLD_MAX;
203
+ return raw;
204
+ }
205
+ }