Spaces:
Sleeping
fix: 4 hardening fixes — sticky-retry, canned-msg copy, admin refresh wiring, recall privacy
Browse files1) sticky-retry hardening (single_brain.py / KI-singlebrain-503 extension)
_gemini_call: gain `is_sticky` kwarg; non-sticky session keeps the
1-retry + 1.5s schedule (fast-fail → nim_fallback on cold-start 503);
STICKY session now gets 2 retries with jittered exp backoffs
(1.5s → 3s, ±25%). handle_turn reads session.single_brain_sticky once
and threads it. Soaks up ~6-10s Gemini "high demand" 503 storms that
previously surfaced as the sticky_graceful_retry canned reply.
2) canned-message copy (main.py)
"Sorry, I'm having trouble — could you say that again?" wrongly blamed
STT/comprehension when the cause was an upstream Gemini transient.
New text: "My model service had a brief blip on that turn — please
send the same message again, it should go through now." Plain register
per feedback_no_ai_sounding_register, locates blame correctly, tells
the user exactly what to do.
3) admin LLM Chain refresh wiring (frontend/public/admin/llm-control.html, KI-296)
The top-left "Last refresh / Next in" timer (renderUpdatedLabel reads
STATE.health.updated_at) was a frozen client-side projection — STATE.health
was populated ONCE at login (/api/admin/health) and never re-fetched.
`Refresh now` POSTed /api/admin/probe + GET /api/admin/llm-health but
skipped /api/admin/health, so the operator saw the table refresh (and
the bottom-right FRESH badge update) while the top-left timer stayed
stuck — user (correctly) suspected the actual probing wasn't happening.
Pattern-extrapolated fix: Refresh-now click + 30s auto-poll + tab-entry
refreshChain + tab re-entry partial refresh all now also call fetchHealth()
and renderUpdatedLabel(). 4 sites updated in lockstep.
4) recall privacy (single_brain.py recall_block + session_state.apply_pending_recall)
The Welcome-Back prompt previously echoed staged attrs into the
user-visible text — "are you the same Rohit who spoke with us before
(age 34, metro, kids, first_buy)?" That disclosed a prior visitor's
identity facts to ANY stranger who happens to share the name slug
(recall key is name-only; all Rohits collide on rohit.json). New
prompt asks the user to RE-STATE one identifying fact (age) instead.
apply_pending_recall now runs a match-before-merge contradiction
guard: if any live decision-critical slot (age / dependents /
income_band / location_tier / primary_goal / parents_age_max)
contradicts the staged recall, discard the whole staged recall and
merge nothing — so a wrong "yes" plus a contradicting fact can't
inherit prior attrs.
Compile + node --check pass. Live audit and docs/memory update follow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/main.py +10 -2
- backend/session_state.py +35 -3
- backend/single_brain.py +105 -46
- frontend/public/admin/llm-control.html +37 -5
|
@@ -975,8 +975,16 @@ async def chat(req: ChatRequest, request: Request):
|
|
| 975 |
)
|
| 976 |
turn = single_brain.TurnResult(
|
| 977 |
reply_text=(
|
| 978 |
-
|
| 979 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 980 |
),
|
| 981 |
citations=[],
|
| 982 |
retrieved_chunk_ids=[],
|
|
|
|
| 975 |
)
|
| 976 |
turn = single_brain.TurnResult(
|
| 977 |
reply_text=(
|
| 978 |
+
# 2026-05-27 — honest copy. Previous text falsely
|
| 979 |
+
# blamed comprehension ("could you say that
|
| 980 |
+
# again?") when the actual cause was an upstream
|
| 981 |
+
# Gemini transient (HTTP 503 / timeout / etc.)
|
| 982 |
+
# that survived the internal retry schedule.
|
| 983 |
+
# Tells the user exactly what to do (resend the
|
| 984 |
+
# same message) and locates blame correctly.
|
| 985 |
+
"My model service had a brief blip on that turn "
|
| 986 |
+
"— please send the same message again, it should "
|
| 987 |
+
"go through now."
|
| 988 |
),
|
| 989 |
citations=[],
|
| 990 |
retrieved_chunk_ids=[],
|
|
@@ -240,10 +240,20 @@ def apply_pending_recall(session: SessionState, *, confirmed: bool) -> bool:
|
|
| 240 |
profile into a live session. Called after the user explicitly answers
|
| 241 |
the "are you <name>?" confirm prompt.
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
confirmed=True — the user affirmed it's them. Stored fields fill any
|
| 244 |
-
EMPTY slot on the live profile
|
| 245 |
-
|
| 246 |
-
|
|
|
|
| 247 |
confirmed=False — the user denied / it isn't them. The staged recall is
|
| 248 |
discarded. The live session stays blank. Returns False.
|
| 249 |
|
|
@@ -263,6 +273,28 @@ def apply_pending_recall(session: SessionState, *, confirmed: bool) -> bool:
|
|
| 263 |
stored_fields = pending.get("stored_fields") or {}
|
| 264 |
if not stored_fields:
|
| 265 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
for fld, new in stored_fields.items():
|
| 267 |
try:
|
| 268 |
if fld not in Profile.__dataclass_fields__:
|
|
|
|
| 240 |
profile into a live session. Called after the user explicitly answers
|
| 241 |
the "are you <name>?" confirm prompt.
|
| 242 |
|
| 243 |
+
PRIVACY HARDENING (2026-05-27). The brain prompt no longer discloses
|
| 244 |
+
staged attrs (single_brain.py recall_block). To compensate for the
|
| 245 |
+
accidental / mistaken "yes" path, we now run a **match-before-merge**
|
| 246 |
+
contradiction check: if the live profile has captured any identity
|
| 247 |
+
fact in this conversation that CONTRADICTS the staged recall (e.g.
|
| 248 |
+
user said age=29 but staged.age=34), we discard the staged recall
|
| 249 |
+
entirely — no partial merge. Empty live slots (no contradiction
|
| 250 |
+
possible) are still filled from staged on confirmed=True.
|
| 251 |
+
|
| 252 |
confirmed=True — the user affirmed it's them. Stored fields fill any
|
| 253 |
+
EMPTY slot on the live profile — UNLESS any live slot
|
| 254 |
+
contradicts the staged value (then the whole staged
|
| 255 |
+
recall is dropped). Returns True iff a profile was
|
| 256 |
+
applied.
|
| 257 |
confirmed=False — the user denied / it isn't them. The staged recall is
|
| 258 |
discarded. The live session stays blank. Returns False.
|
| 259 |
|
|
|
|
| 273 |
stored_fields = pending.get("stored_fields") or {}
|
| 274 |
if not stored_fields:
|
| 275 |
return False
|
| 276 |
+
# PRIVACY HARDENING (2026-05-27) — match-before-merge guard.
|
| 277 |
+
# Fields chosen here are decision-critical identity facts where a
|
| 278 |
+
# mismatch unambiguously means "different person" (vs. e.g.
|
| 279 |
+
# health_conditions which evolves between visits).
|
| 280 |
+
_GUARD_FIELDS = (
|
| 281 |
+
"age", "dependents", "income_band", "location_tier",
|
| 282 |
+
"primary_goal", "parents_age_max",
|
| 283 |
+
)
|
| 284 |
+
for fld in _GUARD_FIELDS:
|
| 285 |
+
live_v = getattr(session.profile, fld, None)
|
| 286 |
+
staged_v = stored_fields.get(fld)
|
| 287 |
+
if live_v in (None, "", []) or staged_v in (None, "", []):
|
| 288 |
+
continue
|
| 289 |
+
# Normalise to compare. Int and string forms of age both common.
|
| 290 |
+
if str(live_v).strip().lower() != str(staged_v).strip().lower():
|
| 291 |
+
_log.info(
|
| 292 |
+
"apply_pending_recall: contradiction on %s "
|
| 293 |
+
"(live=%r, staged=%r) — discarding staged recall, "
|
| 294 |
+
"no merge",
|
| 295 |
+
fld, live_v, staged_v,
|
| 296 |
+
)
|
| 297 |
+
return False
|
| 298 |
for fld, new in stored_fields.items():
|
| 299 |
try:
|
| 300 |
if fld not in Profile.__dataclass_fields__:
|
|
@@ -27,6 +27,7 @@ import asyncio
|
|
| 27 |
import json
|
| 28 |
import logging
|
| 29 |
import os
|
|
|
|
| 30 |
import time
|
| 31 |
from dataclasses import dataclass, field
|
| 32 |
from typing import Any, Optional
|
|
@@ -62,15 +63,33 @@ PER_CALL_TIMEOUT_SEC = 25.0
|
|
| 62 |
# user turn.
|
| 63 |
MAX_ITERATIONS = 8
|
| 64 |
|
| 65 |
-
# Transient-error retry policy (2026-05-15 / KI-singlebrain-503
|
|
|
|
| 66 |
# Live HF Space logs (rohitsar567/InsuranceBot, 2026-05-15 08:15Z) show
|
| 67 |
# Gemini intermittently returns HTTP 503 "model is currently experiencing
|
| 68 |
-
# high demand" — sometimes 3 in a row on the same session — which
|
| 69 |
-
# tripped the orchestrator fallback
|
| 70 |
-
#
|
| 71 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
_TRANSIENT_HTTP_CODES = {429, 500, 502, 503, 504}
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
|
| 76 |
SYSTEM_PROMPT = """You are an Indian health-insurance advisor speaking with a customer.
|
|
@@ -815,30 +834,43 @@ def _system_instruction(
|
|
| 815 |
recall_block = ""
|
| 816 |
if pending_recall:
|
| 817 |
_nm = (pending_recall.get("name") or "there").strip()
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 826 |
recall_block = (
|
| 827 |
"\n\n═══════════════════════════════════\n"
|
| 828 |
"RETURNING-USER CHECK — HIGHEST PRIORITY THIS TURN "
|
| 829 |
"(overrides RULE 1 / fact-find for this one turn)\n"
|
| 830 |
"═══════════════════════════════════\n"
|
| 831 |
-
f"A stored profile
|
| 832 |
-
f"gave (\"{_nm}\").
|
|
|
|
|
|
|
|
|
|
| 833 |
"Your ENTIRE reply this turn MUST be ONLY the confirmation "
|
| 834 |
"question below. Do NOT call any tool, do NOT save_profile_field, "
|
| 835 |
"do NOT run the 7-question fact-find, do NOT recommend:\n"
|
| 836 |
-
f" \"Welcome back —
|
| 837 |
-
f"
|
| 838 |
-
f"
|
| 839 |
-
"Then wait for their
|
| 840 |
-
"
|
| 841 |
-
"
|
|
|
|
| 842 |
)
|
| 843 |
restored_block = ""
|
| 844 |
if recall_applied:
|
|
@@ -993,16 +1025,22 @@ async def _gemini_call(
|
|
| 993 |
contents: list[dict],
|
| 994 |
tools: list[dict],
|
| 995 |
timeout_sec: float,
|
|
|
|
|
|
|
| 996 |
) -> dict:
|
| 997 |
"""Single non-streaming Gemini generateContent call. Returns the raw
|
| 998 |
JSON payload. Raises SingleBrainError on any 4xx/5xx/transport error.
|
| 999 |
|
| 1000 |
-
Internal retry
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1006 |
"""
|
| 1007 |
url = f"{GEMINI_BASE_URL}/{model}:generateContent?key={api_key}"
|
| 1008 |
body: dict = {
|
|
@@ -1033,8 +1071,15 @@ async def _gemini_call(
|
|
| 1033 |
|
| 1034 |
last_err: Optional[str] = None
|
| 1035 |
last_status: Optional[int] = None
|
| 1036 |
-
#
|
| 1037 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1038 |
async with httpx.AsyncClient(timeout=client_timeout) as client:
|
| 1039 |
try:
|
| 1040 |
resp = await client.post(url, headers=headers, json=body)
|
|
@@ -1045,13 +1090,14 @@ async def _gemini_call(
|
|
| 1045 |
f"Gemini timeout after {timeout_sec:.1f}s (model={model})"
|
| 1046 |
)
|
| 1047 |
last_status = None
|
| 1048 |
-
if
|
|
|
|
| 1049 |
_log.warning(
|
| 1050 |
-
"single_brain transient timeout (attempt=
|
| 1051 |
-
"retrying
|
| 1052 |
-
|
| 1053 |
)
|
| 1054 |
-
await asyncio.sleep(
|
| 1055 |
continue
|
| 1056 |
raise SingleBrainError(last_err) from e
|
| 1057 |
except httpx.HTTPError as e:
|
|
@@ -1060,13 +1106,16 @@ async def _gemini_call(
|
|
| 1060 |
f"({type(e).__name__}): {str(e)[:200]}"
|
| 1061 |
)
|
| 1062 |
last_status = None
|
| 1063 |
-
if
|
|
|
|
| 1064 |
_log.warning(
|
| 1065 |
"single_brain transient transport error "
|
| 1066 |
-
"(attempt=
|
| 1067 |
-
|
|
|
|
|
|
|
| 1068 |
)
|
| 1069 |
-
await asyncio.sleep(
|
| 1070 |
continue
|
| 1071 |
raise SingleBrainError(last_err) from e
|
| 1072 |
|
|
@@ -1078,18 +1127,20 @@ async def _gemini_call(
|
|
| 1078 |
pass
|
| 1079 |
last_status = resp.status_code
|
| 1080 |
last_err = f"Gemini HTTP {resp.status_code}: {detail}"
|
| 1081 |
-
# Transient → retry
|
| 1082 |
-
# raise immediately; retrying won't help.
|
| 1083 |
if (
|
| 1084 |
-
|
| 1085 |
and resp.status_code in _TRANSIENT_HTTP_CODES
|
| 1086 |
):
|
|
|
|
| 1087 |
_log.warning(
|
| 1088 |
-
"single_brain transient HTTP %d (attempt=
|
| 1089 |
-
"retrying
|
| 1090 |
-
resp.status_code,
|
|
|
|
| 1091 |
)
|
| 1092 |
-
await asyncio.sleep(
|
| 1093 |
continue
|
| 1094 |
raise SingleBrainError(last_err)
|
| 1095 |
|
|
@@ -2078,6 +2129,13 @@ async def handle_turn(
|
|
| 2078 |
# tool, with no risk of an infinite loop.
|
| 2079 |
_b2_reprompted: bool = False
|
| 2080 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2081 |
for it in range(MAX_ITERATIONS):
|
| 2082 |
# Issue A instrumentation (KI-Z6-LATENCY, 2026-05-15) — Priya T3
|
| 2083 |
# timed at 18.7s vs an 8s budget. We need per-iteration breakdown
|
|
@@ -2094,6 +2152,7 @@ async def handle_turn(
|
|
| 2094 |
contents=contents,
|
| 2095 |
tools=TOOL_SCHEMAS,
|
| 2096 |
timeout_sec=PER_CALL_TIMEOUT_SEC,
|
|
|
|
| 2097 |
)
|
| 2098 |
except SingleBrainError:
|
| 2099 |
raise
|
|
|
|
| 27 |
import json
|
| 28 |
import logging
|
| 29 |
import os
|
| 30 |
+
import random
|
| 31 |
import time
|
| 32 |
from dataclasses import dataclass, field
|
| 33 |
from typing import Any, Optional
|
|
|
|
| 63 |
# user turn.
|
| 64 |
MAX_ITERATIONS = 8
|
| 65 |
|
| 66 |
+
# Transient-error retry policy (2026-05-15 / KI-singlebrain-503; extended
|
| 67 |
+
# 2026-05-27 for sticky-session hardening).
|
| 68 |
# Live HF Space logs (rohitsar567/InsuranceBot, 2026-05-15 08:15Z) show
|
| 69 |
# Gemini intermittently returns HTTP 503 "model is currently experiencing
|
| 70 |
+
# high demand" — sometimes 3 in a row on the same session — which previously
|
| 71 |
+
# tripped the orchestrator fallback after a SINGLE retry (1.5s). Backoff
|
| 72 |
+
# schedule below is sized so non-sticky sessions still fail-fast (~3s →
|
| 73 |
+
# nim_fallback) but sticky sessions (where falling back would discard
|
| 74 |
+
# last_recommendation_ids / last_retrieved_chunks / slug_to_insurer) get
|
| 75 |
+
# 2 retries with jittered exponential backoff, soaking up the ~6-10s 503
|
| 76 |
+
# storms that produced the user-visible "could you say that again?" canned
|
| 77 |
+
# reply at main.py:976. Jitter ±25% prevents synchronized retry storms
|
| 78 |
+
# across concurrent sessions.
|
| 79 |
_TRANSIENT_HTTP_CODES = {429, 500, 502, 503, 504}
|
| 80 |
+
_TRANSIENT_RETRY_BACKOFFS_NON_STICKY = (1.5,) # 1 retry
|
| 81 |
+
_TRANSIENT_RETRY_BACKOFFS_STICKY = (1.5, 3.0) # 2 retries, exp
|
| 82 |
+
_TRANSIENT_RETRY_JITTER_FRAC = 0.25 # ±25%
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _jittered_backoff(base_sec: float) -> float:
|
| 86 |
+
"""Return base_sec scaled by a uniform random factor in
|
| 87 |
+
[1 - JITTER, 1 + JITTER]. Pure helper so the retry loop stays flat.
|
| 88 |
+
"""
|
| 89 |
+
return base_sec * random.uniform(
|
| 90 |
+
1.0 - _TRANSIENT_RETRY_JITTER_FRAC,
|
| 91 |
+
1.0 + _TRANSIENT_RETRY_JITTER_FRAC,
|
| 92 |
+
)
|
| 93 |
|
| 94 |
|
| 95 |
SYSTEM_PROMPT = """You are an Indian health-insurance advisor speaking with a customer.
|
|
|
|
| 834 |
recall_block = ""
|
| 835 |
if pending_recall:
|
| 836 |
_nm = (pending_recall.get("name") or "there").strip()
|
| 837 |
+
# PRIVACY HARDENING (2026-05-27). Previously this prompt echoed the
|
| 838 |
+
# staged profile's age / dependents / location_tier / primary_goal
|
| 839 |
+
# into the user-visible "are you the same <name> (age 34, metro,
|
| 840 |
+
# kids, first_buy)?" question. That disclosed a prior visitor's
|
| 841 |
+
# identity facts to ANY stranger who happened to share the name
|
| 842 |
+
# slug — the recall key is name-only, so two different Rohits
|
| 843 |
+
# collide on rohit.json. The leak is independent of the merge
|
| 844 |
+
# gate (apply_pending_recall) because the disclosure happened in
|
| 845 |
+
# the prompt itself before any user answer.
|
| 846 |
+
#
|
| 847 |
+
# New design: do NOT disclose stored attrs. Ask the user to
|
| 848 |
+
# RE-STATE one identifying fact (their age). On the next turn,
|
| 849 |
+
# session_state.apply_pending_recall runs a match-before-merge
|
| 850 |
+
# contradiction check against whatever the user just saved via
|
| 851 |
+
# save_profile_field — a wrong "yes" plus a contradicting age
|
| 852 |
+
# discards the staged recall, so a stranger can't inherit prior
|
| 853 |
+
# attrs even by mis-clicking "yes".
|
| 854 |
recall_block = (
|
| 855 |
"\n\n═══════════════════════════════════\n"
|
| 856 |
"RETURNING-USER CHECK — HIGHEST PRIORITY THIS TURN "
|
| 857 |
"(overrides RULE 1 / fact-find for this one turn)\n"
|
| 858 |
"═══════════════════════════════════\n"
|
| 859 |
+
f"A stored profile may exist under the name the user just "
|
| 860 |
+
f"gave (\"{_nm}\"). DO NOT disclose any stored attribute "
|
| 861 |
+
"(age, dependents, location, goal, conditions, etc.) in your "
|
| 862 |
+
"reply — disclosing them to an unconfirmed identity is a "
|
| 863 |
+
"privacy leak. Stay neutral.\n"
|
| 864 |
"Your ENTIRE reply this turn MUST be ONLY the confirmation "
|
| 865 |
"question below. Do NOT call any tool, do NOT save_profile_field, "
|
| 866 |
"do NOT run the 7-question fact-find, do NOT recommend:\n"
|
| 867 |
+
f" \"Welcome back — have we spoken before? If yes, please "
|
| 868 |
+
f"share your age so I can pull up the right profile. If not, "
|
| 869 |
+
f"no problem — just say so and we'll start fresh.\"\n"
|
| 870 |
+
"Then wait for their answer on the NEXT turn. If they share "
|
| 871 |
+
"an age (or other fact), save_profile_field captures it and "
|
| 872 |
+
"the system runs a match-before-merge check against the "
|
| 873 |
+
"staged recall — you never merge anything yourself."
|
| 874 |
)
|
| 875 |
restored_block = ""
|
| 876 |
if recall_applied:
|
|
|
|
| 1025 |
contents: list[dict],
|
| 1026 |
tools: list[dict],
|
| 1027 |
timeout_sec: float,
|
| 1028 |
+
*,
|
| 1029 |
+
is_sticky: bool = False,
|
| 1030 |
) -> dict:
|
| 1031 |
"""Single non-streaming Gemini generateContent call. Returns the raw
|
| 1032 |
JSON payload. Raises SingleBrainError on any 4xx/5xx/transport error.
|
| 1033 |
|
| 1034 |
+
Internal retry policy (2026-05-15 → extended 2026-05-27):
|
| 1035 |
+
- Non-sticky session (no prior successful single_brain turn): 1 retry
|
| 1036 |
+
with a 1.5s backoff. Fast-fail to nim_fallback on sustained outage
|
| 1037 |
+
(cold-start 503 is exactly what nim_fallback exists for).
|
| 1038 |
+
- Sticky session (prior successful turn exists, brain state would be
|
| 1039 |
+
lost on cross-fade): 2 retries with jittered exponential backoffs
|
| 1040 |
+
(1.5s → 3s, ±25% jitter). Soaks up the ~6-10s Gemini "high demand"
|
| 1041 |
+
503 storms that previously surfaced as the user-visible
|
| 1042 |
+
sticky_graceful_retry canned reply at main.py:976.
|
| 1043 |
+
Transient = HTTP 429/5xx, httpx.TimeoutException, httpx.HTTPError.
|
| 1044 |
"""
|
| 1045 |
url = f"{GEMINI_BASE_URL}/{model}:generateContent?key={api_key}"
|
| 1046 |
body: dict = {
|
|
|
|
| 1071 |
|
| 1072 |
last_err: Optional[str] = None
|
| 1073 |
last_status: Optional[int] = None
|
| 1074 |
+
# Sticky path: initial + 2 retries (3 attempts). Non-sticky: initial + 1.
|
| 1075 |
+
backoffs = (
|
| 1076 |
+
_TRANSIENT_RETRY_BACKOFFS_STICKY
|
| 1077 |
+
if is_sticky
|
| 1078 |
+
else _TRANSIENT_RETRY_BACKOFFS_NON_STICKY
|
| 1079 |
+
)
|
| 1080 |
+
max_attempts = 1 + len(backoffs)
|
| 1081 |
+
for attempt in range(max_attempts):
|
| 1082 |
+
is_last_attempt = attempt == max_attempts - 1
|
| 1083 |
async with httpx.AsyncClient(timeout=client_timeout) as client:
|
| 1084 |
try:
|
| 1085 |
resp = await client.post(url, headers=headers, json=body)
|
|
|
|
| 1090 |
f"Gemini timeout after {timeout_sec:.1f}s (model={model})"
|
| 1091 |
)
|
| 1092 |
last_status = None
|
| 1093 |
+
if not is_last_attempt:
|
| 1094 |
+
_backoff = _jittered_backoff(backoffs[attempt])
|
| 1095 |
_log.warning(
|
| 1096 |
+
"single_brain transient timeout (attempt=%d/%d, "
|
| 1097 |
+
"sticky=%s); retrying after %.2fs backoff",
|
| 1098 |
+
attempt + 1, max_attempts, is_sticky, _backoff,
|
| 1099 |
)
|
| 1100 |
+
await asyncio.sleep(_backoff)
|
| 1101 |
continue
|
| 1102 |
raise SingleBrainError(last_err) from e
|
| 1103 |
except httpx.HTTPError as e:
|
|
|
|
| 1106 |
f"({type(e).__name__}): {str(e)[:200]}"
|
| 1107 |
)
|
| 1108 |
last_status = None
|
| 1109 |
+
if not is_last_attempt:
|
| 1110 |
+
_backoff = _jittered_backoff(backoffs[attempt])
|
| 1111 |
_log.warning(
|
| 1112 |
"single_brain transient transport error "
|
| 1113 |
+
"(attempt=%d/%d, sticky=%s, %s); retrying "
|
| 1114 |
+
"after %.2fs backoff",
|
| 1115 |
+
attempt + 1, max_attempts, is_sticky,
|
| 1116 |
+
type(e).__name__, _backoff,
|
| 1117 |
)
|
| 1118 |
+
await asyncio.sleep(_backoff)
|
| 1119 |
continue
|
| 1120 |
raise SingleBrainError(last_err) from e
|
| 1121 |
|
|
|
|
| 1127 |
pass
|
| 1128 |
last_status = resp.status_code
|
| 1129 |
last_err = f"Gemini HTTP {resp.status_code}: {detail}"
|
| 1130 |
+
# Transient → retry per schedule. Permanent (4xx like
|
| 1131 |
+
# 400/401/403/404) → raise immediately; retrying won't help.
|
| 1132 |
if (
|
| 1133 |
+
not is_last_attempt
|
| 1134 |
and resp.status_code in _TRANSIENT_HTTP_CODES
|
| 1135 |
):
|
| 1136 |
+
_backoff = _jittered_backoff(backoffs[attempt])
|
| 1137 |
_log.warning(
|
| 1138 |
+
"single_brain transient HTTP %d (attempt=%d/%d, "
|
| 1139 |
+
"sticky=%s); retrying after %.2fs backoff",
|
| 1140 |
+
resp.status_code, attempt + 1, max_attempts,
|
| 1141 |
+
is_sticky, _backoff,
|
| 1142 |
)
|
| 1143 |
+
await asyncio.sleep(_backoff)
|
| 1144 |
continue
|
| 1145 |
raise SingleBrainError(last_err)
|
| 1146 |
|
|
|
|
| 2129 |
# tool, with no risk of an infinite loop.
|
| 2130 |
_b2_reprompted: bool = False
|
| 2131 |
|
| 2132 |
+
# 2026-05-27 — sticky sessions get the extended retry schedule in
|
| 2133 |
+
# _gemini_call. A failed call mid-stream on a sticky session can't fall
|
| 2134 |
+
# back to nim_fallback without discarding last_recommendation_ids /
|
| 2135 |
+
# last_retrieved_chunks / slug_to_insurer (see main.py:965-992), so we
|
| 2136 |
+
# absorb transient Gemini 503 bursts more aggressively here.
|
| 2137 |
+
_is_sticky_session = bool(getattr(session, "single_brain_sticky", False))
|
| 2138 |
+
|
| 2139 |
for it in range(MAX_ITERATIONS):
|
| 2140 |
# Issue A instrumentation (KI-Z6-LATENCY, 2026-05-15) — Priya T3
|
| 2141 |
# timed at 18.7s vs an 8s budget. We need per-iteration breakdown
|
|
|
|
| 2152 |
contents=contents,
|
| 2153 |
tools=TOOL_SCHEMAS,
|
| 2154 |
timeout_sec=PER_CALL_TIMEOUT_SEC,
|
| 2155 |
+
is_sticky=_is_sticky_session,
|
| 2156 |
)
|
| 2157 |
except SingleBrainError:
|
| 2158 |
raise
|
|
@@ -1686,9 +1686,17 @@
|
|
| 1686 |
// tab. fetchHealth/fetchChains/fetchUsage targeted DOM elements that
|
| 1687 |
// no longer exist (#counters, #chains-grid, #health-tbody) so they're
|
| 1688 |
// no longer called. fetchLlmHealth + renderLlmHealth feed the 2 tables.
|
| 1689 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1690 |
renderLlmHealth();
|
| 1691 |
setLastUpdated();
|
|
|
|
| 1692 |
STATE.chainLoaded = true;
|
| 1693 |
});
|
| 1694 |
}
|
|
@@ -2274,12 +2282,20 @@
|
|
| 2274 |
// faster than that because credit + degraded-window state changes
|
| 2275 |
// sub-tick (chat hot path stamps both) and the operator needs to see
|
| 2276 |
// those without manually clicking Refresh.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2277 |
var LLM_HEALTH_POLL_INTERVAL_MS = 30000;
|
| 2278 |
function startLlmHealthPolling() {
|
| 2279 |
stopLlmHealthPolling();
|
| 2280 |
STATE.llmHealthPollTimer = setInterval(function () {
|
| 2281 |
if (STATE.activeTab !== 'chain') return;
|
| 2282 |
-
|
|
|
|
|
|
|
| 2283 |
}, LLM_HEALTH_POLL_INTERVAL_MS);
|
| 2284 |
}
|
| 2285 |
function stopLlmHealthPolling() {
|
|
@@ -3120,7 +3136,11 @@
|
|
| 3120 |
} else {
|
| 3121 |
// Tab already loaded — refresh just the KI-086 panel on re-entry
|
| 3122 |
// so the operator immediately sees fresh credits / sin-bin state.
|
| 3123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3124 |
}
|
| 3125 |
startLlmHealthPolling();
|
| 3126 |
}
|
|
@@ -3283,6 +3303,14 @@
|
|
| 3283 |
// state from /api/admin/llm-health, which is refreshed by a 5-min
|
| 3284 |
// background loop. User clicked Refresh expecting current state but
|
| 3285 |
// got cached state. Now: POST /api/admin/probe → then re-fetch.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3286 |
var llmHealthBtn = $('btn-refresh-llm-health');
|
| 3287 |
if (llmHealthBtn) {
|
| 3288 |
llmHealthBtn.onclick = function () {
|
|
@@ -3291,8 +3319,12 @@
|
|
| 3291 |
btn.textContent = 'Refreshing…';
|
| 3292 |
apiPost('/api/admin/probe', null)
|
| 3293 |
.catch(function () { /* tolerate probe failure; still re-fetch cached state */ })
|
| 3294 |
-
.then(function () { return fetchLlmHealth(); })
|
| 3295 |
-
.then(function () {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3296 |
.catch(handleFetchErr)
|
| 3297 |
.then(function () { btn.disabled = false; btn.textContent = 'Refresh now'; });
|
| 3298 |
};
|
|
|
|
| 1686 |
// tab. fetchHealth/fetchChains/fetchUsage targeted DOM elements that
|
| 1687 |
// no longer exist (#counters, #chains-grid, #health-tbody) so they're
|
| 1688 |
// no longer called. fetchLlmHealth + renderLlmHealth feed the 2 tables.
|
| 1689 |
+
//
|
| 1690 |
+
// KI-296 (2026-05-27) — fetchHealth IS called again here so STATE.health
|
| 1691 |
+
// (source for the top-left "Last refresh / Next in" timer at L1623-1652)
|
| 1692 |
+
// reflects the actual last-probe timestamp on tab entry, not the
|
| 1693 |
+
// login-time snapshot. The DOM elements fetchHealth used to feed are
|
| 1694 |
+
// gone, but the JSON it returns still drives the consolidated header
|
| 1695 |
+
// row's timing display.
|
| 1696 |
+
return Promise.all([fetchHealth(), fetchLlmHealth()]).then(function () {
|
| 1697 |
renderLlmHealth();
|
| 1698 |
setLastUpdated();
|
| 1699 |
+
renderUpdatedLabel();
|
| 1700 |
STATE.chainLoaded = true;
|
| 1701 |
});
|
| 1702 |
}
|
|
|
|
| 2282 |
// faster than that because credit + degraded-window state changes
|
| 2283 |
// sub-tick (chat hot path stamps both) and the operator needs to see
|
| 2284 |
// those without manually clicking Refresh.
|
| 2285 |
+
//
|
| 2286 |
+
// KI-296 (2026-05-27) — also re-fetch /api/admin/health so STATE.health
|
| 2287 |
+
// (source for the top-left "Last refresh / Next in" timer) stays in
|
| 2288 |
+
// sync with whatever the background_probe_loop just did. Without
|
| 2289 |
+
// this, the timer projects "Next in: 5min" from a stale login-time
|
| 2290 |
+
// anchor and never re-syncs with reality.
|
| 2291 |
var LLM_HEALTH_POLL_INTERVAL_MS = 30000;
|
| 2292 |
function startLlmHealthPolling() {
|
| 2293 |
stopLlmHealthPolling();
|
| 2294 |
STATE.llmHealthPollTimer = setInterval(function () {
|
| 2295 |
if (STATE.activeTab !== 'chain') return;
|
| 2296 |
+
Promise.all([fetchHealth(), fetchLlmHealth()])
|
| 2297 |
+
.then(function () { renderLlmHealth(); renderUpdatedLabel(); })
|
| 2298 |
+
.catch(function () { /* swallow */ });
|
| 2299 |
}, LLM_HEALTH_POLL_INTERVAL_MS);
|
| 2300 |
}
|
| 2301 |
function stopLlmHealthPolling() {
|
|
|
|
| 3136 |
} else {
|
| 3137 |
// Tab already loaded — refresh just the KI-086 panel on re-entry
|
| 3138 |
// so the operator immediately sees fresh credits / sin-bin state.
|
| 3139 |
+
// KI-296 (2026-05-27) — also re-fetch /api/admin/health for the
|
| 3140 |
+
// top-left timer (renderUpdatedLabel reads STATE.health.updated_at).
|
| 3141 |
+
Promise.all([fetchHealth(), fetchLlmHealth()])
|
| 3142 |
+
.then(function () { renderLlmHealth(); renderUpdatedLabel(); })
|
| 3143 |
+
.catch(function () { /* swallow */ });
|
| 3144 |
}
|
| 3145 |
startLlmHealthPolling();
|
| 3146 |
}
|
|
|
|
| 3303 |
// state from /api/admin/llm-health, which is refreshed by a 5-min
|
| 3304 |
// background loop. User clicked Refresh expecting current state but
|
| 3305 |
// got cached state. Now: POST /api/admin/probe → then re-fetch.
|
| 3306 |
+
//
|
| 3307 |
+
// KI-296 (2026-05-27) — also re-fetch /api/admin/health so STATE.health
|
| 3308 |
+
// (the source for the top-left "Last refresh / Next in" timer at
|
| 3309 |
+
// L1623-1652) reflects the just-completed probe. Without this, the
|
| 3310 |
+
// POST + the LlmHealth GET refreshed the table + the bottom-right
|
| 3311 |
+
// FRESH badge but the top-left timer kept ticking from the
|
| 3312 |
+
// login-time snapshot — making the user (correctly) suspect that
|
| 3313 |
+
// probing wasn't happening at all.
|
| 3314 |
var llmHealthBtn = $('btn-refresh-llm-health');
|
| 3315 |
if (llmHealthBtn) {
|
| 3316 |
llmHealthBtn.onclick = function () {
|
|
|
|
| 3319 |
btn.textContent = 'Refreshing…';
|
| 3320 |
apiPost('/api/admin/probe', null)
|
| 3321 |
.catch(function () { /* tolerate probe failure; still re-fetch cached state */ })
|
| 3322 |
+
.then(function () { return Promise.all([fetchHealth(), fetchLlmHealth()]); })
|
| 3323 |
+
.then(function () {
|
| 3324 |
+
renderLlmHealth();
|
| 3325 |
+
renderUpdatedLabel();
|
| 3326 |
+
toast('LLM health refreshed', 'success');
|
| 3327 |
+
})
|
| 3328 |
.catch(handleFetchErr)
|
| 3329 |
.then(function () { btn.disabled = false; btn.textContent = 'Refresh now'; });
|
| 3330 |
};
|