Spaces:
Sleeping
feat(llm-chain): KI-078 — tighten per-link timeout + narrow exception catch + stamp fallback reason for telemetry
Browse filesThree 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 +12 -0
- backend/orchestrator.py +6 -1
- backend/providers/nvidia_nim_llm.py +24 -5
|
@@ -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 |
)
|
|
@@ -473,7 +473,12 @@ async def handle_turn(
|
|
| 473 |
pass
|
| 474 |
|
| 475 |
if outcome.ambiguous:
|
| 476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -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.
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|