Spaces:
Sleeping
fix(single_brain): KI-244 — Z4 hardening — max_tokens, mark_recommendation gates, sticky-graceful retry
Browse filesThree issues from Z2 5-persona test addressed:
1) Mid-session amnesia (Priya T3, Vikram T2/T4 returned "I'm sorry, I missed
a step" AFTER profile capture succeeded):
- single_brain.py: maxOutputTokens 1024 → 2048 (prose p95 ~600 + tool-call
JSON p95 ~800 + 20% margin = 1680, rounded up). Mirrors KI-150 fact_find
fix (420 → 700).
- Added finishReason=MAX_TOKENS warning log so future truncation surfaces.
2) Hallucinated closure (Priya T6 congratulated user despite zero rec;
Vikram T6 called mark_recommendation with empty policy_ids):
- brain_tools.mark_recommendation now gates BEFORE the Y2 record_policy_event
write:
(a) empty cleaned policy_ids → {recorded: False, error: 'no_policies_supplied'}
(b) non-empty ids but session.last_retrieved_chunks empty/missing →
{recorded: False, error: 'no_retrieval_history — call retrieve_policies first'}
3) Brain election bouncing (Priya bounced sales_brain → single_brain →
single_brain → sales_brain in one session):
- session_state.SessionState.single_brain_sticky: bool = False (new field).
- main.py: on first successful single_brain turn, stamp sticky=True.
On subsequent SingleBrainError after sticky, return a graceful
"Sorry, I'm having trouble — could you say that again?" reply with
brain_used="single_brain::sticky_graceful_retry" INSTEAD of falling
through to the legacy orchestrator. Non-sticky path (first turn) still
falls through.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/brain_tools.py +19 -0
- backend/main.py +59 -16
- backend/session_state.py +10 -0
- backend/single_brain.py +36 -2
|
@@ -328,6 +328,25 @@ def mark_recommendation(
|
|
| 328 |
seen.add(s)
|
| 329 |
cleaned.append(s)
|
| 330 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
try:
|
| 332 |
session.last_recommendation_ids = cleaned
|
| 333 |
except Exception as e: # noqa: BLE001
|
|
|
|
| 328 |
seen.add(s)
|
| 329 |
cleaned.append(s)
|
| 330 |
|
| 331 |
+
# Z2 fix — Issue 2 (hallucinated closure). Vikram T6 saw the LLM emit
|
| 332 |
+
# mark_recommendation with an empty policy_ids list, the tool silently
|
| 333 |
+
# returned {"recorded": True, "policy_ids": []}, and the bot then said
|
| 334 |
+
# "I'm glad we found a good fit" despite ZERO cards shown. Two
|
| 335 |
+
# preconditions, gated BEFORE any session write so we don't poison
|
| 336 |
+
# last_recommendation_ids / shown_policies events with junk:
|
| 337 |
+
# (a) empty (after dedup) → no_policies_supplied
|
| 338 |
+
# (b) non-empty BUT no retrieval history this session → caller
|
| 339 |
+
# must run retrieve_policies first (Y2 cache check via
|
| 340 |
+
# session.last_retrieved_chunks)
|
| 341 |
+
if not cleaned:
|
| 342 |
+
return {"recorded": False, "error": "no_policies_supplied"}
|
| 343 |
+
_retrieval_cache = getattr(session, "last_retrieved_chunks", None)
|
| 344 |
+
if not _retrieval_cache:
|
| 345 |
+
return {
|
| 346 |
+
"recorded": False,
|
| 347 |
+
"error": "no_retrieval_history — call retrieve_policies first",
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
try:
|
| 351 |
session.last_recommendation_ids = cleaned
|
| 352 |
except Exception as e: # noqa: BLE001
|
|
@@ -445,6 +445,14 @@ async def chat(req: ChatRequest, request: Request):
|
|
| 445 |
from backend.session_state import get_session
|
| 446 |
|
| 447 |
_sb_session = get_session(session_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
try:
|
| 449 |
turn = await asyncio.wait_for(
|
| 450 |
single_brain.handle_turn(
|
|
@@ -454,23 +462,58 @@ async def chat(req: ChatRequest, request: Request):
|
|
| 454 |
),
|
| 455 |
timeout=45.0,
|
| 456 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
except single_brain.SingleBrainError as _sb_err:
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
else:
|
| 475 |
turn = await asyncio.wait_for(
|
| 476 |
handle_turn(
|
|
|
|
| 445 |
from backend.session_state import get_session
|
| 446 |
|
| 447 |
_sb_session = get_session(session_id)
|
| 448 |
+
# Z2 fix — Issue 3 (brain election bouncing). Once a session
|
| 449 |
+
# has had ANY successful single_brain turn, it must stay on
|
| 450 |
+
# single_brain for the rest of its lifetime. Falling back to
|
| 451 |
+
# the legacy orchestrator mid-stream (which is what Priya saw
|
| 452 |
+
# 6 turns in a row) discards everything single_brain captured
|
| 453 |
+
# in last_recommendation_ids / last_retrieved_chunks /
|
| 454 |
+
# slug_to_insurer and confuses the user. Sticky check below.
|
| 455 |
+
_sb_was_sticky = getattr(_sb_session, "single_brain_sticky", False)
|
| 456 |
try:
|
| 457 |
turn = await asyncio.wait_for(
|
| 458 |
single_brain.handle_turn(
|
|
|
|
| 462 |
),
|
| 463 |
timeout=45.0,
|
| 464 |
)
|
| 465 |
+
# First successful single_brain turn stamps the flag so
|
| 466 |
+
# every subsequent turn on this session is locked in.
|
| 467 |
+
try:
|
| 468 |
+
_sb_session.single_brain_sticky = True
|
| 469 |
+
except Exception: # noqa: BLE001
|
| 470 |
+
pass
|
| 471 |
except single_brain.SingleBrainError as _sb_err:
|
| 472 |
+
if _sb_was_sticky:
|
| 473 |
+
# Z2 belt+suspenders — session already had a clean
|
| 474 |
+
# single_brain turn. Do NOT cross-fade to the legacy
|
| 475 |
+
# orchestrator (loses turn state + frontend sees the
|
| 476 |
+
# brain hop). Emit a graceful retry prompt instead.
|
| 477 |
+
logging.warning(
|
| 478 |
+
"single_brain failed on STICKY session (session=%s); "
|
| 479 |
+
"emitting graceful retry, NOT falling back to "
|
| 480 |
+
"orchestrator: %s",
|
| 481 |
+
session_id, _sb_err,
|
| 482 |
+
)
|
| 483 |
+
turn = single_brain.TurnResult(
|
| 484 |
+
reply_text=(
|
| 485 |
+
"Sorry, I'm having trouble — could you say "
|
| 486 |
+
"that again?"
|
| 487 |
+
),
|
| 488 |
+
citations=[],
|
| 489 |
+
retrieved_chunk_ids=[],
|
| 490 |
+
brain_used="single_brain::sticky_graceful_retry",
|
| 491 |
+
intent="qa",
|
| 492 |
+
language="en",
|
| 493 |
+
latency_ms=int((time.time() - t_chat0) * 1000),
|
| 494 |
+
raw_reply=f"SingleBrainError: {_sb_err}",
|
| 495 |
+
faithfulness_passed=True,
|
| 496 |
+
faithfulness_reasons=[],
|
| 497 |
+
blocked=False,
|
| 498 |
+
profile_updates={},
|
| 499 |
+
)
|
| 500 |
+
else:
|
| 501 |
+
logging.warning(
|
| 502 |
+
"single_brain failed, falling back to orchestrator "
|
| 503 |
+
"(session=%s): %s",
|
| 504 |
+
session_id, _sb_err,
|
| 505 |
+
)
|
| 506 |
+
turn = await asyncio.wait_for(
|
| 507 |
+
handle_turn(
|
| 508 |
+
user_text=req.user_text,
|
| 509 |
+
chat_history=req.chat_history,
|
| 510 |
+
user_profile=req.profile,
|
| 511 |
+
policy_filter_ids=req.policy_filter_ids,
|
| 512 |
+
session_id=session_id,
|
| 513 |
+
view_context=req.view_context,
|
| 514 |
+
),
|
| 515 |
+
timeout=45.0,
|
| 516 |
+
)
|
| 517 |
else:
|
| 518 |
turn = await asyncio.wait_for(
|
| 519 |
handle_turn(
|
|
@@ -74,6 +74,16 @@ class SessionState:
|
|
| 74 |
# "Conversation turn" column in the admin Recommendation History panel
|
| 75 |
# (previously showed "—" because no caller populated the field).
|
| 76 |
turn_idx: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
def _flush(self) -> None:
|
| 79 |
"""No-op since KI-118 (2026-05-15). Disk persistence was removed; the
|
|
|
|
| 74 |
# "Conversation turn" column in the admin Recommendation History panel
|
| 75 |
# (previously showed "—" because no caller populated the field).
|
| 76 |
turn_idx: int = 0
|
| 77 |
+
# Z2 fix — Issue 3 (brain election bouncing). Priya's session hopped
|
| 78 |
+
# sales_brain → single_brain → single_brain → sales_brain across 6
|
| 79 |
+
# turns even though USE_SINGLE_BRAIN=true; the Gemini 503 fallback
|
| 80 |
+
# (Z1 retry already softens) was dropping the session back onto the
|
| 81 |
+
# legacy orchestrator mid-stream. Belt-and-suspenders: main.py stamps
|
| 82 |
+
# this True after the FIRST successful single_brain turn, and
|
| 83 |
+
# subsequent SingleBrainError responses on the same session must NOT
|
| 84 |
+
# fall through to the orchestrator — they emit a graceful retry
|
| 85 |
+
# prompt so the session stays sticky on single_brain.
|
| 86 |
+
single_brain_sticky: bool = False
|
| 87 |
|
| 88 |
def _flush(self) -> None:
|
| 89 |
"""No-op since KI-118 (2026-05-15). Disk persistence was removed; the
|
|
@@ -402,8 +402,17 @@ async def _gemini_call(
|
|
| 402 |
"tools": [{"functionDeclarations": tools}],
|
| 403 |
"toolConfig": {"functionCallingConfig": {"mode": "AUTO"}},
|
| 404 |
"generationConfig": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
"temperature": 0.4,
|
| 406 |
-
"maxOutputTokens":
|
| 407 |
},
|
| 408 |
}
|
| 409 |
headers = {"Content-Type": "application/json"}
|
|
@@ -477,10 +486,35 @@ async def _gemini_call(
|
|
| 477 |
raise SingleBrainError(last_err)
|
| 478 |
|
| 479 |
try:
|
| 480 |
-
|
| 481 |
except Exception as e: # noqa: BLE001
|
| 482 |
raise SingleBrainError(f"Gemini malformed JSON: {e}") from e
|
| 483 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
# Defensive — loop fell through without returning or raising. Should
|
| 485 |
# be unreachable, but raise so we never silently return None.
|
| 486 |
raise SingleBrainError(
|
|
|
|
| 402 |
"tools": [{"functionDeclarations": tools}],
|
| 403 |
"toolConfig": {"functionCallingConfig": {"mode": "AUTO"}},
|
| 404 |
"generationConfig": {
|
| 405 |
+
# Z2 fix — Issue 1 (mid-session amnesia). Priya T3 + Vikram T2/T4
|
| 406 |
+
# came back with the "I lost my train of thought" template even
|
| 407 |
+
# though slot capture succeeded. Root cause matches KI-150
|
| 408 |
+
# (fact_find LLM, 420 → 700): when Gemini must emit prose AND a
|
| 409 |
+
# tool-call trailer in the same response, the model hits
|
| 410 |
+
# maxOutputTokens mid-emission, the trailer truncates, and the
|
| 411 |
+
# caller falls through to the defensive reply. Budget breakdown
|
| 412 |
+
# at p95: prose ~600 tok + tool-call JSON ~800 tok + 20% margin
|
| 413 |
+
# ⇒ 1680, rounded up to a safe power-of-two-ish 2048.
|
| 414 |
"temperature": 0.4,
|
| 415 |
+
"maxOutputTokens": 2048,
|
| 416 |
},
|
| 417 |
}
|
| 418 |
headers = {"Content-Type": "application/json"}
|
|
|
|
| 486 |
raise SingleBrainError(last_err)
|
| 487 |
|
| 488 |
try:
|
| 489 |
+
_payload = resp.json()
|
| 490 |
except Exception as e: # noqa: BLE001
|
| 491 |
raise SingleBrainError(f"Gemini malformed JSON: {e}") from e
|
| 492 |
|
| 493 |
+
# Z2 fix — Issue 1 truncation detector. If Gemini hit our
|
| 494 |
+
# maxOutputTokens budget the candidate's finishReason will be
|
| 495 |
+
# "MAX_TOKENS" and the tool-call trailer (if any) is likely
|
| 496 |
+
# truncated → caller will degrade to the defensive "I lost my
|
| 497 |
+
# train of thought" reply. Log a WARNING (not raise) so the turn
|
| 498 |
+
# still flows, but ops can detect a future budget regression by
|
| 499 |
+
# alerting on this log line. Swallow any shape errors — this is
|
| 500 |
+
# purely observational.
|
| 501 |
+
try:
|
| 502 |
+
_cands = _payload.get("candidates") or []
|
| 503 |
+
if _cands:
|
| 504 |
+
_fr = (_cands[0].get("finishReason") or "").upper()
|
| 505 |
+
if _fr == "MAX_TOKENS":
|
| 506 |
+
_log.warning(
|
| 507 |
+
"single_brain Gemini finishReason=MAX_TOKENS "
|
| 508 |
+
"(model=%s, budget=%d) — prose+tool-call trailer "
|
| 509 |
+
"may be truncated; raise maxOutputTokens if this "
|
| 510 |
+
"recurs",
|
| 511 |
+
model, body["generationConfig"]["maxOutputTokens"],
|
| 512 |
+
)
|
| 513 |
+
except Exception: # noqa: BLE001
|
| 514 |
+
pass
|
| 515 |
+
|
| 516 |
+
return _payload
|
| 517 |
+
|
| 518 |
# Defensive — loop fell through without returning or raising. Should
|
| 519 |
# be unreachable, but raise so we never silently return None.
|
| 520 |
raise SingleBrainError(
|