rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
078ff45
·
1 Parent(s): dd6172a

feat(llm-chain): KI-078 — tighten per-link timeout + narrow exception catch + stamp fallback reason for telemetry

Browse files

Three related LLM-chain hardening fixes that together close the loop on
KI-075's fact-find brain failure root cause:

1. nvidia_nim_llm.py — per-link timeout 12s → 6s in get_fast_brain_llm().
With a 22s total chain budget, a 12s per-link meant only 1 candidate
could complete before the budget expired — i.e. the entire fallback
chain was effectively dead. Healthy paths (Groq LPU, NIM Nemotron
Nano, NIM Qwen) all TTFT in <2s, so 6s catches a degraded link fast
and lets the chain try 3-4 candidates inside the 22s budget. NIM
cold-start still has Groq's cross-provider link (no NIM cold-start
exposure) + the outer fact_find_brain wait_for=25s.

2. nvidia_nim_llm.py — narrow the broad `except Exception` in
NimChainLLM.chat() loop to re-raise asyncio.CancelledError,
KeyboardInterrupt, SystemExit. The previous broad catch was
swallowing CancelledError, so when fact_find_brain's outer
asyncio.wait_for(_TIMEOUT_S=25s) fired, this loop kept burning the
full chain budget instead of bubbling cancellation up — the fact-
find turn lost its fallback window every time.

3. fact_find_brain.py + orchestrator.py — stamp _fallback_reason on
FactFindOutcome when _canonical_fallback fires (one of: "timeout",
"llm_error", "no_trailer", "empty_reply"). Orchestrator now builds
brain_used = "fact_find_brain::fallback:timeout" instead of just
"fact_find_brain::fallback". Admin telemetry / usage logs can now
measure the fallback-cause mix, which is essential for verifying
KI-075 + KI-078 impact in production.

Verification:
- py_compile all 3 files: OK
- inline FactFindOutcome shape + orchestrator tag wiring tests: OK
- pytest tests/test_routing_regression.py -x -q: 15 passed, 13 subtests passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

backend/fact_find_brain.py CHANGED
@@ -55,6 +55,16 @@ class FactFindOutcome:
55
  slot_driving: Optional[str] = None
56
  fact_find_complete: bool = False
57
  ambiguous: bool = False
 
 
 
 
 
 
 
 
 
 
58
 
59
 
60
  # ----------------------------------------------------------------------------
@@ -612,6 +622,7 @@ def _canonical_fallback(session, user_text: str, *, reason: str) -> FactFindOutc
612
  slot_driving=slot,
613
  fact_find_complete=False,
614
  ambiguous=True,
 
615
  )
616
  # Nothing left to ask — gentle hand-off.
617
  reply = (
@@ -625,4 +636,5 @@ def _canonical_fallback(session, user_text: str, *, reason: str) -> FactFindOutc
625
  slot_driving=None,
626
  fact_find_complete=False,
627
  ambiguous=True,
 
628
  )
 
55
  slot_driving: Optional[str] = None
56
  fact_find_complete: bool = False
57
  ambiguous: bool = False
58
+ # KI-078 (2026-05-15) — when `ambiguous=True` because the LLM brain
59
+ # bailed and `_canonical_fallback` was used, this stamps WHY so the
60
+ # orchestrator can append it to `brain_used` and admin telemetry can
61
+ # measure the fallback-reason mix. One of:
62
+ # "timeout" — asyncio.wait_for(_TIMEOUT_S) expired
63
+ # "llm_error" — chain raised (non-timeout) before returning
64
+ # "no_trailer" — reply had no <FF>...</FF> JSON block, or it failed parse
65
+ # "empty_reply" — trailer stripped to an empty user-facing reply
66
+ # None when the brain succeeded.
67
+ _fallback_reason: Optional[str] = None
68
 
69
 
70
  # ----------------------------------------------------------------------------
 
622
  slot_driving=slot,
623
  fact_find_complete=False,
624
  ambiguous=True,
625
+ _fallback_reason=reason, # KI-078 — telemetry stamp
626
  )
627
  # Nothing left to ask — gentle hand-off.
628
  reply = (
 
636
  slot_driving=None,
637
  fact_find_complete=False,
638
  ambiguous=True,
639
+ _fallback_reason=reason, # KI-078 — telemetry stamp
640
  )
backend/orchestrator.py CHANGED
@@ -473,7 +473,12 @@ async def handle_turn(
473
  pass
474
 
475
  if outcome.ambiguous:
476
- brain_tag = "fact_find_brain::fallback"
 
 
 
 
 
477
  elif outcome.fact_find_complete:
478
  brain_tag = "fact_find_brain::complete"
479
  else:
 
473
  pass
474
 
475
  if outcome.ambiguous:
476
+ # KI-078 (2026-05-15) — append fallback reason so admin telemetry
477
+ # can measure the fallback-cause mix (timeout vs llm_error vs
478
+ # no_trailer vs empty_reply). Essential for measuring KI-075 +
479
+ # KI-078 impact in production.
480
+ reason = getattr(outcome, "_fallback_reason", None) or "unknown"
481
+ brain_tag = f"fact_find_brain::fallback:{reason}"
482
  elif outcome.fact_find_complete:
483
  brain_tag = "fact_find_brain::complete"
484
  else:
backend/providers/nvidia_nim_llm.py CHANGED
@@ -422,6 +422,14 @@ class NimChainLLM(LLMProvider):
422
  httpx.ConnectError, httpx.NetworkError, asyncio.TimeoutError) as e:
423
  last_err = e
424
  continue # try next model in chain
 
 
 
 
 
 
 
 
425
  except Exception as e:
426
  # Unexpected error — record + try next, but surface eventually if all fail
427
  last_err = e
@@ -525,11 +533,22 @@ def get_brain_llm() -> NimChainLLM:
525
  def get_fast_brain_llm() -> NimChainLLM:
526
  """Fast brain — multi-model NIM chain optimized for low TTFT.
527
  Primary: Qwen 3-Next 80B (50%) or Groq Llama-3.3-70B (50%). See
528
- FAST_BRAIN_CHAIN for fallback order. KI-021 — per-link 12s, total chain
529
- budget 22s. KI-025 — provider-balanced for 2× fast-brain throughput;
530
- Groq LPU's sub-1s TTFT is actually faster than NIM Qwen on average,
531
- so this rotation is a strict win for fact-find / QA latency."""
532
- return NimChainLLM(chain=_balanced_brain_chain(FAST_BRAIN_CHAIN), timeout=12.0,
 
 
 
 
 
 
 
 
 
 
 
533
  role="fast_brain", total_budget_s=22.0)
534
 
535
 
 
422
  httpx.ConnectError, httpx.NetworkError, asyncio.TimeoutError) as e:
423
  last_err = e
424
  continue # try next model in chain
425
+ except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
426
+ # KI-078 (2026-05-15) — must re-raise these. The previous
427
+ # broad `except Exception` swallowed CancelledError, so when
428
+ # fact_find_brain's outer `asyncio.wait_for(_TIMEOUT_S)`
429
+ # fired, this loop kept consuming budget instead of bubbling
430
+ # the cancellation up. That cost the entire fact-find turn
431
+ # its fallback window.
432
+ raise
433
  except Exception as e:
434
  # Unexpected error — record + try next, but surface eventually if all fail
435
  last_err = e
 
533
  def get_fast_brain_llm() -> NimChainLLM:
534
  """Fast brain — multi-model NIM chain optimized for low TTFT.
535
  Primary: Qwen 3-Next 80B (50%) or Groq Llama-3.3-70B (50%). See
536
+ FAST_BRAIN_CHAIN for fallback order.
537
+
538
+ KI-078 (2026-05-15) per-link timeout tightened 12s 6s. With a 22s
539
+ total chain budget and a 12s per-link, only 1 candidate could complete
540
+ before the budget expired — i.e. the fallback chain was effectively
541
+ dead. Groq LPU + NIM Nemotron Nano + NIM Qwen all hit TTFT in <2s on
542
+ healthy paths, so 6s catches a degraded link fast and lets the chain
543
+ try 3-4 candidates inside the 22s budget. The cold-start case (NIM
544
+ pool warm-up taking 10-15s) is still handled by the outer
545
+ fact_find_brain `_TIMEOUT_S=25s` wait_for + Groq's cross-provider link
546
+ which has no NIM cold-start exposure.
547
+
548
+ KI-025 — provider-balanced for 2× fast-brain throughput; Groq LPU's
549
+ sub-1s TTFT is actually faster than NIM Qwen on average, so this
550
+ rotation is a strict win for fact-find / QA latency."""
551
+ return NimChainLLM(chain=_balanced_brain_chain(FAST_BRAIN_CHAIN), timeout=6.0,
552
  role="fast_brain", total_budget_s=22.0)
553
 
554