From 025183599939af9a4d3f5ff25b23698a30faf45d Mon Sep 17 00:00:00 2001 From: OoO Date: Sun, 24 May 2026 16:03:56 +0800 Subject: [PATCH] V10.428 add Nemotron price decision envelopes --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 3 +- docs/memory/history_logs.md | 1 + services/nemoton_dispatcher_service.py | 234 ++++++++++++++++++++++- tests/test_nemotron_decision_envelope.py | 104 ++++++++++ 5 files changed, 334 insertions(+), 10 deletions(-) create mode 100644 tests/test_nemotron_decision_envelope.py diff --git a/config.py b/config.py index 192ba10..781cd7a 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.427" +SYSTEM_VERSION = "V10.428" 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 115a613..caaa7ad 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.427 +> **適用版本**: V10.428 --- @@ -43,6 +43,7 @@ - `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 也應共用同一份欄位語意。 +- NemoTron `price_alert` / `human_review` 派發會把同款證據、價差、七日銷量變化、營收流失、HITL 邊界與資料品質寫入同一份 `decision_envelope`,並同步放入 EventRouter event 與 KM metadata;12 Agent 後續只能沿用此信封補充分析,不得繞過 matcher / feeder / review service 直接改價或覆寫比價資料。 - 競品比價相關的 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 28dc421..fb209ed 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **V10.428 NemoTron 價格決策信封落地**: `NemoTronDispatcher` 的 `price_alert` 與 `human_review` 事件現在會產生 12 Agent 共用 `decision_envelope`,把同款證據、價差、七日銷量變化、營收流失、建議行動、HITL guardrails、資料品質與 trace 同步寫入 EventRouter event 與 KM metadata;這讓 Telegram、AI 觀測台、PPT QA 與後續 Agent 協作能讀同一份可稽核證據,而不是各自解析告警文字。 - **V10.427 111 fallback circuit breaker**: `OllamaService` 在選到 111 final fallback 前先讀 `ai_calls` 近 60 分鐘比例;若 Ollama 呼叫 >=20、111 >=5 且占比 >=5%,會短暫跳過 111 並清除 resolved host cache,避免 111 在已偏高時繼續承接長任務。DB 觀測失敗採 fail-open,避免觀測層故障反向中斷 GCP-A/GCP-B 正常路由。 - **V10.426 111 proxy 拒絕日誌去重**: `ollama111_allow_proxy.py` 對同一來源 IP 的 reject log 預設 60 秒去重,保留 110 / 121 被擋的可觀測性,同時避免旁路 VM 持續探測時把 111 的 proxy log 與磁碟 I/O 刷高。 - **V10.425 111 fallback 使用率護欄**: Scheduler 每 15 分鐘只讀 `ai_calls` 檢查 111 Ollama fallback 使用率,預設 60 分鐘內 Ollama 呼叫 >=20、111 呼叫 >=3 且占比 >=5% 才推 Telegram,並列出 111 caller Top 5;此護欄只觀測與告警,不改路由、不寫 DB、不重啟服務,讓 111 被異常承接高負載時可即早發現。 diff --git a/services/nemoton_dispatcher_service.py b/services/nemoton_dispatcher_service.py index a5ecf81..aa29875 100644 --- a/services/nemoton_dispatcher_service.py +++ b/services/nemoton_dispatcher_service.py @@ -21,6 +21,7 @@ import json import logging import os import re +import uuid from datetime import datetime from typing import Optional import requests @@ -402,6 +403,178 @@ def _format_match_evidence_block( 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 = ( + "價格調整需人工覆核;不得自動寫入或覆蓋正式競品價格" + if decision_type == "price_alert" + else "身份、包裝、單位價或前台狀態需人工確認" + ) + 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", + "competitor_product_id": competitor_product_id, + "competitor_product_name": str(competitor_product_name or "")[:120], + }, + "evidence": evidence, + "analysis": _sanitize_text(analysis, fallback="請人工確認", max_len=300), + "recommended_action": { + "action": action, + "owner": "營運", + "requires_hitl": True, + }, + "expected_impact": { + "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}] 清單。 @@ -1163,7 +1336,26 @@ class NemotronDispatcher: competitor_product_id=competitor_product_id, competitor_product_name=competitor_product_name, ) - self._send_telegram(msg) + 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}" @@ -1181,7 +1373,8 @@ class NemotronDispatcher: "price_basis": price_basis, "alert_tier": alert_tier, "match_score": match_score, - "competitor_product_id": competitor_product_id}, + "competitor_product_id": competitor_product_id, + "decision_envelope": decision_envelope}, ) def _exec_add_to_recommendation( @@ -1276,7 +1469,26 @@ class NemotronDispatcher: competitor_product_id=competitor_product_id, competitor_product_name=competitor_product_name, ) - self._send_telegram(msg) + 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] 人工覆核請求 → {sku} loss=${revenue_loss_7d:,.0f}" ) @@ -1293,7 +1505,8 @@ class NemotronDispatcher: "price_basis": price_basis, "alert_tier": alert_tier, "match_score": match_score, - "competitor_product_id": competitor_product_id}, + "competitor_product_id": competitor_product_id, + "decision_envelope": decision_envelope}, ) def _exec_route_to_km( @@ -1387,7 +1600,7 @@ class NemotronDispatcher: except Exception as e: logger.warning(f"[Dispatcher] sink insight 略過 ({insight_type}/{sku}): {e}") - def _send_telegram(self, message: str): + def _send_telegram(self, message: str, decision_envelope: Optional[dict] = None): """ ADR-019 Phase 5: 改走 EventRouter 統一入口 舊行為(直接呼叫 Telegram Bot API + MarkdownV2 跳脫)已由 EventRouter @@ -1397,15 +1610,20 @@ class NemotronDispatcher: """ try: from services.event_router import dispatch_sync - dispatch_sync(event={ + payload = {"raw_message": message} + event = { "event_type": "nemoton_dispatch_alert", "severity": "alert", "source": "NemoTron.Dispatcher", "title": "NemoTron 派發器告警", "summary": message[:400], "status": "dispatched", - "payload": {"raw_message": message}, - }) + "payload": payload, + } + if isinstance(decision_envelope, dict) and decision_envelope: + event["decision_envelope"] = decision_envelope + payload["decision_envelope"] = decision_envelope + dispatch_sync(event=event) except Exception as e: logger.error(f"[Dispatcher] EventRouter dispatch 失敗: {e}") logger.info(f"[Dispatcher] 告警內容(fallback log):{message[:200]}") diff --git a/tests/test_nemotron_decision_envelope.py b/tests/test_nemotron_decision_envelope.py new file mode 100644 index 0000000..01ce159 --- /dev/null +++ b/tests/test_nemotron_decision_envelope.py @@ -0,0 +1,104 @@ +import sys +import types + + +def test_price_decision_envelope_has_hilt_guardrails(): + import services.nemoton_dispatcher_service as module + + envelope = module._build_price_decision_envelope( + decision_type="price_alert", + sku="10413050", + name="測試精華液", + gap_pct=16.7, + sales_delta=-31.2, + confidence=0.91, + analysis="建議進入人工價格覆核", + momo_price=1200, + comp_price=999, + revenue_loss_7d=65000, + recommended_price=999, + match_type="exact", + price_basis="total_price", + alert_tier="price_alert_exact", + match_score=0.93, + competitor_product_id="PCHOME-1", + competitor_product_name="PChome 測試精華液", + ) + + assert envelope["decision_type"] == "price_alert" + assert envelope["severity"] == "P1" + assert envelope["confidence"] == 0.91 + assert envelope["subject"]["sku"] == "10413050" + assert envelope["subject"]["competitor_product_id"] == "PCHOME-1" + assert envelope["guardrails"]["can_auto_execute"] is False + assert envelope["guardrails"]["data_quality"] == "complete" + assert envelope["guardrails"]["match_type"] == "exact" + assert envelope["guardrails"]["price_basis"] == "total_price" + assert envelope["guardrails"]["alert_tier"] == "price_alert_exact" + assert envelope["recommended_action"]["requires_hitl"] is True + assert envelope["expected_impact"]["revenue_loss_7d"] == 65000 + assert envelope["expected_impact"]["gap_amount"] == 201 + assert any(e["metric"] == "match_score" and e["value"] == 0.93 for e in envelope["evidence"]) + + +def test_send_telegram_attaches_decision_envelope_to_event_router(monkeypatch): + import services.nemoton_dispatcher_service as module + + captured = [] + fake_event_router = types.ModuleType("services.event_router") + fake_event_router.dispatch_sync = lambda event: captured.append(event) + monkeypatch.setitem(sys.modules, "services.event_router", fake_event_router) + + envelope = {"decision_type": "price_alert", "severity": "P2", "guardrails": {"can_auto_execute": False}} + module.NemotronDispatcher()._send_telegram("hello price alert", decision_envelope=envelope) + + assert len(captured) == 1 + event = captured[0] + assert event["event_type"] == "nemoton_dispatch_alert" + assert event["decision_envelope"] == envelope + assert event["payload"]["decision_envelope"] == envelope + assert event["payload"]["raw_message"] == "hello price alert" + + +def test_exec_trigger_price_alert_persists_decision_envelope(monkeypatch): + import services.nemoton_dispatcher_service as module + + dispatcher = module.NemotronDispatcher() + sent = [] + stored = [] + monkeypatch.setattr( + dispatcher, + "_send_telegram", + lambda message, decision_envelope=None: sent.append((message, decision_envelope)), + ) + monkeypatch.setattr( + dispatcher, + "_sink_insight_to_km", + lambda *args, **kwargs: stored.append((args, kwargs)), + ) + + dispatcher._exec_trigger_price_alert( + sku="SKU-1", + name="測試商品", + gap_pct=12.5, + sales_delta=-10.0, + action="請人工確認是否跟價", + confidence=0.88, + momo_price=1000, + comp_price=875, + revenue_loss_7d=32000, + recommended_price=875, + match_type="exact", + price_basis="total_price", + alert_tier="price_alert_exact", + match_score=0.9, + competitor_product_id="PC-1", + ) + + assert sent + _, envelope = sent[0] + assert envelope["decision_type"] == "price_alert" + assert envelope["guardrails"]["can_auto_execute"] is False + assert envelope["subject"]["competitor_product_id"] == "PC-1" + assert stored + assert stored[0][1]["metadata"]["decision_envelope"] == envelope