Spaces:
Sleeping
fix(arch+robustness): KI-225..KI-235 — Path B single-LLM rewrite + audit bundle
Browse filesArchitectural 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 +182 -0
- backend/brain_tools.py +398 -0
- backend/faithfulness.py +259 -0
- backend/llm_health.py +115 -4
- backend/main.py +56 -11
- backend/orchestrator.py +262 -8
- backend/providers/google_gemini_llm.py +179 -21
- backend/providers/nvidia_nim_llm.py +138 -10
- backend/providers/openrouter_llm.py +71 -0
- backend/providers/tiered_brain_llm.py +1 -1
- backend/retrieval_filters.py +422 -0
- backend/sales_brain_normalizer.py +185 -17
- backend/session_state.py +6 -0
- backend/single_brain.py +649 -0
- frontend/public/admin/llm-control.html +439 -1
- frontend/src/app/layout.tsx +11 -1
- frontend/src/app/page.tsx +343 -26
- frontend/src/lib/api.ts +14 -1
- frontend/src/lib/useStreamingVoice.ts +299 -15
- frontend/src/lib/voice_resilience.ts +205 -0
|
@@ -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 |
+
}
|
|
@@ -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 |
+
]
|
|
@@ -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:
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1045 |
summary["models"].append({
|
| 1046 |
"model": m,
|
| 1047 |
-
"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,
|
|
@@ -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 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 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)",
|
|
@@ -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 |
)
|
|
@@ -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 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
"""
|
| 87 |
-
|
|
|
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 188 |
-
#
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 302 |
write=2.0,
|
| 303 |
pool=2.0,
|
| 304 |
)
|
| 305 |
|
| 306 |
async with httpx.AsyncClient(timeout=client_timeout) as client:
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
if resp.status_code >= 400:
|
| 336 |
-
#
|
| 337 |
-
#
|
| 338 |
-
#
|
|
|
|
|
|
|
|
|
|
| 339 |
detail = ""
|
| 340 |
try:
|
| 341 |
detail = resp.text[:500]
|
| 342 |
except Exception:
|
| 343 |
pass
|
| 344 |
-
|
| 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 |
]
|
|
@@ -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 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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', '
|
| 444 |
-
'minimax', 'nvidia', '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
|
|
@@ -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,
|
|
@@ -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 =
|
| 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}")
|
|
@@ -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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
if any(k in s for k in (
|
| 488 |
-
|
| 489 |
-
|
|
|
|
| 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 |
-
|
| 581 |
-
"
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
"
|
| 585 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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))
|
|
@@ -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
|
|
@@ -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 |
+
]
|
|
@@ -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;
|
|
@@ -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 }>) {
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
const display = (final + interim).trim();
|
| 587 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 934 |
<div className="flex items-end gap-2">
|
| 935 |
<textarea
|
| 936 |
value={input}
|
| 937 |
-
onChange={(e) =>
|
| 938 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1653 |
-
|
|
|
|
|
|
|
| 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">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
|
@@ -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
|
| 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,
|
|
@@ -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 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
}
|
| 677 |
-
console.debug("[useStreamingVoice] Sarvam failed; using Web Speech fallback"
|
| 678 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
| 1101 |
-
//
|
| 1102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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 |
+
}
|