diff --git a/services/nemoton_dispatcher_service.py b/services/nemoton_dispatcher_service.py index 03fa584..06ea511 100644 --- a/services/nemoton_dispatcher_service.py +++ b/services/nemoton_dispatcher_service.py @@ -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-004:Hermes 規則引擎降級路由 + # ────────────────────────────────────────────── + 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 {