Files
ewoooc/services/watcher_agent.py

352 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Watcher Agent — 主動偵測與觸發
角色:
- 定期輪詢 sales snapshot檢查銷量下滑或競品價格突漲
- 發生異常時構建 event 並 dispatch 到 EventRouter
- 與 ActionPlanner 配合生成後續計畫
設計:
- 輕量級,無需額外 infra僅用 PostgreSQL
- 異常閾值可透過 env 調整
- 與 OpenClaw 共享 agent_context 記憶
"""
import json
import logging
import os
import time
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
import requests
from sqlalchemy import text
from database.manager import get_session
from services.ai_orchestrator import AIOrchestrator
from services.event_router import dispatch
sys_log = logging.getLogger(__name__)
# ─── 環境設定 ────────────────────────────────────────────────
SALES_SNAPSHOT_TABLE = os.getenv("WATCHER_SNAPSHOT_TABLE", "daily_sales_snapshot")
SALES_DROP_THRESHOLD = float(os.getenv("WATCHER_SALES_DROP_THRESHOLD", "0.20")) # 20%
PRICE_SURGE_THRESHOLD = float(os.getenv("WATCHER_PRICE_SURGE_THRESHOLD", "0.15")) # 15%
CACHE_TTL_MIN = int(os.getenv("WATCHER_CACHE_TTL_MIN", "30")) # 輪詻間隔
# ─── 共享上下文鍵 ────────────────────────────────────────────
WATCHER_CTX_NS = "watcher"
class WatcherAgent:
"""
主動偵測 Agent
流程:
1) 載入最近兩週銷售快照
2) 計算環比變化
3) 篩選異常 SKU
4) 構建 event 並 dispatch
5) 寫入 agent_context 供後續 Agent 使用
"""
def __init__(self, orchestrator: Optional[AIOrchestrator] = None):
self.orchestrator = orchestrator or AIOrchestrator()
async def scan(self) -> int:
"""執行一次掃描,回傳觸發的異常數"""
rows = await self._fetch_sales_snapshot()
if not rows:
sys_log.info("[Watcher] 無銷售快照,跳過掃描")
return 0
anomalies = self._detect_anomalies(rows)
if not anomalies:
sys_log.info("[Watcher] 未檢測到異常")
return 0
sys_log.info(f"[Watcher] 檢測到 {len(anomalies)} 筆異常,開始 dispatch")
triggered = 0
for an in anomalies:
if await self._dispatch_anomaly(an):
triggered += 1
return triggered
async def track_outcomes(self, days: int = 7) -> None:
"""
排程回撥:執行後 days 天後檢查 action_outcomes
並將結果回饋給 OpenClaw 學習。
這裡僅作佈署示意;實際排程由外部 scheduler 負責。
"""
sys_log.info(f"[Watcher] 排程 outcome 回撥({days} 天後)")
# 範例:
# await outcome_tracker.schedule_follow_up(plan_id, sku, metric)
# ── 內部方法 ────────────────────────────────────────────────
async def _fetch_sales_snapshot(self) -> List[Dict[str, Any]]:
"""
讀取銷售快照。
欄位假設:
- sku
- name
- category
- sales_curr (最近7天銷售金額)
- sales_prev (前7天銷售金額)
- price_momo (MOMO 價格)
- price_pchome (PChome 價格)
- stock_status (庫存狀態)
若實際欄位名不同,請依實際調整。
"""
session = get_session()
try:
sql = text(f"""
SELECT sku, name, category,
COALESCE(sales_curr, 0) AS sales_curr,
COALESCE(sales_prev, 0) AS sales_prev,
price_momo, price_pchome, stock_status
FROM {SALES_SNAPSHOT_TABLE}
WHERE snapshot_date = CURRENT_DATE - INTERVAL '1 day'
LIMIT 500
""")
result = session.execute(sql).fetchall()
return [dict(row._mapping) for row in result]
except Exception as e:
sys_log.error(f"[Watcher] 無法讀取快照: {e}")
return []
finally:
session.close()
def _detect_anomalies(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
anomalies: List[Dict[str, Any]] = []
for r in rows:
sku = r["sku"]
name = r["name"]
curr = float(r["sales_curr"] or 0)
prev = float(r["sales_prev"] or 1) # 避免除以 0
pchome = r["price_pchome"]
momo = r["price_momo"]
stock = r.get("stock_status", "unknown")
drop_pct = (curr - prev) / prev if prev else 0.0
price_gap_pct = ((momo - pchome) / pchome * 100) if pchome else 0.0
reasons = []
# 銷量下滑異常
if drop_pct <= -SALES_DROP_THRESHOLD:
reasons.append(
f"銷量下滑 {drop_pct:+.1%}(閾值 {SALES_DROP_THRESHOLD:+.0%}"
)
# 競品價格突漲(若我方價格低且差距擴大)
if price_gap_pct > PRICE_SURGE_THRESHOLD:
reasons.append(
f"競品價格突漲 {price_gap_pct:+.1f}% 形成高價差"
)
# 庫存危機(可擴充)
if stock in ("out_of_stock", "low_stock"):
reasons.append(f"庫存狀態: {stock}")
if not reasons:
continue
anomalies.append({
"sku": sku,
"name": name,
"category": r.get("category", ""),
"drop_pct": drop_pct,
"price_gap_pct": price_gap_pct,
"reasons": reasons,
"stock": stock,
"momo_price": momo,
"pchome_price": pchome,
})
return anomalies
async def _dispatch_anomaly(self, anom: Dict[str, Any]) -> bool:
"""
依異常類型決定路由:
- 銷量下滑 + 價差微小 → L1分析原因
- 銷量下滑 + 價差大 → L2規劃 + 審核)
- 競品價格突漲 → L2防範被動
"""
drop = anom["drop_pct"]
gap = anom["price_gap_pct"]
sku = anom["sku"]
name = anom["name"]
session_id = self._ensure_session(sku)
# 構建 event payload與 EventRouter 對齊)
event = {
"source": "watcher",
"event_type": "sales_anomaly",
"severity": "alert",
"title": f"銷售異常偵測 — {sku} {name}",
"summary": "; ".join(anom["reasons"]),
"payload": {
"sku": sku,
"name": name,
"category": anom["category"],
"drop_pct": anom["drop_pct"],
"price_gap_pct": anom["price_gap_pct"],
"stock": anom["stock"],
"momo_price": anom["momo_price"],
"pchome_price": anom["pchome_price"],
"sales_prev": anom.get("sales_prev"),
"sales_curr": anom.get("sales_curr"),
},
"impact": "銷量下滑可能導致收入損失",
"status": "open",
}
# 決策路由
if drop <= -SALES_DROP_THRESHOLD and abs(gap) < PRICE_SURGE_THRESHOLD:
# 銷量下滑但價差微小 → 檢查是否非價格因素(缺貨/流量)
event["severity"] = "alert"
event["payload"]["non_price_factor"] = True
# 交由 L1 分析原因
return await self._route_l1(event, session_id)
else:
# 銷量下滑 + 價差大 或 競品價格突漲 → L2 規劃
event["severity"] = "alert"
return await self._route_l2(event, session_id)
async def _route_l1(self, event: Dict[str, Any], session_id: str) -> bool:
"""L1Hermes 分析下滑原因"""
try:
result = await self.orchestrator.handle_l1(event, session_id)
sys_log.info(f"[Watcher] L1 dispatch success for {event['payload']['sku']}")
# 寫入共享上下文
await self._save_context(session_id, "hermes", {
"summary": result.get("summary"),
"probable_cause": result.get("probable_cause"),
"actions": result.get("actions", []),
})
return True
except Exception as e:
sys_log.error(f"[Watcher] L1 dispatch failed: {e}")
# 保底:直接通知
await self._fallback_notify(event)
return False
async def _route_l2(self, event: Dict[str, Any], session_id: str) -> bool:
"""L2NemoTron 規劃 + 審核閘"""
try:
result = await self.orchestrator.handle_l2(event, session_id)
sys_log.info(f"[Watcher] L2 dispatch success for {event['payload']['sku']}")
# 寫入共享上下文與 action_plans
await self._save_context(session_id, "nemotron", {
"plan": result.get("plan"),
"actions_taken": result.get("actions_taken", []),
})
await self._save_action_plan(event, result.get("plan"))
return True
except Exception as e:
sys_log.error(f"[Watcher] L2 dispatch failed: {e}")
# 保底通知
await self._fallback_notify(event)
return False
async def _fallback_notify(self, event: Dict[str, Any]) -> None:
"""當 AI 失敗時,直接通知並記錄原因"""
sku = event["payload"]["sku"]
name = event["payload"]["name"]
text = (
f"⚠️ [Watcher Fallback] {sku} {name}\n"
f"原因:{event['summary']}\n"
f"建議:立即人工檢查銷售與庫存狀態。"
)
await self._notify_telegram(text)
async def _notify_telegram(self, text: str) -> bool:
"""透過 Telegram 發送訊息"""
from services.telegram_templates import alert as render_alert
bot_token = os.getenv("TELEGRAM_BOT_TOKEN")
if not bot_token:
sys_log.warning("[Watcher] TELEGRAM_BOT_TOKEN 未設定")
return False
chat_ids_raw = os.getenv("TELEGRAM_CHAT_IDS", "[]")
try:
chat_ids = json.loads(chat_ids_raw)
except json.JSONDecodeError:
chat_ids = []
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {
"chat_id": chat_ids[0] if chat_ids else -1003940688311,
"text": render_alert(title="銷售異常通知", content=text),
"parse_mode": "HTML",
}
try:
r = requests.post(url, json=payload, timeout=10)
return r.ok
except Exception as e:
sys_log.error(f"[Watcher] Telegram 通知失敗: {e}")
return False
def _ensure_session(self, sku: str) -> str:
"""保證 session_id 存在簡化skuid 作為 session"""
return f"session:{sku}"
async def _save_context(self, session_id: str, agent: str, data: Dict[str, Any]) -> None:
"""寫入 agent_context共享記憶"""
session = get_session()
try:
# 刪除舊的 key
session.execute(
text("DELETE FROM agent_context WHERE session_id = :sid AND agent_name = :ag"),
{"sid": session_id, "ag": agent},
)
# 寫入新 context
session.execute(
text("""
INSERT INTO agent_context
(session_id, agent_name, context_key, context_val, created_at, ttl_minutes)
VALUES
(:sid, :ag, :ck, :cv, NOW(), :ttl)
"""),
{
"sid": session_id,
"ag": agent,
"ck": "latest",
"cv": json.dumps(data, ensure_ascii=False),
"ttl": CACHE_TTL_MIN * 2,
},
)
session.commit()
except Exception as e:
session.rollback()
sys_log.warning(f"[Watcher] 寫入 context 失敗: {e}")
finally:
session.close()
async def _save_action_plan(self, event: Dict[str, Any], plan: Optional[Dict[str, Any]]) -> None:
"""將 NemoTron 的 plan 寫入 action_plans"""
if not plan:
return
session = get_session()
try:
sku = event["payload"]["sku"]
session.execute(
text("""
INSERT INTO action_plans
(session_id, plan_type, sku, payload, status, created_by)
VALUES
(:sid, :pt, :sku, :pl, 'pending', 'nemotron')
"""),
{
"sid": f"session:{sku}",
"pt": plan.get("type", "price_adjust"),
"sku": sku,
"pl": json.dumps(plan, ensure_ascii=False),
},
)
session.commit()
except Exception as e:
session.rollback()
sys_log.warning(f"[Watcher] 寫入 action_plan 失敗: {e}")
finally:
session.close()