feat(adr-004): NIM HTTP 429 → Hermes 規則引擎降級路由
All checks were successful
CD Pipeline / deploy (push) Successful in 1m10s

- _call_nim(): 429 不重試,立即拋出讓上層接管
- _hermes_rule_fallback(): 確定性四規則路由(gap/sales/risk 閾值),
  Telegram 告警加 🟡 降級前綴,行為與 NIM system prompt 一致
- dispatch(): 捕捉 HTTPError 429 → 轉 _hermes_rule_fallback(),
  回傳 nim_stats.degraded=True 供監控追蹤

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ogt
2026-04-19 11:23:59 +08:00
parent c49c2c4f6f
commit 709efb6e37

View File

@@ -384,6 +384,11 @@ class NemotronDispatcher:
break
except (requests.Timeout, requests.HTTPError) as e:
last_err = e
# ADR-004: 429 不重試,立即拋出讓上層啟動 Hermes 規則引擎降級
if isinstance(e, requests.HTTPError) and e.response is not None \
and e.response.status_code == 429:
logger.warning("[NIM] HTTP 429 速率限制,跳出 retry 迴圈")
raise
if _attempt < 2:
_time.sleep(2 ** _attempt)
logger.warning(f"[NIM] retry {_attempt + 1}/2 after {e}")
@@ -431,6 +436,87 @@ class NemotronDispatcher:
logger.info(f"[NIM] 收到 {len(results)} 個 tool_calls | tokens={nim_stats['total_tokens']}")
return results, nim_stats
# ──────────────────────────────────────────────
# ADR-004Hermes 規則引擎降級路由
# ──────────────────────────────────────────────
def _hermes_rule_fallback(self, threats: list, hermes_stats: Optional[dict] = None) -> dict:
"""
ADR-004 降級模式NIM HTTP 429 時,改用確定性規則路由 Hermes 威脅清單。
路由規則與 NIM system prompt 一致,所有 Telegram 告警加 🟡 降級前綴。
Rules依序判斷命中即停
1. gap_pct < 5% 且 sales_delta < -30% → flag_for_human_review疑似缺貨/流量異常)
2. gap_pct ≥ 5% 且 risk=HIGH → trigger_price_alert
3. gap_pct < 0 且 sales_delta > 0 → add_to_recommendation我方具競爭力
4. 其餘 → flag_for_human_review信心不足/複雜情況)
"""
degraded_note = "🟡 [降級模式 ADR-004] NIM 配額耗盡,改用 Hermes 規則引擎決策"
footprint = degraded_note + "\n" + _build_footprint_block(hermes_stats, None)
dispatched, errors = 0, []
for t in threats:
try:
if t.gap_pct < 5 and t.sales_7d_delta_pct < -30:
# Rule 1價差微小但銷量大跌 → 非定價問題,人工確認
self._exec_flag_for_human_review(
sku=t.sku, name=t.name,
concern=(
f"🟡 [規則引擎] 價差僅 {t.gap_pct:+.1f}% 但銷量大跌 "
f"{t.sales_7d_delta_pct:+.1f}%,疑似缺貨/下架/平台流量異常,"
"請人工走查前台。"
),
confidence=0.80,
footprint=footprint,
momo_price=t.momo_price, comp_price=t.pchome_price,
gap_pct=t.gap_pct, sales_delta=t.sales_7d_delta_pct,
)
elif t.gap_pct >= 5 and t.risk == "HIGH":
# Rule 2高價差 HIGH 風險 → 競價告警
self._exec_trigger_price_alert(
t.sku, t.name,
t.gap_pct, t.sales_7d_delta_pct,
f"🟡 [規則引擎] {t.recommended_action}",
t.confidence,
momo_price=t.momo_price, comp_price=t.pchome_price,
footprint=footprint,
)
elif t.gap_pct < 0 and t.sales_7d_delta_pct > 0:
# Rule 3我方具競爭力 + 銷量正成長 → 推薦
self._exec_add_to_recommendation(
t.sku, t.name,
(
f"🟡 [規則引擎] 我方比競品便宜 {abs(t.gap_pct):.1f}%"
f"銷量正成長 {t.sales_7d_delta_pct:+.1f}%"
),
t.confidence,
footprint=footprint,
threat=t,
)
else:
# Rule 4其餘複雜情況 → 人工覆核
self._exec_flag_for_human_review(
sku=t.sku, name=t.name,
concern=(
f"🟡 [規則引擎] 情況複雜或信心不足(信心 {t.confidence:.0%}"
f"建議:{t.recommended_action}"
),
confidence=t.confidence,
footprint=footprint,
momo_price=t.momo_price, comp_price=t.pchome_price,
gap_pct=t.gap_pct, sales_delta=t.sales_7d_delta_pct,
)
dispatched += 1
except Exception as e:
errors.append(f"fallback({t.sku}): {e}")
logger.error(f"[Dispatcher][ADR-004] Hermes fallback 失敗 {t.sku}: {e}")
logger.info(
f"[Dispatcher][ADR-004] Hermes 規則引擎降級完成 "
f"dispatched={dispatched} errors={len(errors)}"
)
return {"dispatched": dispatched, "skipped": 0, "errors": errors, "nim_stats": {"degraded": True}}
# ──────────────────────────────────────────────
# 語意化訊息格式器
# ──────────────────────────────────────────────
@@ -825,6 +911,23 @@ class NemotronDispatcher:
try:
tool_calls, nim_stats = self._call_nim(nim_candidates)
except requests.HTTPError as e:
if e.response is not None and e.response.status_code == 429:
logger.warning("[Dispatcher][ADR-004] NIM HTTP 429啟動 Hermes 規則引擎降級")
fb = self._hermes_rule_fallback(nim_candidates, hermes_stats)
return {
"dispatched": dispatched + fb["dispatched"],
"skipped": skipped + fb["skipped"],
"errors": errors + fb["errors"],
"nim_stats": fb["nim_stats"],
}
logger.error(f"[Dispatcher] NIM HTTP 錯誤: {e}")
return {
"dispatched": dispatched,
"skipped": len(nim_candidates),
"errors": errors + [str(e)],
"nim_stats": {},
}
except Exception as e:
logger.error(f"[Dispatcher] NIM 呼叫失敗: {e}")
return {