rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
fe7ca39
·
1 Parent(s): d6d717f

fix(single_brain): KI-244 — Z4 hardening — max_tokens, mark_recommendation gates, sticky-graceful retry

Browse files

Three 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 CHANGED
@@ -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
backend/main.py CHANGED
@@ -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
- logging.warning(
459
- "single_brain failed, falling back to orchestrator "
460
- "(session=%s): %s",
461
- session_id, _sb_err,
462
- )
463
- turn = await asyncio.wait_for(
464
- handle_turn(
465
- user_text=req.user_text,
466
- chat_history=req.chat_history,
467
- user_profile=req.profile,
468
- policy_filter_ids=req.policy_filter_ids,
469
- session_id=session_id,
470
- view_context=req.view_context,
471
- ),
472
- timeout=45.0,
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(
backend/session_state.py CHANGED
@@ -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
backend/single_brain.py CHANGED
@@ -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": 1024,
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
- return resp.json()
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(