rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
2acdc9e
·
1 Parent(s): 1786697

fix: 4 hardening fixes — sticky-retry, canned-msg copy, admin refresh wiring, recall privacy

Browse files

1) 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 CHANGED
@@ -975,8 +975,16 @@ async def chat(req: ChatRequest, request: Request):
975
  )
976
  turn = single_brain.TurnResult(
977
  reply_text=(
978
- "Sorry, I'm having trouble — could you say "
979
- "that again?"
 
 
 
 
 
 
 
 
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=[],
backend/session_state.py CHANGED
@@ -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 (already-captured slots
245
- from THIS conversation win — the user may have given
246
- fresher facts). Returns True iff a profile was applied.
 
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__:
backend/single_brain.py CHANGED
@@ -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 immediately
69
- # tripped the orchestrator fallback. We retry ONCE on these transient codes
70
- # with a short backoff before raising SingleBrainError so the legacy
71
- # orchestrator only takes over on a genuinely sustained outage.
 
 
 
 
 
72
  _TRANSIENT_HTTP_CODES = {429, 500, 502, 503, 504}
73
- _TRANSIENT_RETRY_BACKOFF_SEC = 1.5
 
 
 
 
 
 
 
 
 
 
 
 
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
- _sm = pending_recall.get("summary") or {}
819
- _bits = []
820
- for _k in ("age", "location_tier", "dependents", "primary_goal",
821
- "health_conditions"):
822
- _v = _sm.get(_k)
823
- if _v not in (None, "", []):
824
- _bits.append(f"{_k.replace('_', ' ')}: {_v}")
825
- _summ = "; ".join(_bits) if _bits else "a saved profile"
 
 
 
 
 
 
 
 
 
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 already exists under the name the user just "
832
- f"gave (\"{_nm}\"). Known hints — {_summ}.\n"
 
 
 
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 — are you the same {_nm} who spoke with us "
837
- f"before ({_summ})? If yes, I'll pick up right where we left "
838
- f"off. If not, no problem — just say so and we'll start fresh.\"\n"
839
- "Then wait for their yes/no on the NEXT turn. The system "
840
- "applies or discards the saved profile from their answer — you "
841
- "never merge anything yourself."
 
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: on transient failures (HTTP 429/5xx, httpx
1001
- TimeoutException, httpx.HTTPError) we retry ONCE after a short
1002
- backoff before raising. This soaks up the brief Gemini "high demand"
1003
- 503 bursts observed live (2026-05-15) so we don't fall through to
1004
- the legacy orchestrator mid-session for what is usually a sub-second
1005
- blip on the provider side.
 
 
 
 
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
- # 2 attempts total: initial + 1 retry on transient failure.
1037
- for attempt in range(2):
 
 
 
 
 
 
 
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 attempt == 0:
 
1049
  _log.warning(
1050
- "single_brain transient timeout (attempt=1); "
1051
- "retrying once after %.1fs backoff",
1052
- _TRANSIENT_RETRY_BACKOFF_SEC,
1053
  )
1054
- await asyncio.sleep(_TRANSIENT_RETRY_BACKOFF_SEC)
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 attempt == 0:
 
1064
  _log.warning(
1065
  "single_brain transient transport error "
1066
- "(attempt=1, %s); retrying once after %.1fs backoff",
1067
- type(e).__name__, _TRANSIENT_RETRY_BACKOFF_SEC,
 
 
1068
  )
1069
- await asyncio.sleep(_TRANSIENT_RETRY_BACKOFF_SEC)
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 once. Permanent (4xx like 400/401/403/404) →
1082
- # raise immediately; retrying won't help.
1083
  if (
1084
- attempt == 0
1085
  and resp.status_code in _TRANSIENT_HTTP_CODES
1086
  ):
 
1087
  _log.warning(
1088
- "single_brain transient HTTP %d (attempt=1); "
1089
- "retrying once after %.1fs backoff",
1090
- resp.status_code, _TRANSIENT_RETRY_BACKOFF_SEC,
 
1091
  )
1092
- await asyncio.sleep(_TRANSIENT_RETRY_BACKOFF_SEC)
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
frontend/public/admin/llm-control.html CHANGED
@@ -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
- return fetchLlmHealth().then(function () {
 
 
 
 
 
 
 
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
- fetchLlmHealth().then(renderLlmHealth).catch(function () { /* swallow */ });
 
 
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
- fetchLlmHealth().then(renderLlmHealth).catch(function () { /* swallow */ });
 
 
 
 
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 () { renderLlmHealth(); toast('LLM health refreshed', 'success'); })
 
 
 
 
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
  };