#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ NemoTron 行動派發器 (Module 2 — Dispatcher) 角色:派發器 (Dispatcher) 模型:NVIDIA NIM meta/llama-3.1-8b-instruct(Tool Calling 專用) 輸入:Hermes 分析師輸出的 Top N 威脅清單 (list[PriceThreat]) + 運算足跡 輸出:呼叫工具 → Telegram 語意化告警 / DB 推薦商品寫入 訊息規範:語意化 Emoji 字典 + 倒金字塔結構 + 底部運算足跡區塊 詳見 docs/AI_INTELLIGENCE_MODULE_SOT.md § 五、六 工具清單(扁平化,避免 NIM JSON 截斷): trigger_price_alert → Telegram 競價高危險預警 add_to_recommendation → 寫入前台推薦商品 + Telegram 通知 flag_for_human_review → Telegram AI 例外決策請求 """ import json import logging import os import re import uuid from datetime import datetime from typing import Optional import requests from services.mcp_context_service import build_mcp_context from config import HERMES_URL # ADR-008 集中化:禁止硬編碼 IP from services.ai_call_logger import log_ai_call # Operation Ollama-First v5.0 P1 from services.ai_exception_contract import LEGACY_REVIEW_GATE_KEY logger = logging.getLogger(__name__) # [2026-04-18 台北] Bug-3.1 防線升級:簡體字/異體字黑名單(2次精選) — Claude Opus 4.7 # 規則:只收「繁體中文環境 100% 不會出現」的字。繁簡通用字(迎、据、准、理、 # 整、別、始、束、史、點、但、於、原、據、準、理)一律剔除,避免誤報正常繁中。 _SIMPLIFIED_BLACKLIST = frozenset( # 頂級高頻(繁體寫法完全不同) '参给当为来国发会说时间过从实现个这话动问题' '经济学员务关对应听见设计电脑产东团专义价样种让' '议统战爱态头带业无该决积权导档号风险' # 商業 / 財務常見 '选择证龙习惯亏损营运货币场较况负责调优势' # 書寫表達 '认识书写结历观终则' # 異體字 '亊' ) def _has_repeated_phrase(text: str, min_len: int = 4, min_count: int = 2) -> bool: """偵測連續中文片段中「min_len 字子串」重複出現 ≥ min_count 次的語意坍塌。 範例: 「当前事亊当前事亊」→ min_len=4, 「当前事亊」重複 2 次 → True 「價差擴大且銷量下滑」→ 無 4 字重複 → False """ chinese = ''.join(c for c in text if '\u4e00' <= c <= '\u9fff') if len(chinese) < min_len * min_count: return False for i in range(len(chinese) - min_len + 1): phrase = chinese[i:i + min_len] if chinese.count(phrase) >= min_count: return True return False def _sanitize_text(text: str, fallback: str = "請 AI 自動驗證確認", max_len: int = 200) -> str: """防止 LLM 幻覺文字輸出到 Telegram。 [2026-04-18 台北] Bug-3.1 三層檢測升級 — Claude Opus 4.7 L1: 連續 ≥15 字中文且整段無標點 → 幻覺(原規則) L2: 命中簡體字/異體字黑名單 → 簡繁污染 L3: 連續中文片段內 4+ 字子串重複 ≥2 次 → 語意坍塌 任一層命中 → fallback 替換為「請 AI 自動驗證確認」(或呼叫端指定字串) """ if not text or not isinstance(text, str): return fallback text = text.strip()[:max_len] # L1: 連續無標點中文區段(原規則) if re.search(r'[\u4e00-\u9fff]{15,}', text) and not re.search(r'[,。、?!\s]', text): logger.warning(f"[Sanitize L1] 連續無標點中文幻覺,fallback: {text[:40]}") return fallback # L2: 簡體字/異體字污染 hits = [c for c in text if c in _SIMPLIFIED_BLACKLIST] if hits: logger.warning(f"[Sanitize L2] 簡體/異體字污染 {''.join(sorted(set(hits)))},fallback: {text[:40]}") return fallback # L3: 短語重複(語意坍塌) if _has_repeated_phrase(text): logger.warning(f"[Sanitize L3] 短語重複偵測(語意坍塌),fallback: {text[:40]}") return fallback return text or fallback # ── NVIDIA NIM ────────────────────────────────────── NIM_BASE_URL = "https://integrate.api.nvidia.com/v1" NIM_MODEL = "meta/llama-3.1-8b-instruct" NIM_API_KEY = os.getenv("NVIDIA_API_KEY", "") NIM_TIMEOUT = 60 # 秒 # ── 每日配額守門 ───────────────────────────────────── NIM_DAILY_LIMIT = 80 # 留 20 個給 AWOOOI,100/天免費配額 _nim_call_count = {"date": "", "count": 0} # ── Operation Ollama-First v5.0 / Phase 3 / A9 ────────────────── # GCP Ollama qwen3:14b 灰度切換開關 # - true → qwen3 主路徑,NIM 降為備援,最後仍兜底 Hermes 規則引擎(ADR-004) # - false → 緊急停用 Ollama-first 時才回 NIM-first # 模型選擇:A2 web-research 紅綠燈報告 docs/phase0_research_report_20260503.md # 原戰役計畫 deepseek-r1:14b 的 Ollama tool_calls chat template 缺對應 jinja # (GitHub Issue #10935 未解),改採 qwen3:14b(Ollama 官方 + qwenlm 雙確認 tools 支援)。 # 統帥 2026-05-03 23:30 指令:「免費優先」— GCP qwen3:14b 已拉 # 預設 ON:qwen3:14b 主 → NIM 備援 → Hermes 規則引擎兜底(ADR-004) # 緊急停用(回 NIM-first):export NEMOTRON_OLLAMA_FIRST=false NEMOTRON_OLLAMA_FIRST = os.getenv("NEMOTRON_OLLAMA_FIRST", "true").lower() == "true" NEMOTRON_OLLAMA_MODEL = os.getenv("NEMOTRON_OLLAMA_MODEL", "qwen3:14b") NEMOTRON_OLLAMA_TIMEOUT = int(os.getenv("NEMOTRON_OLLAMA_TIMEOUT", "180")) # 秒 def _check_nim_quota() -> bool: today = datetime.now().strftime("%Y-%m-%d") if _nim_call_count["date"] != today: _nim_call_count["date"] = today _nim_call_count["count"] = 0 if _nim_call_count["count"] >= NIM_DAILY_LIMIT: logger.warning(f"[NIM] 今日配額已達上限 {NIM_DAILY_LIMIT},跳過") return False _nim_call_count["count"] += 1 return True # ── 告警去重 (Deduplication) 快取 ──────────────────────── import time _ALERT_CACHE = {} # {sku: timestamp} _ALERT_TTL_SEC = 4 * 3600 # 預設 4 小時(防止同商品短時間重複告警) def _is_duplicate_alert(sku: str) -> bool: """檢查是否在 TTL 內已經告警過。若是則回傳 True,否則記錄當下時間並回傳 False""" now = time.time() last_alert = _ALERT_CACHE.get(sku) if last_alert and (now - last_alert) < _ALERT_TTL_SEC: return True _ALERT_CACHE[sku] = now # 順便清理過期快取,避免記憶體洩漏 expired = [k for k, v in _ALERT_CACHE.items() if (now - v) >= _ALERT_TTL_SEC] for k in expired: del _ALERT_CACHE[k] return False def _nim_quota_used() -> int: """回傳今日已使用配額數""" today = datetime.now().strftime("%Y-%m-%d") if _nim_call_count["date"] != today: return 0 return _nim_call_count["count"] # ── Tool 定義(扁平化 Schema)──────────────────────── TOOLS = [ { "type": "function", "function": { "name": "trigger_price_alert", "description": "發送競價高危險預警 Telegram 告警,當商品價格比競品貴且銷量明顯下滑時使用", "parameters": { "type": "object", # [2026-04-18 台北] Bug-1 防線一:移除 momo_price / comp_price — # 客觀售價由 Python 從 threat_map 獨裁注入,不讓 LLM 碰 # 避免 NemoTron 漏吐 → default=0 → Telegram 顯示 $0 幻覺 — Claude Opus 4.7 "properties": { "sku": {"type": "string", "description": "商品 SKU 編號"}, "name": {"type": "string", "description": "商品名稱"}, "gap_pct": {"type": "number", "description": "我方與競品的價差百分比(正值代表我貴)"}, "sales_delta": {"type": "number", "description": "近7天銷量變動百分比(負值代表下滑)"}, "action": {"type": "string", "description": "建議行動說明"}, "confidence": {"type": "number", "description": "AI 信心度 0.0~1.0"}, }, "required": ["sku", "name", "gap_pct", "sales_delta", "action", "confidence"], }, }, }, { "type": "function", "function": { "name": "add_to_recommendation", "description": "將價格具競爭力且庫存充足的商品加入前台推薦商品區塊", "parameters": { "type": "object", "properties": { "sku": {"type": "string", "description": "商品 SKU 編號"}, "name": {"type": "string", "description": "商品名稱"}, "reason": {"type": "string", "description": "推薦原因(給後台審核人員)"}, "confidence": {"type": "number", "description": "AI 信心度 0.0~1.0"}, }, "required": ["sku", "name", "reason", "confidence"], }, }, }, { "type": "function", "function": { "name": "flag_for_human_review", "description": "當情況複雜、AI 信心不足,或需要 AI 例外決策時,發送 Telegram 請求AI 例外決策", "parameters": { "type": "object", "properties": { "sku": {"type": "string", "description": "商品 SKU 編號"}, "name": {"type": "string", "description": "商品名稱"}, "concern": {"type": "string", "description": "需要人工判斷的疑慮說明(含矛盾數據描述)"}, "confidence": {"type": "number", "description": "AI 信心度 0.0~1.0"}, }, "required": ["sku", "name", "concern", "confidence"], }, }, }, { "type": "function", "function": { "name": "route_to_km", "description": ( "將商品競價洞察路由到知識庫(KM)的指定領域分類," "供未來 RAG 查詢與 OpenClaw 週報引用。" "適用於:數據有參考價值但不需立即告警的情況。" ), "parameters": { "type": "object", "properties": { "sku": {"type": "string", "description": "商品 SKU 編號"}, "name": {"type": "string", "description": "商品名稱"}, "km_domain": { "type": "string", "description": ( "KM 領域分類,必須為以下之一:" "price_competition(競價情報)、" "sales_anomaly(銷量異常)、" "promotion_opportunity(促銷機會)、" "market_trend(市場趨勢)" ), }, "summary": {"type": "string", "description": "此洞察的核心摘要(繁體中文,50 字內)"}, "confidence": {"type": "number", "description": "AI 信心度 0.0~1.0"}, }, "required": ["sku", "name", "km_domain", "summary", "confidence"], }, }, }, { "type": "function", "function": { "name": "mark_for_relearn", "description": ( "當新數據與 KM 既有洞察矛盾,或告警方向被推翻時," "將該商品的歷史洞察標記為需重新學習(relearn)。" "適用於:此次分析結果與上次截然不同的情況。" ), "parameters": { "type": "object", "properties": { "sku": {"type": "string", "description": "商品 SKU 編號"}, "name": {"type": "string", "description": "商品名稱"}, "reason": {"type": "string", "description": "標記原因說明(繁體中文)"}, }, "required": ["sku", "name", "reason"], }, }, }, ] # ── 金額影響量化(B' 軌:告警必須攜帶可決策的金額數字) ── def _compute_business_impact(threat) -> dict: """從 PriceThreat 計算「過去 7 日營收流失」與「建議調價金額」。 回傳純 Python 客觀計算結果,由 dispatcher 強制注入告警 — LLM 不得碰觸這些數字。 revenue_loss_7d = max(0, sales_7d_prev_amount - sales_7d_curr_amount) **僅在 gap_pct > 0 時** 語意:「我方比競品貴,且過去 7 日銷量金額下滑 → 推估價格因素導致的流失」 若 gap_pct ≤ 0(我方已便宜或持平)即使銷量下滑亦歸 0,避免把 季節性/商品壽命終結等非價格因素誤標為「流失」誘導降價(Critic Medium-3 fix) recommended_price = round(pchome_price) 語意:「跟進競品的最低調價金額」;統帥可基於此再依毛利策略加溢價 gap_pct ≤ 0(我方已便宜或持平)→ recommended_price=None(不需調價) """ try: gap_pct = float(getattr(threat, "gap_pct", 0) or 0) except (TypeError, ValueError): gap_pct = 0.0 revenue_loss_7d = 0.0 if gap_pct > 0: try: prev = float(getattr(threat, "sales_7d_prev_amount", 0) or 0) curr = float(getattr(threat, "sales_7d_curr_amount", 0) or 0) revenue_loss_7d = max(0.0, prev - curr) except (TypeError, ValueError): revenue_loss_7d = 0.0 recommended_price = None try: pchome = float(getattr(threat, "pchome_price", 0) or 0) if pchome > 0 and gap_pct > 0: recommended_price = round(pchome) except (TypeError, ValueError): pass return { "revenue_loss_7d": revenue_loss_7d, "recommended_price": recommended_price, } # ── 語意化 Emoji 字典 ────────────────────────────────── # 身份識別 HEADER_DISPATCHER = "⚡ NemoTron 派發器" # 風險級別 ICON_CRITICAL = "🚨" # 高危險/立即行動 ICON_WARNING = "⚠️" # 中風險/AI 例外決策 ICON_INSIGHT = "💡" # 低風險/策略建議 ICON_REPORT = "📊" # 例行報告 # 業務屬性 ICON_PRICE = "💰" ICON_SALES = "📦" ICON_COMPETE = "🏆" ICON_AI = "🧠" ICON_FOOTPRINT = "⚙️" MATCH_TYPE_LABELS = { "exact": "高信心同款", "same_product_different_pack": "同商品不同包裝", "same_line_variant": "同系列不同款", "comparable": "可比但需覆核", "no_match": "非同款", } PRICE_BASIS_LABELS = { "total_price": "總價可比", "unit_price": "單位價可比", "manual_review": "AI 例外決策後可比", "none": "不可比", } ALERT_TIER_LABELS = { "price_alert_exact": "可直接價格告警", "unit_price_review": "單位價覆核", "identity_review": "身份覆核", "suppress": "不告警", } def _threat_match_metadata(threat) -> dict: return { "match_type": getattr(threat, "match_type", "exact") or "exact", "price_basis": getattr(threat, "price_basis", "total_price") or "total_price", "alert_tier": getattr(threat, "alert_tier", "price_alert_exact") or "price_alert_exact", "match_score": float(getattr(threat, "match_score", 0) or 0), "competitor_product_id": getattr(threat, "competitor_product_id", "") or "", "competitor_product_name": getattr(threat, "competitor_product_name", "") or "", } def _can_direct_price_alert(threat) -> bool: meta = _threat_match_metadata(threat) return ( meta["match_type"] == "exact" and meta["price_basis"] == "total_price" and meta["alert_tier"] == "price_alert_exact" ) def _format_match_evidence_block( *, match_type: str = "", price_basis: str = "", alert_tier: str = "", match_score: float = 0.0, competitor_product_id: str = "", competitor_product_name: str = "", ) -> str: match_type = match_type or "exact" price_basis = price_basis or "total_price" alert_tier = alert_tier or "price_alert_exact" lines = [ f"{ICON_COMPETE} 比對證據:", f"• 身份分級:{MATCH_TYPE_LABELS.get(match_type, match_type)}", f"• 比價基準:{PRICE_BASIS_LABELS.get(price_basis, price_basis)}", f"• 告警路徑:{ALERT_TIER_LABELS.get(alert_tier, alert_tier)}", ] if match_score: lines.append(f"• Match score:{match_score:.3f}") if competitor_product_id: lines.append(f"• PChome ID:{competitor_product_id}") if competitor_product_name: lines.append(f"• PChome 品名:{str(competitor_product_name)[:68]}") return "\n".join(lines) + "\n\n" def _safe_float(value, default: float = 0.0) -> float: try: return float(value) except (TypeError, ValueError): return default def _price_decision_data_quality( momo_price, comp_price, match_score: float, match_type: str, price_basis: str, alert_tier: str, ) -> str: momo_ok = _safe_float(momo_price) > 0 comp_ok = _safe_float(comp_price) > 0 identity_ok = bool(match_type and price_basis and alert_tier) score_ok = _safe_float(match_score) > 0 if momo_ok and comp_ok and identity_ok and score_ok: return "complete" if momo_ok or comp_ok or identity_ok: return "partial" return "missing" def _price_decision_severity( *, decision_type: str, gap_pct, revenue_loss_7d: float, alert_tier: str, ) -> str: gap = abs(_safe_float(gap_pct)) loss = _safe_float(revenue_loss_7d) if decision_type == "price_alert" and alert_tier == "price_alert_exact": if gap >= 15 or loss >= 50000: return "P1" return "P2" if loss >= 50000 or gap >= 20: return "P2" return "P3" def _build_price_decision_envelope( *, decision_type: str, sku: str, name: str, gap_pct, sales_delta, confidence: float, analysis: str, momo_price=None, comp_price=None, revenue_loss_7d: float = 0.0, recommended_price: Optional[float] = None, match_type: str = "", price_basis: str = "", alert_tier: str = "", match_score: float = 0.0, competitor_product_id: str = "", competitor_product_name: str = "", ) -> dict: """建立 12 Agent 共用的價格決策信封;只描述證據,不執行價格或匹配覆寫。""" match_type = match_type or "unknown" price_basis = price_basis or "manual_review" alert_tier = alert_tier or "identity_review" match_score_value = _safe_float(match_score) confidence_value = max(0.0, min(1.0, _safe_float(confidence, 0.0))) gap_value = _safe_float(gap_pct) sales_value = _safe_float(sales_delta) momo_value = _safe_float(momo_price) comp_value = _safe_float(comp_price) loss_value = _safe_float(revenue_loss_7d) gap_amount = None if momo_value > 0 and comp_value > 0: gap_amount = round(momo_value - comp_value, 2) data_quality = _price_decision_data_quality( momo_price=momo_price, comp_price=comp_price, match_score=match_score_value, match_type=match_type, price_basis=price_basis, alert_tier=alert_tier, ) severity = _price_decision_severity( decision_type=decision_type, gap_pct=gap_value, revenue_loss_7d=loss_value, alert_tier=alert_tier, ) evidence = [ { "type": "match", "metric": "match_score", "value": round(match_score_value, 3), "basis": f"{match_type}/{price_basis}/{alert_tier}", "confidence": round(match_score_value, 3) if match_score_value else None, }, { "type": "price", "metric": "gap_pct", "value": f"{gap_value:+.1f}%", "basis": "MOMO latest price + PChome competitor_prices", }, { "type": "sales", "metric": "sales_7d_delta_pct", "value": f"{sales_value:+.1f}%", "basis": "daily_sales_snapshot 7d vs previous 7d", }, ] if loss_value > 0: evidence.append({ "type": "impact", "metric": "revenue_loss_7d", "value": round(loss_value, 2), "basis": "sales_7d_prev_amount - sales_7d_curr_amount", }) action = "price_follow_review" if decision_type == "price_alert" else "identity_or_price_review" blocked_reason = ( "價格調整需 AI 例外決策;不得自動寫入或覆蓋正式競品價格" if decision_type == "price_alert" else "身份、包裝、單位價或前台狀態需 AI 自動驗證確認" ) risk_reduction = "high" if severity == "P1" else ("medium" if severity == "P2" else "watch") return { "decision_id": f"nemotron:{decision_type}:{sku}:{uuid.uuid4().hex[:8]}", "source_agent": "nemotron", "decision_type": decision_type, "severity": severity, "subject": { "sku": sku, "name": name, "event_type": "price_competition", "momo_price": momo_value if momo_value > 0 else None, "competitor_price": comp_value if comp_value > 0 else None, "competitor_product_id": competitor_product_id, "competitor_product_name": str(competitor_product_name or "")[:120], }, "evidence": evidence, "analysis": _sanitize_text(analysis, fallback="請 AI 自動驗證確認", max_len=300), "recommended_action": { "action": action, "owner": "營運", LEGACY_REVIEW_GATE_KEY: False, "requires_ai_exception": True, }, "expected_impact": { "momo_price": momo_value if momo_value > 0 else None, "competitor_price": comp_value if comp_value > 0 else None, "candidate_gap_pct": round(gap_value, 1), "sales_7d_delta_pct": round(sales_value, 1), "revenue_loss_7d": round(loss_value, 2), "gap_amount": gap_amount, "recommended_price": recommended_price, "risk_reduction": risk_reduction, }, "confidence": round(confidence_value, 3), "guardrails": { "can_auto_execute": False, "blocked_reason": blocked_reason, "data_quality": data_quality, "match_type": match_type, "price_basis": price_basis, "alert_tier": alert_tier, }, "trace": { "model": NEMOTRON_OLLAMA_MODEL if NEMOTRON_OLLAMA_FIRST else NIM_MODEL, "provider": "nemotron_dispatcher", }, } # ── tool_calls 解析(NIM 與 qwen3 共用)────────────────────────── def _parse_tool_calls_struct(tool_calls: list) -> list: """從 OpenAI 格式的 tool_calls 結構陣列抽出 [{tool, args}] 清單。 NIM 與 qwen3 (Ollama /api/chat) 兩邊回應對齊 OpenAI schema: [{"function": {"name": ..., "arguments": }, ...}] arguments 在 NIM 是 JSON 字串、在 Ollama 通常已是 dict;本 helper 兼容兩者。 """ results = [] for tc in tool_calls or []: fn = tc.get("function", {}) if isinstance(tc, dict) else {} if not fn: continue raw_args = fn.get("arguments", {}) if isinstance(raw_args, str): try: args = json.loads(raw_args) if raw_args.strip() else {} except json.JSONDecodeError: args = {} elif isinstance(raw_args, dict): args = raw_args else: args = {} name = fn.get("name") if name: results.append({"tool": name, "args": args}) return results def _parse_content_fallback(raw_content: str) -> list: """當模型沒回 tool_calls 結構、把工具呼叫塞進 content 時嘗試解析。 既有 NIM llama-3.1-8b 偶有此行為(line 537-554 原邏輯); qwen3 開 thinking_mode=False 後較少見,但保留同等容錯。 """ if not raw_content or not isinstance(raw_content, str): return [] try: parsed = json.loads(raw_content.strip()) except Exception as parse_err: logger.error(f"[ToolCalls] content fallback JSON 解析失敗:{parse_err}") return [] if not isinstance(parsed, list): return [] results = [] for item in parsed: if not isinstance(item, dict): continue name = item.get("name") or (item.get("function", {}) or {}).get("name") args = item.get("parameters") or item.get("arguments") or {} if isinstance(args, str): try: args = json.loads(args) except json.JSONDecodeError: args = {} if name: results.append({"tool": name, "args": args}) if results: logger.info(f"[ToolCalls] content fallback 解析成功,取得 {len(results)} 個 tool_calls") return results def _build_footprint_json(hermes_stats: Optional[dict], nim_stats: Optional[dict]) -> dict: """ 建立結構化運算足跡 (用於 DB model_footprint JSONB 欄位) Returns: {"analyst": {...}, "dispatcher": {...}} """ result = {} if hermes_stats: result["analyst"] = { "model": "qwen2.5:7b-instruct", "host": hermes_stats.get("host", HERMES_URL), "host_label": hermes_stats.get("host_label", "未知"), "duration_sec": hermes_stats.get("duration_sec", 0), "tokens": hermes_stats.get("tokens", 0), "cost_usd": 0, } if nim_stats: result["dispatcher"] = { "model": NIM_MODEL, "platform": "NVIDIA NIM", "total_tokens": nim_stats.get("total_tokens", 0), "quota_used": nim_stats.get("quota_used", 0), "cost_usd": 0, } return result def _build_footprint_block(hermes_stats: Optional[dict], nim_stats: Optional[dict]) -> str: """ 建立底部運算足跡區塊(Telegram 顯示用) Args: hermes_stats: {"duration_sec": float, "tokens": int} nim_stats: {"total_tokens": int, "quota_used": int} Returns: 分隔線 + ⚙️ 運算足跡 文字區塊 """ lines = ["─────────────────────", f"{ICON_FOOTPRINT} 運算足跡:"] if hermes_stats: dur = hermes_stats.get("duration_sec", 0) tok = hermes_stats.get("tokens", "?") label = hermes_stats.get("host_label", "本地 188") lines.append( f"• 🔍 分析: Qwen2.5 7B ({label}) | " f"耗時: {dur:.1f}s | Tokens: {tok} | $0 成本" ) else: lines.append("• 🔍 分析: Qwen2.5 7B (未知主機) | $0 成本") if nim_stats: tok = nim_stats.get("total_tokens", "?") quota = nim_stats.get("quota_used", "?") lines.append( f"• ⚡ 決策: NemoTron NIM | " f"{tok} Tokens | $0 (配額內 {quota}/{NIM_DAILY_LIMIT})" ) else: lines.append(f"• ⚡ 決策: NemoTron NIM | $0 (配額內)") return "\n".join(lines) class NemotronDispatcher: """ NemoTron 行動派發器 接收 Hermes 輸出 → NIM tool calling 決策 → 語意化 Telegram 告警 / DB 寫入 """ def __init__(self, notification_manager=None, engine=None): """ Args: notification_manager: NotificationManager 實例(負責 Telegram 推播) engine: SQLAlchemy engine(用於寫入推薦商品 DB) """ self.nm = notification_manager self.engine = engine # ────────────────────────────────────────────── # NIM Tool Calling # ────────────────────────────────────────────── def _call_nim(self, threats: list) -> tuple: """ 將 Hermes 威脅清單交給 NIM,取得 tool_calls 決策清單 Returns: (list of {"tool": str, "args": dict}, dict nim_stats) nim_stats: {"total_tokens": int, "quota_used": int} """ if not NIM_API_KEY: logger.warning("[NemotronDispatcher] NVIDIA_API_KEY 未設定,跳過 NIM 呼叫") raise RuntimeError("NVIDIA_API_KEY not configured") threat_summary = json.dumps( [ { "sku": t.sku, "name": t.name, "momo_price": t.momo_price, "pchome_price": t.pchome_price, "gap_pct": t.gap_pct, "sales_delta": t.sales_7d_delta_pct, "risk": t.risk, "action": t.recommended_action, "confidence": t.confidence, **_threat_match_metadata(t), } for t in threats ], ensure_ascii=False, ) # 注入 MCP 市場上下文 mcp_ctx = build_mcp_context() messages = [ { "role": "system", "content": ( "你是台灣電商競價情報的行動派發器。" f"當前市場背景 (MCP):\n{mcp_ctx}\n\n" "根據 Hermes 分析師提供的威脅清單,決定對每支商品呼叫哪個工具。\n" "路由鐵律(依序判斷,命中即停):\n" "1. match_type 不是 exact,或 price_basis 不是 total_price,或 alert_tier 不是 price_alert_exact " "→ 不可直接價格告警,呼叫 flag_for_human_review,concern 說明需覆核身份、包裝或單位價。\n" "2. gap_pct < 5% 且 sales_delta < -30% → 非價格異常,呼叫 flag_for_human_review," "concern 說明『價差接近 0 但銷量大幅下滑,疑似缺貨/下架/平台流量異常,請 AI 例外走查前台』。\n" "3. gap_pct ≥ 5% 且 risk=HIGH → trigger_price_alert(填入 momo_price, comp_price)。\n" "4. 我方價格低於競品且銷量正成長 → add_to_recommendation。\n" "5. confidence < 0.6 或其他複雜情況 → flag_for_human_review。\n" "每支商品只呼叫一個工具。\n" "【語言鐵律 — 台灣標準正體中文(繁體)】所有文字欄位必須遵守:\n" " 1. 嚴禁簡體字(例:不可用「参给当为来国发会说时间过从实现这话动问题」," " 必須用「參給當為來國發會說時間過從實現這話動問題」)\n" " 2. 嚴禁異體字(例:不可用「亊」,必須用「事」)\n" " 3. 嚴禁短語重複(例:不可輸出「當前事當前事」這種坍塌)\n" " 4. 嚴禁無意義字元組合或亂碼\n" "若無法產出合理的繁體中文說明,直接輸出「請人工評估議價空間」。" ), }, { "role": "user", "content": f"請處理以下 {len(threats)} 筆威脅清單:\n{threat_summary}", }, ] # P1-4 修復:NIM API 指數退避 retry(最多 3 次) # Phase 1 v5.0: 包 ai_call_logger 追蹤 NIM 配額/tokens/錯誤 import time as _time last_err = None with log_ai_call( caller='nemotron_dispatch', provider='nim', model=NIM_MODEL, meta={'threat_count': len(threats), 'quota_used': _nim_quota_used()}, ) as _ctx: for _attempt in range(3): try: resp = requests.post( f"{NIM_BASE_URL}/chat/completions", headers={ "Authorization": f"Bearer {NIM_API_KEY}", "Content-Type": "application/json", }, json={ "model": NIM_MODEL, "messages": messages, "tools": TOOLS, "tool_choice": "required", "max_tokens": 2048, }, timeout=NIM_TIMEOUT, ) resp.raise_for_status() 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 迴圈") _ctx.set_error(f"NIM 429 rate-limited") _ctx.fallback_to_caller('hermes_rule_engine') raise if _attempt < 2: _time.sleep(2 ** _attempt) logger.warning(f"[NIM] retry {_attempt + 1}/2 after {e}") else: raise last_err body = resp.json() usage = body.get("usage", {}) # 記錄 token / 成本到 ai_calls 表 _ctx.set_tokens( input=usage.get("prompt_tokens", 0), output=usage.get("completion_tokens", 0), ) nim_stats = { "total_tokens": usage.get("total_tokens", 0), "quota_used": _nim_quota_used(), } choices = body.get("choices", []) message = choices[0].get("message", {}) if choices else {} tool_calls = message.get("tool_calls", []) or [] # 共用結構解析(NIM / qwen3 兩邊統一走同一條) results = _parse_tool_calls_struct(tool_calls) if not results: # llama-3.1-8b-instruct 有時把 tool call 寫進 content 而非 tool_calls 結構 raw_content = message.get("content", "") or "" logger.warning(f"[NIM] 0 tool_calls,嘗試從 content 解析:{raw_content[:120]}") results = _parse_content_fallback(raw_content) logger.info(f"[NIM] 收到 {len(results)} 個 tool_calls | tokens={nim_stats['total_tokens']}") return results, nim_stats # ────────────────────────────────────────────── # GCP Ollama qwen3:14b Tool Calling(Operation Ollama-First v5.0 / Phase 3) # ────────────────────────────────────────────── def _call_qwen3_dispatch(self, threats: list) -> tuple: """ 將 Hermes 威脅清單交給 GCP Ollama qwen3:14b,取得 tool_calls 決策。 Why qwen3:14b(A2 web-research 結論,docs/phase0_research_report_20260503.md): - Ollama registry 官方頁 + qwenlm.github.io 雙確認 tools capability 可用 - 預設可關閉 thinking mode(避免 deepseek-r1 的 30s thinking 延遲) - 14B 體積 9.3GB,與 deepseek-r1:14b 同級 - 與 NIM 一致採 OpenAI 兼容 chat completion + tools schema Returns: (list of {"tool": str, "args": dict}, dict ollama_stats) ollama_stats: {"total_tokens": int, "host": str, "model": str} """ from services.ollama_service import ( get_host_label, get_provider_tag, mark_unhealthy, resolve_ollama_host, ) threat_summary = json.dumps( [ { "sku": t.sku, "name": t.name, "momo_price": t.momo_price, "pchome_price": t.pchome_price, "gap_pct": t.gap_pct, "sales_delta": t.sales_7d_delta_pct, "risk": t.risk, "action": t.recommended_action, "confidence": t.confidence, **_threat_match_metadata(t), } for t in threats ], ensure_ascii=False, ) # 注入 MCP 市場上下文(與 NIM 路徑一致) mcp_ctx = build_mcp_context() # System prompt 與 NIM 完全一致(避免兩套維護) system_prompt = ( "你是台灣電商競價情報的行動派發器。" f"當前市場背景 (MCP):\n{mcp_ctx}\n\n" "根據 Hermes 分析師提供的威脅清單,決定對每支商品呼叫哪個工具。\n" "路由鐵律(依序判斷,命中即停):\n" "1. match_type 不是 exact,或 price_basis 不是 total_price,或 alert_tier 不是 price_alert_exact " "→ 不可直接價格告警,呼叫 flag_for_human_review,concern 說明需覆核身份、包裝或單位價。\n" "2. gap_pct < 5% 且 sales_delta < -30% → 非價格異常,呼叫 flag_for_human_review," "concern 說明『價差接近 0 但銷量大幅下滑,疑似缺貨/下架/平台流量異常,請 AI 例外走查前台』。\n" "3. gap_pct ≥ 5% 且 risk=HIGH → trigger_price_alert(填入 momo_price, comp_price)。\n" "4. 我方價格低於競品且銷量正成長 → add_to_recommendation。\n" "5. confidence < 0.6 或其他複雜情況 → flag_for_human_review。\n" "每支商品只呼叫一個工具。\n" "【語言鐵律 — 台灣標準正體中文(繁體)】所有文字欄位必須遵守:\n" " 1. 嚴禁簡體字、嚴禁異體字(例:不可用「亊」,必須用「事」)\n" " 2. 嚴禁短語重複(語意坍塌)、嚴禁無意義字元組合\n" "若無法產出合理的繁體中文說明,直接輸出「請人工評估議價空間」。" ) payload = { "model": NEMOTRON_OLLAMA_MODEL, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": f"請處理以下 {len(threats)} 筆威脅清單:\n{threat_summary}"}, ], "tools": TOOLS, # 重用既有 NIM tools schema "stream": False, "options": { "temperature": 0.2, "num_predict": 2048, }, } with log_ai_call( caller='nemotron_dispatch', provider='gcp_ollama', model=NEMOTRON_OLLAMA_MODEL, request_id=f"nem-{int(time.time())}", meta={ 'flag': 'NEMOTRON_OLLAMA_FIRST', 'threats_count': len(threats), }, ) as ctx: attempted_hosts = [] body = None host = None last_error = None for _attempt in range(3): host = resolve_ollama_host().rstrip("/") if host in attempted_hosts: break attempted_hosts.append(host) try: resp = requests.post( f"{host}/api/chat", json=payload, timeout=NEMOTRON_OLLAMA_TIMEOUT, ) resp.raise_for_status() body = resp.json() ctx.set_provider(get_provider_tag(host)) ctx.add_meta('host', host) ctx.add_meta('host_label', get_host_label(host)) ctx.add_meta('attempted_hosts', attempted_hosts) break except Exception as e: last_error = e # 連線/HTTP 失敗 → 標記主機 unhealthy,下一輪依序嘗試 GCP-B / 111。 mark_unhealthy(host) logger.warning( "[Dispatcher][qwen3] host=%s 呼叫失敗,嘗試下一台: %s", host, e, ) if body is None: ctx.set_error( f"qwen3 call failed after {len(attempted_hosts)} host(s): " f"{type(last_error).__name__}: {last_error}" ) ctx.fallback_to_caller('nim') raise RuntimeError(last_error or "qwen3 all hosts failed") ctx.set_tokens( input=body.get('prompt_eval_count', 0), output=body.get('eval_count', 0), ) msg = body.get('message', {}) if isinstance(body, dict) else {} tool_calls = msg.get('tool_calls', []) or [] # 走共用 tool_calls 結構解析(與 NIM 同一條 helper) results = _parse_tool_calls_struct(tool_calls) if not results: # qwen3 沒回 tool_calls → 走既有 content fallback 解析 raw_content = msg.get('content', '') or '' logger.warning( f"[Dispatcher][qwen3] 0 tool_calls,嘗試從 content 解析:{raw_content[:120]}" ) results = _parse_content_fallback(raw_content) ollama_stats = { "total_tokens": (body.get('prompt_eval_count', 0) or 0) + (body.get('eval_count', 0) or 0), "host": host, "host_label": get_host_label(host), "provider": get_provider_tag(host), "model": NEMOTRON_OLLAMA_MODEL, } logger.info( f"[Dispatcher][qwen3] 收到 {len(results)} 個 tool_calls | " f"tokens={ollama_stats['total_tokens']} host={host}" ) return results, ollama_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: # B' 軌:每個 threat 預先算金額影響,所有路徑統一注入 impact = _compute_business_impact(t) rl, rp = impact["revenue_loss_7d"], impact["recommended_price"] match_meta = _threat_match_metadata(t) if not _can_direct_price_alert(t): self._exec_flag_for_human_review( sku=t.sku, name=t.name, concern=( "🟡 [規則引擎] 比對證據尚未達直接價格告警門檻;" f"match_type={match_meta['match_type']}、" f"price_basis={match_meta['price_basis']}、" f"alert_tier={match_meta['alert_tier']}。" "請先覆核是否為同款、同包裝或需改用單位價。" ), confidence=max(float(getattr(t, "confidence", 0.5) or 0.5), 0.75), footprint=footprint, momo_price=t.momo_price, comp_price=t.pchome_price, gap_pct=t.gap_pct, sales_delta=t.sales_7d_delta_pct, revenue_loss_7d=rl, recommended_price=rp, **match_meta, ) dispatched += 1 continue if t.gap_pct < 5 and t.sales_7d_delta_pct < -30: # Rule 1:價差微小但銷量大跌 → 非定價問題,AI 自動驗證確認 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}%,疑似缺貨/下架/平台流量異常," "請 AI 例外走查前台。" ), 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, revenue_loss_7d=rl, recommended_price=rp, **_threat_match_metadata(t), ) 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, revenue_loss_7d=rl, recommended_price=rp, **_threat_match_metadata(t), ) 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:其餘複雜情況 → AI 例外決策 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, revenue_loss_7d=rl, recommended_price=rp, **_threat_match_metadata(t), ) 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}} # ────────────────────────────────────────────── # 語意化訊息格式器 # ────────────────────────────────────────────── @staticmethod def _fmt_price_alert( sku: str, name: str, momo_price, comp_price, gap_pct: float, sales_delta: float, action: str, confidence: float, footprint: str, revenue_loss_7d: float = 0.0, recommended_price: Optional[float] = None, match_type: str = "exact", price_basis: str = "total_price", alert_tier: str = "price_alert_exact", match_score: float = 0.0, competitor_product_id: str = "", competitor_product_name: str = "", ) -> str: """ 類別一:緊急告警 倒金字塔:結論先行 → 核心數據 → 金額影響 → AI 洞察 → 運算足跡 [2026-04-18 台北] Bug-3 防線三 UI 物理隔離: - 核心問題 = Python 客觀組字(價差 X% / 銷量 Y%),不碰 AI 文字 - 關鍵數據 = Python 獨裁注入,None/0 降級為 N/A 而非 $0 - AI 洞察 = action 唯一使用位置;移除假冒「Hermes 分析師研判」標籤 (action 實為 NemoTron 輸出,非 Hermes) — Claude Opus 4.7 [2026-05-02 台北] B' 軌:金額影響量化 — Claude Opus 4.7 - revenue_loss_7d / recommended_price 純 Python 計算(_compute_business_impact) - 解決「告警內容空泛、人類無可批准的具體動作」根因 """ conf_pct = int(confidence * 100) # 客觀數據(None/0 降級避免 $0 幻覺) mp_str = f"${momo_price:,.0f}" if momo_price not in (None, 0) else "N/A(資料缺失)" cp_str = f"${comp_price:,.0f}" if comp_price not in (None, 0) else "N/A(資料缺失)" # 核心問題:Python 客觀組字,不碰 AI 文字 core_issue = f"價差 {gap_pct:+.1f}% / 近七天銷量 {sales_delta:+.1f}%" # 金額影響區塊(B' 軌新增) impact_lines = [] if revenue_loss_7d and revenue_loss_7d > 0: impact_lines.append(f"📉 過去 7 日營收流失:NT$ {revenue_loss_7d:,.0f}") if recommended_price is not None and recommended_price > 0: impact_lines.append( f"🎯 跟進競品建議價:NT$ {recommended_price:,.0f}" f"(毛利策略可再加溢價)" ) impact_block = ("\n".join(impact_lines) + "\n\n") if impact_lines else "" match_block = _format_match_evidence_block( match_type=match_type, price_basis=price_basis, alert_tier=alert_tier, match_score=match_score, competitor_product_id=competitor_product_id, competitor_product_name=competitor_product_name, ) # AI 洞察:唯一允許 LLM 文字進入的欄位 ai_insight = _sanitize_text(action, fallback="請人工評估議價空間") return ( f"{ICON_CRITICAL} [{HEADER_DISPATCHER}] 競價高危險預警\n\n" f"{ICON_WARNING} 核心問題:[{sku}] {name}\n" f"{core_issue}\n\n" f"{ICON_REPORT} 關鍵數據:\n" f"• 我方價格:{mp_str}\n" f"• 競品價格:{cp_str}\n" f"• 銷量變化:{sales_delta:+.1f}%\n\n" f"{match_block}" f"{impact_block}" f"{ICON_AI} AI 洞察(信心度 {conf_pct}%):\n" f"{ai_insight}\n\n" f"{footprint}" ) @staticmethod def _fmt_human_review( sku: str, name: str, concern: str, footprint: str, momo_price: float = None, comp_price: float = None, gap_pct: float = None, sales_delta: float = None, revenue_loss_7d: float = 0.0, recommended_price: Optional[float] = None, match_type: str = "", price_basis: str = "", alert_tier: str = "", match_score: float = 0.0, competitor_product_id: str = "", competitor_product_name: str = "", ) -> str: """ 類別二:AI 例外決策 客觀數據由 Python 注入(防幻覺),AI 診斷隔離在獨立欄位 B' 軌:補金額影響欄位 """ # 客觀數據快照(100% Python,不經 LLM) if momo_price is not None and comp_price is not None: data_block = ( f"{ICON_REPORT} 客觀數據快照:\n" f"• 我方價格:${momo_price:,.0f}\n" f"• 競品價格:${comp_price:,.0f}(價差 {gap_pct:+.1f}%)\n" f"• 七天銷量變化:{sales_delta:+.1f}%\n" ) else: data_block = f"{ICON_REPORT} 客觀數據:(無競品比價數據)\n" # 金額影響(B' 軌新增) impact_lines = [] if revenue_loss_7d and revenue_loss_7d > 0: impact_lines.append(f"📉 過去 7 日營收流失:NT$ {revenue_loss_7d:,.0f}") if recommended_price is not None and recommended_price > 0: impact_lines.append(f"🎯 跟進競品建議價:NT$ {recommended_price:,.0f}") impact_block = ("\n".join(f"• {l}" for l in impact_lines) + "\n") if impact_lines else "" match_block = _format_match_evidence_block( match_type=match_type or "unknown", price_basis=price_basis or "manual_review", alert_tier=alert_tier or "identity_review", match_score=match_score, competitor_product_id=competitor_product_id, competitor_product_name=competitor_product_name, ) return ( f"{ICON_WARNING} [{HEADER_DISPATCHER}] 異常波動需 AI 例外決策\n\n" f"🔍 待查商品:[{sku}] {name}\n\n" f"{data_block}" f"{match_block}" f"{impact_block}\n" f"🧠 AI 診斷:\n" f"{concern}\n\n" f"👉 建議行動:請營運人員立即進行前台走查。\n\n" f"{footprint}" ) @staticmethod def _fmt_recommendation( sku: str, name: str, reason: str, confidence: float, db_written: bool, footprint: str, ) -> str: """ 類別三:策略執行通知 """ conf_pct = int(confidence * 100) db_status = "✅ 系統已自動寫入 ai_price_recommendations 推薦表" if db_written \ else "⚠️ DB 未注入,僅發送通知(表尚未建立)" return ( f"{ICON_INSIGHT} [{HEADER_DISPATCHER}] 潛力商品自動佈署\n\n" f"{ICON_COMPETE} 推薦品項:[{sku} {name}] 已自動加入「首頁推薦區塊」\n\n" f"{ICON_REPORT} 決策依據:\n" f"{reason}\n\n" f"{ICON_AI} AI 洞察 (信心度 {conf_pct}%):\n" f"具備價格競爭優勢,NemoTron 主動提升曝光量以最大化業績。\n\n" f"👉 執行狀態:{db_status}\n\n" f"{footprint}" ) # ────────────────────────────────────────────── # 工具實作 # ────────────────────────────────────────────── def _exec_trigger_price_alert( self, sku: str, name: str, gap_pct: float, sales_delta: float, action: str, confidence: float, momo_price=None, comp_price=None, footprint: str = "", revenue_loss_7d: float = 0.0, recommended_price: Optional[float] = None, match_type: str = "exact", price_basis: str = "total_price", alert_tier: str = "price_alert_exact", match_score: float = 0.0, competitor_product_id: str = "", competitor_product_name: str = "", ): """發送語意化競價高危險預警 [2026-04-18 台北] Bug-1 防線一 保險:default 改 None,避免 LLM 漏吐 → 舊版 default=0 → Telegram 顯示 $0。Layer A Hermes 已根治,這層是第二道屏障 — Claude Opus 4.7 [2026-05-02 台北] B' 軌:revenue_loss_7d / recommended_price 純 Python 注入 — Claude Opus 4.7 """ msg = self._fmt_price_alert( sku, name, momo_price, comp_price, gap_pct, sales_delta, action, confidence, footprint, revenue_loss_7d=revenue_loss_7d, recommended_price=recommended_price, match_type=match_type, price_basis=price_basis, alert_tier=alert_tier, match_score=match_score, competitor_product_id=competitor_product_id, competitor_product_name=competitor_product_name, ) decision_envelope = _build_price_decision_envelope( decision_type="price_alert", sku=sku, name=name, gap_pct=gap_pct, sales_delta=sales_delta, confidence=confidence, analysis=action, momo_price=momo_price, comp_price=comp_price, revenue_loss_7d=revenue_loss_7d, recommended_price=recommended_price, match_type=match_type, price_basis=price_basis, alert_tier=alert_tier, match_score=match_score, competitor_product_id=competitor_product_id, competitor_product_name=competitor_product_name, ) self._send_telegram(msg, decision_envelope=decision_envelope) logger.info( f"[Dispatcher] 競價告警 → {sku} gap={gap_pct:.1f}% sales={sales_delta:.1f}% " f"loss=${revenue_loss_7d:,.0f} rec_price={recommended_price}" ) # ADR-007 雙寫:沉澱到 ai_insights 供日後 RAG self._sink_insight_to_km( insight_type="price_alert", sku=sku, name=name, content=f"[高危險告警] {name} 價差 {gap_pct:+.1f}% / 銷量 {sales_delta:+.1f}%。行動:{action}", metadata={"gap_pct": gap_pct, "sales_delta": sales_delta, "confidence": confidence, "momo_price": momo_price, "comp_price": comp_price, "revenue_loss_7d": revenue_loss_7d, "recommended_price": recommended_price, "match_type": match_type, "price_basis": price_basis, "alert_tier": alert_tier, "match_score": match_score, "competitor_product_id": competitor_product_id, "decision_envelope": decision_envelope}, ) def _exec_add_to_recommendation( self, sku: str, name: str, reason: str, confidence: float, footprint: str = "", footprint_data: Optional[dict] = None, # 結構化 JSON,寫入 model_footprint 欄位 threat=None, # PriceThreat 物件,用於寫入完整數據快照 ): """寫入前台推薦商品 DB + 語意化 Telegram 通知""" db_written = False if self.engine: try: from sqlalchemy import text footprint_json = json.dumps(footprint_data or {}, ensure_ascii=False) with self.engine.begin() as conn: conn.execute(text(""" INSERT INTO ai_price_recommendations (sku, name, reason, strategy, confidence, momo_price, pchome_price, gap_pct, sales_7d_delta, model_footprint, status, created_at, updated_at) VALUES (:sku, :name, :reason, 'promote', :confidence, :momo_price, :pchome_price, :gap_pct, :sales_delta, :footprint, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT (sku) DO UPDATE SET reason = EXCLUDED.reason, confidence = EXCLUDED.confidence, momo_price = EXCLUDED.momo_price, pchome_price = EXCLUDED.pchome_price, gap_pct = EXCLUDED.gap_pct, sales_7d_delta = EXCLUDED.sales_7d_delta, model_footprint = EXCLUDED.model_footprint, status = 'pending', updated_at = CURRENT_TIMESTAMP """), { "sku": sku, "name": name, "reason": reason, "confidence": confidence, "momo_price": getattr(threat, "momo_price", None) if threat else None, "pchome_price": getattr(threat, "pchome_price", None) if threat else None, "gap_pct": getattr(threat, "gap_pct", None) if threat else None, "sales_delta": getattr(threat, "sales_7d_delta_pct", None) if threat else None, "footprint": footprint_json, }) db_written = True logger.info(f"[Dispatcher] 推薦商品寫入 DB → {sku}") except Exception as e: logger.error(f"[Dispatcher] DB 寫入失敗 {sku}: {e}") msg = self._fmt_recommendation( sku, name, reason, confidence, db_written, footprint, ) self._send_telegram(msg) # ADR-007 雙寫 self._sink_insight_to_km( insight_type="recommendation", sku=sku, name=name, content=f"[推薦商品] {name}。原因:{reason}", metadata={"confidence": confidence, "db_written": db_written}, ) def _exec_flag_for_human_review( self, sku: str, name: str, concern: str, confidence: float, footprint: str = "", momo_price: float = None, comp_price: float = None, gap_pct: float = None, sales_delta: float = None, revenue_loss_7d: float = 0.0, recommended_price: Optional[float] = None, match_type: str = "", price_basis: str = "", alert_tier: str = "", match_score: float = 0.0, competitor_product_id: str = "", competitor_product_name: str = "", ): """發送語意化AI 例外決策請求""" concern = _sanitize_text(concern, fallback=f"數據走勢違背常理,疑似缺貨或前台異常。") msg = self._fmt_human_review( sku, name, concern, footprint, momo_price=momo_price, comp_price=comp_price, gap_pct=gap_pct, sales_delta=sales_delta, revenue_loss_7d=revenue_loss_7d, recommended_price=recommended_price, match_type=match_type, price_basis=price_basis, alert_tier=alert_tier, match_score=match_score, competitor_product_id=competitor_product_id, competitor_product_name=competitor_product_name, ) decision_envelope = _build_price_decision_envelope( decision_type="human_review", sku=sku, name=name, gap_pct=gap_pct, sales_delta=sales_delta, confidence=confidence, analysis=concern, momo_price=momo_price, comp_price=comp_price, revenue_loss_7d=revenue_loss_7d, recommended_price=recommended_price, match_type=match_type, price_basis=price_basis, alert_tier=alert_tier, match_score=match_score, competitor_product_id=competitor_product_id, competitor_product_name=competitor_product_name, ) self._send_telegram(msg, decision_envelope=decision_envelope) logger.info( f"[Dispatcher] AI 例外決策請求 → {sku} loss=${revenue_loss_7d:,.0f}" ) # ADR-007 雙寫 self._sink_insight_to_km( insight_type="human_review", sku=sku, name=name, content=f"[AI 例外決策] {name}。疑慮:{concern}", metadata={"confidence": confidence, "gap_pct": gap_pct, "sales_delta": sales_delta, "momo_price": momo_price, "comp_price": comp_price, "revenue_loss_7d": revenue_loss_7d, "recommended_price": recommended_price, "match_type": match_type, "price_basis": price_basis, "alert_tier": alert_tier, "match_score": match_score, "competitor_product_id": competitor_product_id, "decision_envelope": decision_envelope}, ) def _exec_route_to_km( self, sku: str, name: str, km_domain: str, summary: str, confidence: float, footprint: str = "", threat=None, ): """ 將洞察路由到 KM 指定領域,sink 到 ai_insights 供 RAG 使用。 不送 Telegram 告警(靜默操作,僅 log)。 """ _KM_DOMAINS = {"price_competition", "sales_anomaly", "promotion_opportunity", "market_trend"} domain = km_domain if km_domain in _KM_DOMAINS else "price_competition" summary = _sanitize_text(summary, fallback="競價洞察已歸檔") self._sink_insight_to_km( insight_type=f"km_{domain}", sku=sku, name=name, content=f"[KM 路由 {domain}] {name}:{summary}", metadata={ "km_domain": domain, "confidence": confidence, "momo_price": getattr(threat, "momo_price", None) if threat else None, "pchome_price": getattr(threat, "pchome_price", None) if threat else None, "gap_pct": getattr(threat, "gap_pct", None) if threat else None, "sales_delta": getattr(threat, "sales_7d_delta_pct", None) if threat else None, }, ) logger.info(f"[Dispatcher] KM 路由 → {sku} domain={domain} confidence={confidence:.2f}") def _exec_mark_for_relearn( self, sku: str, name: str, reason: str, footprint: str = "", ): """ 將該 SKU 的既有 ai_insights 標記 status='relearn' + feedback_down+1, 讓每日去重/品質分數重算批次可感知「此洞察已被推翻」。 不送 Telegram 告警(靜默操作,僅 log)。 """ reason = _sanitize_text(reason, fallback="新數據與歷史洞察矛盾,需重新學習") try: from database.manager import DatabaseManager db = DatabaseManager() with db.get_session() as session: from sqlalchemy import text result = session.execute(text(""" UPDATE ai_insights SET status = 'relearn', feedback_down = COALESCE(feedback_down, 0) + 1, updated_at = CURRENT_TIMESTAMP WHERE product_sku = :sku AND status NOT IN ('relearn', 'archived') """), {"sku": sku}) session.commit() rows = result.rowcount logger.info(f"[Dispatcher] mark_for_relearn → {sku} 共更新 {rows} 筆洞察;原因:{reason}") except Exception as e: logger.warning(f"[Dispatcher] mark_for_relearn DB 更新失敗 ({sku}): {e}") # 同時寫入一筆 relearn 事件到 ai_insights 留存紀錄 self._sink_insight_to_km( insight_type="relearn_event", sku=sku, name=name, content=f"[重新學習事件] {name}:{reason}", metadata={"sku": sku, "trigger": "nemoton_dispatcher"}, ) def _sink_insight_to_km(self, insight_type: str, sku: str, name: str, content: str, metadata: dict = None): """ ADR-007 雙寫:派發後把決策/洞察沉澱到 ai_insights(供日後 RAG/PPT) period 以當日 YYYY-MM-DD 作為 cache-aside 鍵,同日同 SKU 同 type 會覆蓋。 失敗不阻斷主線。 """ try: from services.openclaw_learning_service import store_insight period = datetime.now().strftime("%Y-%m-%d") meta = {"sku": sku, "name": name} if metadata: meta.update(metadata) store_insight( insight_type=insight_type, content=content, period=period, product_sku=sku, metadata=meta, ai_model=NIM_MODEL, ) except Exception as e: logger.warning(f"[Dispatcher] sink insight 略過 ({insight_type}/{sku}): {e}") def _send_telegram(self, message: str, decision_envelope: Optional[dict] = None): """ ADR-019 Phase 5: 改走 EventRouter 統一入口 舊行為(直接呼叫 Telegram Bot API + MarkdownV2 跳脫)已由 EventRouter + telegram_templates.send_telegram_with_result 取代。 失敗降級:EventRouter 內建 retry + JSONL queue replay;任何例外不阻斷主線。 """ try: from services.event_router import dispatch_sync payload = {"raw_message": message} event = { "id": "", "event_type": "nemoton_dispatch_alert", "severity": "alert", "source": "NemoTron.Dispatcher", "title": "NemoTron 派發器告警", "summary": message[:400], "status": "dispatched", "payload": payload, } if isinstance(decision_envelope, dict) and decision_envelope: event["id"] = str(decision_envelope.get("decision_id") or "")[:52] event["decision_envelope"] = decision_envelope payload["decision_envelope"] = decision_envelope if not event["id"]: event.pop("id", None) dispatch_sync(event=event) except Exception as e: logger.error(f"[Dispatcher] EventRouter dispatch 失敗: {e}") logger.info(f"[Dispatcher] 告警內容(fallback log):{message[:200]}") # ────────────────────────────────────────────── # 公開介面 # ────────────────────────────────────────────── async def handle_l2(self, event: dict, ctx: dict) -> dict: """L2 行動規劃介面(給 EventRouter / Telegram NLP 使用)。 Contract: event: {"message": str, "user_id": int, "chat_id": int, ...} ctx: 包含 Hermes handle_l1 結果(在 "latest" key 或 flatten 後) Returns: { "action_plan": [{"action": str, "params": dict}, ...], "dispatch_to": "openclaw|direct_response|human_review", "metadata": dict, } 策略: 本方法不呼叫 NIM(批量 tool calling 專用),改以 L1 intent + 訊息關鍵字 決定下一步路由。複雜分析 → openclaw;簡單回覆 → direct_response。 任何例外都回 direct_response 保底。 """ try: message = (event or {}).get("message", "") or "" event_type = (event or {}).get("event_type", "") payload = (event or {}).get("payload") or {} task_name = payload.get("task_name") or (event or {}).get("task_name") if event_type == "scheduler_task_failure" and task_name: try: from services.agent_actions import ALLOWED_RETRY_TASKS if task_name in ALLOWED_RETRY_TASKS: return { "session_id": f"evt:{event_type}:{(event or {}).get('source', 'unknown')}", "plan_type": "retry_task", "action_plan": [{ "action": "retry_task", "params": { "task_name": task_name, "max_attempts": 2, "backoff_sec": 60, }, }], "dispatch_to": "safe_action", "auto_execute": True, "metadata": {"event_type": event_type, "task_name": task_name}, } except Exception as action_err: logger.warning("[NemotronDispatcher.handle_l2] retry_task 規劃跳過: %s", action_err) # ctx 可能是 {"latest": {...}} 或已攤平的 intent 結果 hermes = {} if isinstance(ctx, dict): hermes = ctx.get("latest") if isinstance(ctx.get("latest"), dict) else ctx intent = (hermes or {}).get("intent", "unknown") complexity = float((hermes or {}).get("complexity_score", 0.0) or 0.0) needs_data = bool((hermes or {}).get("requires_data_fetch", False)) msg_lower = message.lower() report_keywords = ("report", "ppt", "週報", "月報", "日報", "報表", "報告") wants_report = any(k in msg_lower for k in report_keywords) if wants_report or complexity >= 0.7 or needs_data or intent in ( "query_sales", "analyze_competitor", "report" ): dispatch_to = "openclaw" else: dispatch_to = "direct_response" action_plan = [{ "action": "strategist_analyze" if dispatch_to == "openclaw" else "reply_simple", "params": {"message": message, "intent": intent}, }] return { "action_plan": action_plan, "dispatch_to": dispatch_to, "metadata": {"complexity_score": complexity, "intent": intent}, } except Exception as e: logger.warning(f"[NemotronDispatcher.handle_l2] 規劃失敗 fallback direct_response: {e}") return { "action_plan": [{"action": "reply_simple", "params": {}}], "dispatch_to": "direct_response", "metadata": {"error": str(e)}, } def dispatch(self, threats: list, hermes_stats: Optional[dict] = None) -> dict: """ 主入口:接收 Hermes 威脅清單,透過 NIM 決策後執行語意化告警 Args: threats: list[PriceThreat] hermes_stats: {"duration_sec": float, "tokens": int} 由 HermesAnalystService 傳入,用於運算足跡顯示 Returns: {"dispatched": int, "skipped": int, "errors": list, "nim_stats": dict} [2026-04-18 台北] Bug-2 防線二 Python 絕對獨裁路由(雙閘門): 閘門 A:銷量 ≤ -95% 絕對斷崖 → 100% 是缺貨/下架/前台異常,不論價差 (2026-04-18 傍晚升級:真實案例 sku=7440662 sales=-100% gap=6.1% 被 NemoTron 錯派降價) 閘門 B:銷量 ≤ -80% 且 |價差| < 5% → 中度斷崖 + 價差微小,定價非主因 命中任一閘門 → 強制走 AI 例外決策,不進 NIM — Claude Opus 4.7 """ if not threats: return {"dispatched": 0, "skipped": 0, "errors": [], "nim_stats": {}} # ── 防線二:Python 絕對獨裁預路由(雙閘門) ── forced_review, nim_candidates = [], [] skipped = 0 for t in threats: if _is_duplicate_alert(t.sku): logger.info(f"[Dispatcher] SKU {t.sku} 在 {int(_ALERT_TTL_SEC/3600)}h 內已告警,觸發去重跳過。") skipped += 1 continue gate_a = t.sales_7d_delta_pct <= -95 # 絕對斷崖 gate_b = t.sales_7d_delta_pct <= -80 and abs(t.gap_pct) < 5 # 中度斷崖 + 微價差 if gate_a or gate_b: forced_review.append(t) else: nim_candidates.append(t) errors = [] dispatched = 0 if forced_review: logger.warning( f"[Dispatcher] 防線二攔截 {len(forced_review)} 筆斷崖異常(不進 NIM): " + ", ".join( f"{t.sku}(sales={t.sales_7d_delta_pct:+.0f}%, gap={t.gap_pct:+.1f}%)" for t in forced_review ) ) hr_footprint = _build_footprint_block(hermes_stats, None) for t in forced_review: # 依閘門類型組 concern 文字 if t.sales_7d_delta_pct <= -95: concern_text = ( f"銷量近乎歸零({t.sales_7d_delta_pct:+.1f}%)— 絕對斷崖警戒。" "此等級業績崩潰幾乎 100% 為缺貨、下架、或前台頁面異常," "與定價策略無關,請營運人員立即走查前台。" ) else: concern_text = ( f"銷量斷崖下跌 {t.sales_7d_delta_pct:+.1f}%,但價差僅 " f"{t.gap_pct:+.1f}%(絕對值 < 5%),與定價無關。" "疑似缺貨、下架、或前台異常,請營運人員立即走查。" ) try: impact = _compute_business_impact(t) self._exec_flag_for_human_review( sku=t.sku, name=t.name, concern=concern_text, confidence=0.99, # Python 規則判定,信心滿格 footprint=hr_footprint, momo_price=t.momo_price, comp_price=t.pchome_price, gap_pct=t.gap_pct, sales_delta=t.sales_7d_delta_pct, revenue_loss_7d=impact["revenue_loss_7d"], recommended_price=impact["recommended_price"], **_threat_match_metadata(t), ) dispatched += 1 except Exception as e: errors.append(f"forced_review({t.sku}): {e}") logger.error(f"[Dispatcher] 防線二執行失敗 {t.sku}: {e}") # 若全部被防線二攔截,直接回傳(不浪費 NIM 配額) if not nim_candidates: logger.info(f"[Dispatcher] 全部 {len(forced_review)} 筆由防線二處理(或去重),不呼叫 NIM") return { "dispatched": dispatched, "skipped": skipped, "errors": errors, "nim_stats": {}, } # ── Operation Ollama-First v5.0 / Phase 3 / A9:qwen3 主路徑(feature flag 灰度)── # NEMOTRON_OLLAMA_FIRST=false 時不進入此分支,僅作緊急退路。 # 若 qwen3 成功取得 tool_calls,沿用既有 TOOL_MAP 執行邏輯(共用 footprint/threat 注入)。 # 若 qwen3 失敗或 0 tool_calls → 不直接降到 Hermes 規則,先嘗試 NIM 備援,再走 ADR-004。 qwen3_used = False qwen3_stats: Optional[dict] = None qwen3_tool_calls: Optional[list] = None if NEMOTRON_OLLAMA_FIRST: try: qwen3_tool_calls, qwen3_stats = self._call_qwen3_dispatch(nim_candidates) if qwen3_tool_calls: qwen3_used = True logger.info( f"[Dispatcher][qwen3] 主路徑成功 tool_calls={len(qwen3_tool_calls)} " f"tokens={qwen3_stats.get('total_tokens', 0)}" ) else: logger.warning("[Dispatcher][qwen3] 0 tool_calls,fallback 至 NIM") except Exception as e: logger.warning(f"[Dispatcher][qwen3] 呼叫失敗 fallback NIM: {e}") # log_ai_call 已在 _call_qwen3_dispatch 內標記 status=error + fallback_to=nim qwen3_tool_calls = None qwen3_stats = None # qwen3 主路徑成功 → 直接進入工具執行區塊(跳過 NIM) if qwen3_used: tool_calls = qwen3_tool_calls # 與既有 NIM 路徑一致的 stats 結構(footprint 顯示用) nim_stats = { "total_tokens": qwen3_stats.get("total_tokens", 0), "quota_used": _nim_quota_used(), # 配額未動用 "provider": qwen3_stats.get("provider", "gcp_ollama"), "model": qwen3_stats.get("model", NEMOTRON_OLLAMA_MODEL), "host": qwen3_stats.get("host"), "host_label": qwen3_stats.get("host_label"), } return self._execute_tool_calls( tool_calls=tool_calls, threats=threats, hermes_stats=hermes_stats, nim_stats=nim_stats, pre_dispatched=dispatched, pre_skipped=skipped, pre_errors=errors, ) # ── 進入 NIM 路徑(flag=false 緊急主路徑;flag=true 則為 qwen3 失敗備援)── if not NIM_API_KEY: logger.warning("[Dispatcher][ADR-004] NVIDIA_API_KEY 未設定,啟動 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"], } if not _check_nim_quota(): logger.warning("[Dispatcher][ADR-004] NIM 配額耗盡,啟動 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"], } try: tool_calls, nim_stats = self._call_nim(nim_candidates) if not tool_calls: logger.warning("[Dispatcher][ADR-004] NIM 0 tool_calls,啟動 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"], } 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.warning("[Dispatcher][ADR-004] NIM HTTP 錯誤,啟動 Hermes 規則引擎降級: %s", e) fb = self._hermes_rule_fallback(nim_candidates, hermes_stats) return { "dispatched": dispatched + fb["dispatched"], "skipped": skipped + fb["skipped"], "errors": errors + [str(e)] + fb["errors"], "nim_stats": fb["nim_stats"], } except Exception as e: logger.warning("[Dispatcher][ADR-004] NIM 呼叫失敗,啟動 Hermes 規則引擎降級: %s", e) fb = self._hermes_rule_fallback(nim_candidates, hermes_stats) return { "dispatched": dispatched + fb["dispatched"], "skipped": skipped + fb["skipped"], "errors": errors + [str(e)] + fb["errors"], "nim_stats": fb["nim_stats"], } return self._execute_tool_calls( tool_calls=tool_calls, threats=threats, hermes_stats=hermes_stats, nim_stats=nim_stats, pre_dispatched=dispatched, pre_skipped=skipped, pre_errors=errors, ) # ────────────────────────────────────────────── # tool_calls 執行區塊(NIM 與 qwen3 共用) # ────────────────────────────────────────────── def _execute_tool_calls( self, tool_calls: list, threats: list, hermes_stats: Optional[dict], nim_stats: dict, pre_dispatched: int = 0, pre_skipped: int = 0, pre_errors: Optional[list] = None, ) -> dict: """執行 LLM 回傳的 tool_calls 清單,注入 Python 獨裁的客觀數字 + 金額影響。 被 NIM 路徑與 qwen3 路徑共用,避免雙路雙維護。 """ errors = list(pre_errors or []) dispatched = pre_dispatched footprint_text = _build_footprint_block(hermes_stats, nim_stats) footprint_data = _build_footprint_json(hermes_stats, nim_stats) threat_map = {t.sku: t for t in threats} TOOL_MAP = { "trigger_price_alert": self._exec_trigger_price_alert, "add_to_recommendation": self._exec_add_to_recommendation, "flag_for_human_review": self._exec_flag_for_human_review, "route_to_km": self._exec_route_to_km, "mark_for_relearn": self._exec_mark_for_relearn, } for tc in tool_calls: tool_name = tc.get("tool") args = dict(tc.get("args", {}) or {}) handler = TOOL_MAP.get(tool_name) if not handler: errors.append(f"未知工具: {tool_name}") continue args["footprint"] = footprint_text t = threat_map.get(args.get("sku")) if tool_name == "trigger_price_alert" and t and not _can_direct_price_alert(t): match_meta = _threat_match_metadata(t) tool_name = "flag_for_human_review" handler = TOOL_MAP[tool_name] args = { "sku": getattr(t, "sku", args.get("sku")), "name": getattr(t, "name", args.get("name")), "concern": ( "比對證據尚未達直接價格告警門檻;" f"match_type={match_meta['match_type']}、" f"price_basis={match_meta['price_basis']}、" f"alert_tier={match_meta['alert_tier']}。" "請先覆核是否為同款、同包裝或需改用單位價。" ), "confidence": max(float(getattr(t, "confidence", 0.5) or 0.5), 0.75), "footprint": footprint_text, } if tool_name == "trigger_price_alert" and t: args["momo_price"] = getattr(t, "momo_price", None) args["comp_price"] = getattr(t, "pchome_price", None) args["gap_pct"] = getattr(t, "gap_pct", None) args["sales_delta"] = getattr(t, "sales_7d_delta_pct", None) impact = _compute_business_impact(t) args["revenue_loss_7d"] = impact["revenue_loss_7d"] args["recommended_price"] = impact["recommended_price"] args.update(_threat_match_metadata(t)) elif tool_name == "flag_for_human_review" and t: args["momo_price"] = getattr(t, "momo_price", None) args["comp_price"] = getattr(t, "pchome_price", None) args["gap_pct"] = getattr(t, "gap_pct", None) args["sales_delta"] = getattr(t, "sales_7d_delta_pct", None) impact = _compute_business_impact(t) args["revenue_loss_7d"] = impact["revenue_loss_7d"] args["recommended_price"] = impact["recommended_price"] args.update(_threat_match_metadata(t)) elif tool_name == "add_to_recommendation": args["footprint_data"] = footprint_data args["threat"] = t elif tool_name == "route_to_km": args["threat"] = t try: handler(**args) dispatched += 1 except Exception as e: errors.append(f"{tool_name}({args.get('sku', '?')}): {e}") logger.error(f"[Dispatcher] 工具執行失敗 [{tool_name}]: {e}") skipped = max(0, len(threats) - dispatched) # nim_stats 在 qwen3 路徑下會帶 provider='gcp_ollama',log 出處可區辨 provider = nim_stats.get("provider", "nim") if isinstance(nim_stats, dict) else "nim" logger.info( f"[Dispatcher] 完成 provider={provider} " f"dispatched={dispatched} skipped={skipped} " f"errors={len(errors)} tokens={nim_stats.get('total_tokens', 0)}" ) return { "dispatched": dispatched, "skipped": skipped, "errors": errors, "nim_stats": nim_stats, } # ───────────────────────────────────────────── # CLI 測試(需設 NVIDIA_API_KEY env var) # python3 services/nemoton_dispatcher_service.py # ───────────────────────────────────────────── if __name__ == "__main__": import sys from dataclasses import dataclass logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") @dataclass class FakeThreat: sku: str name: str category: str momo_price: float pchome_price: float gap_pct: float sales_7d_delta_pct: float risk: str recommended_action: str confidence: float sales_7d_curr_amount: float = 0.0 sales_7d_prev_amount: float = 0.0 fake_threats = [ FakeThreat("A003", "舒特膚AD乳液200ml", "美妝保養", 1200, 980, 22.4, -35.0, "HIGH", "建議立即降價至 $1,000 迎戰,或發放 $200 專屬折價券", 0.85, sales_7d_curr_amount=78000, sales_7d_prev_amount=120000), FakeThreat("A001", "玻尿酸面膜10片裝", "美妝保養", 320, 280, 14.3, -42.0, "HIGH", "建議跟進降價至 $285,配合限時加購活動", 0.78, sales_7d_curr_amount=58000, sales_7d_prev_amount=100000), FakeThreat("A009", "美白化妝水150ml", "美妝保養", 420, 350, 20.0, -22.0, "HIGH", "價格差距過大,建議優先調降或捆包促銷", 0.45, sales_7d_curr_amount=78000, sales_7d_prev_amount=100000), ] # 模擬 Hermes 運算足跡 fake_hermes_stats = {"duration_sec": 34.2, "tokens": 512} if not NIM_API_KEY: print("⚠️ NVIDIA_API_KEY 未設定,測試訊息格式(不呼叫 NIM)") print() dispatcher = NemotronDispatcher() fake_nim_stats = {"total_tokens": 185, "quota_used": 2} footprint = _build_footprint_block(fake_hermes_stats, fake_nim_stats) # 測試三種訊息格式 print("=== 類別一:緊急告警(含 B' 金額影響) ===") print(NemotronDispatcher._fmt_price_alert( "A003", "舒特膚AD乳液200ml", 1200, 980, 22.4, -35.0, "建議立即降價至 $1,000 迎戰,或發放 $200 專屬折價券", 0.85, footprint, revenue_loss_7d=42000.0, # B' 軌驗證:120k - 78k = 42k 流失 recommended_price=980, # B' 軌驗證:跟進競品價 )) print() print("=== 類別二:AI 例外決策 ===") # [2026-04-18 台北] CLI 測試修正:_fmt_human_review 用 kwargs,避免位置錯位 — Claude Opus 4.7 print(NemotronDispatcher._fmt_human_review( sku="A001", name="玻尿酸面膜10片裝", concern=("銷量斷崖下跌 -100.0%,但價差僅 +2.1%(絕對值 < 5%),與定價無關。" "疑似缺貨、下架、或前台異常,請營運人員立即走查。"), footprint=footprint, momo_price=285, comp_price=280, gap_pct=1.8, sales_delta=-100.0, )) print() print("=== 類別三:策略推薦 ===") print(NemotronDispatcher._fmt_recommendation( "A009", "美白化妝水150ml", "我方價格低於市場 20%,近7天銷量回升,具備流量轉換潛力", 0.82, True, footprint, )) print() # ── 防線二 Bug-2 驗證:Python 絕對獨裁路由 ── print("=== 防線二驗證:Python 絕對獨裁預路由 ===") validation_threats = [ FakeThreat("A-EXTREME-1", "斷崖商品(缺貨)", "測試", 300, 298, 0.7, -100.0, "MED", "疑似缺貨", 0.5), FakeThreat("A-EXTREME-2", "斷崖商品(負價差)", "測試", 298, 310, -3.9, -85.0, "MED", "疑似前台異常", 0.5), # [2026-04-18 台北] 閘門 A 案例:sales=-100% gap 超過 5% 仍要攔(真實 sku 7440662) FakeThreat("A-GATE-A", "雪芙蘭小蒼蘭滋養膏 60g", "美妝保養", 52, 49, 6.1, -100.0, "HIGH", "建議跟進降價促銷", 0.95), FakeThreat("A-NORMAL-1", "正常降價案例", "測試", 1200, 980, 22.4, -35.0, "HIGH", "建議跟進降價", 0.85), ] forced, nim_list = [], [] for t in validation_threats: gate_a = t.sales_7d_delta_pct <= -95 gate_b = t.sales_7d_delta_pct <= -80 and abs(t.gap_pct) < 5 if gate_a or gate_b: forced.append(t) else: nim_list.append(t) print(f"強制 AI 例外決策 {len(forced)} 筆(預期 3:閘門 A + 閘門 B × 2): " f"{[t.sku for t in forced]}") print(f"送入 NIM 決策 {len(nim_list)} 筆(預期 1): " f"{[t.sku for t in nim_list]}") assert len(forced) == 3, f"防線二分類錯誤:forced 應為 3,實為 {len(forced)}" assert len(nim_list) == 1, f"防線二分類錯誤:nim 應為 1,實為 {len(nim_list)}" print("✅ 防線二雙閘門分類邏輯正確") sys.exit(0) print("=== NemoTron Dispatcher CLI 測試(真實 NIM) ===\n") dispatcher = NemotronDispatcher() result = dispatcher.dispatch(fake_threats, hermes_stats=fake_hermes_stats) print( f"\n結果:dispatched={result['dispatched']} " f"skipped={result['skipped']} errors={result['errors']}\n" f"NIM: {result['nim_stats']}" )