diff --git a/config.py b/config.py index 1d2170d..52e04cd 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.422" +SYSTEM_VERSION = "V10.423" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index a19c71c..2e35454 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-24 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉 -> **適用版本**: V10.422 +> **適用版本**: V10.423 --- @@ -34,6 +34,15 @@ - 111 的 LAN 入口必須經 `scripts/ops/ollama111_allow_proxy.py` allowlist proxy:真實 Ollama 綁 `127.0.0.1:11434`,proxy 綁 `192.168.0.111:11434`,預設只允許 111 本機與 188 生產宿主;110 / 121 / 其他 LAN client 不能直接打 111,避免跨專案 CI 或 VM 繞過 momo-pro router 載入 7B+ runner。111 上以 `scripts/ops/install_ollama111_allow_proxy.sh` 安裝 user LaunchAgent,讓 proxy 與 `OLLAMA_HOST=127.0.0.1:11434` 在登入/重啟後自動恢復。 - ElephantAlpha 的 `price_drop_alert` / `market_opportunity` Telegram HITL 告警必須把同款證據獨立呈現,至少包含 `match_type`、`price_basis`、`alert_tier` 與 `match_score`;沒有高信心同款與總價可比證據時,不得把 PChome/MOMO 價差寫成可直接跟價建議。 +## 零之一、12 Agent 決策信封(2026-05-24) + +- 12 角色分工不作為 12 個常駐模型;在產品層統一收斂成 `decision_envelope`,由 Hermes / NemoTron / OpenClaw / ElephantAlpha 與人工審核、PPT QA、競品 review queue 共用。 +- `decision_envelope` 必須至少能表達:`decision_type`、`severity`、`evidence[]`、`recommended_action`、`expected_impact`、`confidence`、`guardrails`、`trace`。 +- `guardrails.can_auto_execute=false` 是預設;價格調整、正式比價覆寫、PPT 發送與修復執行都必須遵守 HITL 或既有 service guard,不得因 Agent 信心高就繞過 matcher / feeder / review service。 +- 證據不足時不得輸出空泛效益預測;必須標記 `data_quality=missing|partial|stale`,並把建議行動降級成 `human_review`、`needs_research` 或 `silence_alert`。 +- Telegram `triaged_alert()` 已支援渲染 `decision_envelope`,讓告警固定呈現嚴重度、證據、建議行動、預期影響、信心度與追蹤 ID;後續觀測台與 PPT 也應共用同一份欄位語意。 +- 競品比價相關的 Agent 建議只能讀 `competitor_match_attempts` / review queue / `competitor_prices` 的既有證據;不得直接寫 `competitor_prices` 或覆蓋 `_should_upsert_competitor_price()` 的保護規則。 + ## 一、四 AI Agent 路由架構 ``` diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 8dcfc7f..70f8315 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **V10.423 12 Agent 決策信封**: `triaged_alert()` 支援 `decision_envelope` 結構化區塊,讓 Hermes / NemoTron / OpenClaw / ElephantAlpha 與後續 12 角色決策統一輸出 `severity`、`evidence`、`recommended_action`、`expected_impact`、`confidence`、`guardrails` 與 `trace`;缺證據時必須明確標記資料品質與 HITL 邊界,避免再出現空泛效益預測或不可追溯告警。 - **V10.422 111 proxy LaunchAgent 持久化**: 新增 `scripts/ops/install_ollama111_allow_proxy.sh`,在 111 以 user LaunchAgent 安裝 `com.momo.ollama111-allow-proxy`,啟動時設定 `OLLAMA_HOST=127.0.0.1:11434`、重啟 Ollama、載入 allowlist proxy,避免重開機或重新登入後 111 又回到 LAN 全開狀態。 - **V10.421 Kanebo Milano / hoi 蠟燭品類防錯配**: marketplace matcher 追加 `kanebo_milano_type_conflict` 與 `hoi_candle_line_conflict`,將 Kanebo Milano Collection 蜜粉餅 vs 絕色香水、hoi 日京山風香氛蠟燭 vs hoi!LAB 實驗室香氛蠟燭經典篇列為 hard veto;同品牌、同系列字樣或同容量仍不可跨品類/跨產品線直接比價。 - **V10.420 111 Ollama LAN allowlist proxy**: 追查 111 高負載時確認來源不是 momo-pro,而是 110 上 `awoooi-cd` 臨時測試與 121 VMware VM 直接打 `192.168.0.111:11434`,繞過 `ai_calls` 與 momo-pro router 載入 7B runner。新增 `scripts/ops/ollama111_allow_proxy.py`,將真實 Ollama 收斂到 `127.0.0.1:11434`,由 user-space proxy 綁 `192.168.0.111:11434` 並預設只允許 111 本機與 188 生產宿主;110 / 121 會被 reset,111 fallback 保留給 momo production。 diff --git a/services/telegram_templates.py b/services/telegram_templates.py index b76cd91..7783a00 100644 --- a/services/telegram_templates.py +++ b/services/telegram_templates.py @@ -696,6 +696,90 @@ def _format_ea_escalation_alert( return "\n".join(lines) + +def _format_decision_envelope(envelope: Dict[str, Any]) -> List[str]: + """將 12 Agent 共用決策信封轉成可審核的 Telegram 區塊。""" + if not isinstance(envelope, dict) or not envelope: + return [] + + severity = escape(str(envelope.get("severity") or "info")) + decision_type = escape(str(envelope.get("decision_type") or "general")) + confidence = envelope.get("confidence") + guardrails = envelope.get("guardrails") if isinstance(envelope.get("guardrails"), dict) else {} + data_quality = escape(str(guardrails.get("data_quality") or envelope.get("data_quality") or "unknown")) + can_auto_execute = bool(guardrails.get("can_auto_execute", False)) + blocked_reason = escape(str(guardrails.get("blocked_reason") or "")) + + confidence_text = "" + try: + if confidence is not None: + confidence_text = f" 信心度:{float(confidence):.0%}" + except (TypeError, ValueError): + confidence_text = "" + + lines = [ + "🧭 決策信封", + f"• 類型:{decision_type} 嚴重度:{severity}{confidence_text}", + f"• 資料品質:{data_quality} 自動執行:{'允許' if can_auto_execute else '不允許'}", + ] + if blocked_reason: + lines.append(f"• 邊界:{blocked_reason}") + + evidence_items = envelope.get("evidence") if isinstance(envelope.get("evidence"), list) else [] + if evidence_items: + lines += ["", "證據"] + for item in evidence_items[:3]: + if not isinstance(item, dict): + lines.append(f"• {escape(str(item))[:180]}") + continue + metric = escape(str(item.get("metric") or item.get("type") or "evidence")) + value = escape(str(item.get("value") if item.get("value") is not None else "")) + basis = escape(str(item.get("basis") or "")) + freshness = escape(str(item.get("freshness") or "")) + item_confidence = item.get("confidence") + confidence_suffix = "" + try: + if item_confidence is not None: + confidence_suffix = f" / {float(item_confidence):.0%}" + except (TypeError, ValueError): + confidence_suffix = "" + detail = " / ".join(part for part in (value, basis, freshness) if part) + lines.append(f"• {metric}{confidence_suffix}" + (f":{detail}" if detail else "")) + + recommended_action = envelope.get("recommended_action") + if isinstance(recommended_action, dict): + action = escape(str(recommended_action.get("action") or "human_review")) + owner = escape(str(recommended_action.get("owner") or "未指定")) + deadline = escape(str(recommended_action.get("deadline") or "")) + requires_hitl = bool(recommended_action.get("requires_hitl", True)) + lines += [ + "", + "建議行動", + f"• 動作:{action} 負責:{owner}", + f"• HITL:{'需要' if requires_hitl else '不需要'}" + (f" 期限:{deadline}" if deadline else ""), + ] + + expected_impact = envelope.get("expected_impact") + if isinstance(expected_impact, dict) and expected_impact: + impact_parts = [] + for key in ("revenue_loss_7d", "gap_amount", "cost_usd", "risk_reduction"): + if key in expected_impact and expected_impact[key] is not None: + impact_parts.append(f"{escape(key)}={escape(str(expected_impact[key]))}") + if impact_parts: + lines += ["", "預期影響", "• " + " / ".join(impact_parts[:4])] + + trace = envelope.get("trace") + if isinstance(trace, dict): + trace_parts = [] + for key in ("ai_call_id", "insight_id", "action_plan_id", "model", "provider"): + if trace.get(key) is not None: + trace_parts.append(f"{key}={trace[key]}") + if trace_parts: + lines += ["", f"{escape(' | '.join(trace_parts))}"] + + return lines + [""] + + def triaged_alert(base_event: Dict[str, Any], tier_label: str, ai_summary: str, ai_cause: Optional[str] = None, ai_actions: Optional[list] = None, @@ -730,6 +814,9 @@ def triaged_alert(base_event: Dict[str, Any], tier_label: str, lines += [f"🧠 AI 摘要:{safe_ai_summary[:400]}", ""] if safe_ai_cause: lines += [f"💡 可能原因:{safe_ai_cause}", ""] + decision_envelope = base_event.get("decision_envelope") or base_event.get("decision") + if isinstance(decision_envelope, dict): + lines += _format_decision_envelope(decision_envelope) if safe_actions: lines += ["📋 建議行動:"] + [f" • {a}" for a in safe_actions] + [""] if safe_executed: diff --git a/tests/test_telegram_triaged_alert_format.py b/tests/test_telegram_triaged_alert_format.py index dc55eca..9fac9aa 100644 --- a/tests/test_telegram_triaged_alert_format.py +++ b/tests/test_telegram_triaged_alert_format.py @@ -124,3 +124,73 @@ def test_ea_escalation_generic_actions_do_not_render_as_sku_cards(): assert "📋 TOP 待審 SKU" not in msg assert "• 待審 SKU" not in msg assert "未取得實證前,不執行自動調價、修復或策略派發" in msg + + +def test_triaged_alert_renders_decision_envelope_contract(): + msg, keyboard = triaged_alert( + base_event={ + "event_type": "price_alert", + "title": "MOMO / PChome 價格威脅", + "summary": "高信心同款且 PChome 低價。", + "id": "decision_env_001", + "decision_envelope": { + "decision_type": "price_alert", + "severity": "P1", + "confidence": 0.86, + "evidence": [ + { + "type": "match", + "metric": "match_score", + "value": 0.91, + "basis": "identity_v2 + price_alert_exact", + "freshness": "2026-05-24T10:00:00+08:00", + "confidence": 0.91, + }, + { + "type": "price", + "metric": "gap_pct", + "value": "18.4%", + "basis": "latest price_records", + }, + ], + "recommended_action": { + "action": "human_review", + "owner": "ops", + "deadline": "2026-05-24T18:00:00+08:00", + "requires_hitl": True, + }, + "expected_impact": { + "revenue_loss_7d": 42000, + "gap_amount": 120, + "risk_reduction": "high", + }, + "guardrails": { + "can_auto_execute": False, + "blocked_reason": "price adjustment requires HITL", + "data_quality": "complete", + }, + "trace": { + "ai_call_id": 123, + "action_plan_id": 456, + "model": "qwen3:14b", + "provider": "ollama_gcp_a", + }, + }, + }, + tier_label="Hermes · P1", + ai_summary="建議進人工價格審核。", + ) + + assert "🧭 決策信封" in msg + assert "類型:price_alert" in msg + assert "嚴重度:P1" in msg + assert "信心度:86%" in msg + assert "資料品質:complete" in msg + assert "自動執行:不允許" in msg + assert "邊界:price adjustment requires HITL" in msg + assert "match_score / 91%" in msg + assert "identity_v2 + price_alert_exact" in msg + assert "動作:human_review" in msg + assert "revenue_loss_7d=42000" in msg + assert "ai_call_id=123" in msg + assert keyboard["inline_keyboard"][0][0]["callback_data"] == "momo:eig:decision_env_001"