Files
ewoooc/services/openclaw_strategist_service.py
ogt 0b4f80ee8a
All checks were successful
CD Pipeline / deploy (push) Successful in 1m14s
feat(ai-ops): Agent Action Ladder 骨幹(ADR-012 Phase 1)+ 週報套模板
ADR-012 核心設計:
- 4 級信任邊界:L0 直出 / L1 Hermes 觀察 / L2 NemoTron 診斷執行 / L3 OpenClaw HITL
- 通知鏈絕不中斷:每級失敗立即降級,保底 L0 模板 + 🟡 標記
- Audit Trail:每次 dispatch 自動寫 ai_insights (insight_type=agent_action)
- 安全白名單:L2 可呼叫 6 個安全 action(retry/query_km/silence + 3 個既有 NemoTron tool)

新增檔案:
- services/event_router.py — 事件分流入口,按 severity × event_type 分 Tier
- services/agent_actions.py — 安全 action 白名單(Phase 1 stub + 完整介面)
- docs/adr/ADR-012-agent-action-ladder.md — 完整設計 + 分階段計畫

Phase 1 狀態:
- L0 直出完整可用 
- L1 Hermes / L2 NemoTron 為 stub(Phase 2/3 填實作)
- Fallback 降級鏈已完整 
- 靜音檢查(is_silenced)+ Audit Trail 已就緒 

處理既有 TODO:
- services/openclaw_strategist_service.py::_notify_telegram_group()
  改用 telegram_templates.report() 統一週報格式

全景盤點(新 memory):
- reference_telegram_endpoints_map.md — 21 個 Telegram 發送點
- feedback_agent_action_ladder.md — 操作規範
  (+ 既有 ADR-011 跨專案隔離規範一併生效)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 12:46:51 +08:00

476 lines
18 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.
import os
import requests
import json
from datetime import datetime, timedelta
from services.logger_manager import SystemLogger
from database.manager import get_session
from sqlalchemy import text
from services.openclaw_learning_service import build_rag_context_by_date, store_insight
def _build_citation_footer(start_date: str, end_date: str) -> str:
"""
查詢 ai_insights 中 [start_date, end_date] 區間的洞察來源,
回傳結構化引用區塊供週報末尾附加。
"""
session = get_session()
try:
rows = session.execute(text("""
SELECT
DATE(created_at)::text AS day,
insight_type,
COUNT(*) AS cnt
FROM ai_insights
WHERE DATE(created_at) BETWEEN :s AND :e
AND status NOT IN ('archived')
GROUP BY DATE(created_at), insight_type
ORDER BY DATE(created_at), insight_type
"""), {"s": start_date, "e": end_date}).fetchall()
if not rows:
return ""
TYPE_LABEL = {
"price_alert": "競價告警",
"human_review": "人工覆核",
"recommendation": "推薦商品",
"km_price_competition": "KM競價情報",
"km_sales_anomaly": "KM銷量異常",
"km_promotion_opportunity": "KM促銷機會",
"km_market_trend": "KM市場趨勢",
"relearn_event": "重新學習事件",
"backup_status": "備份狀態",
"price_recommendation": "降價建議",
"price_decision_feedback": "降價決策回饋",
"weekly_meta": "週報策略",
"meta_analysis": "Meta 分析",
}
lines = ["\n\n---", "📚 **本報告引用來源:**"]
for day, itype, cnt in rows:
label = TYPE_LABEL.get(itype, itype)
lines.append(f"{day} 的「{label}」洞察({cnt} 筆)")
lines.append(f"\n> 資料區間:{start_date} {end_date}"
f"由 Hermes / NemoTron / OpenClaw 三層 AI 系統自動蒐集")
return "\n".join(lines)
except Exception as e:
sys_log.warning(f"[OCStrategist] citation footer 查詢失敗: {e}")
return ""
finally:
session.close()
sys_log = SystemLogger("OCStrategist").get_logger()
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
# === Gemini API 配置 ===
GEMINI_API_KEY = os.getenv('GEMINI_API_KEY', '')
GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models'
GEMINI_MODEL = 'gemini-2.0-flash'
def _call_gemini_flash(prompt: str) -> str:
""" 內部調用 Gemini 2.0 Flash API 的通用方法 """
if not GEMINI_API_KEY:
sys_log.error("[OCStrategist] 未設定 GEMINI_API_KEY無法呼叫 Gemini API。")
return "⚠️ 生成失敗:未設定 GEMINI_API_KEY"
payload = {
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
"generationConfig": {"temperature": 0.4, "maxOutputTokens": 4096},
}
try:
url = f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}"
resp = requests.post(url, headers={"Content-Type": "application/json"}, json=payload, timeout=60)
resp.raise_for_status()
data = resp.json()
candidate = data.get("candidates", [{}])[0]
parts = candidate.get("content", {}).get("parts", [])
text_out = "".join(p.get("text", "") for p in parts)
return text_out.strip()
except Exception as e:
sys_log.error(f"[OCStrategist] Gemini API 呼叫失敗: {e}")
return f"⚠️ 呼叫 Gemini 失敗:{e}"
def get_sales_summary_last_7d(start_date: str, end_date: str) -> str:
""" 獲取近 7 天的業績概況,轉為文字供 Gemini 參考 """
session = get_session()
try:
# daily_sales_snapshot 是動態表,嘗試查詢,若失敗則忽視
sql = """
SELECT
snapshot_date,
SUM(COALESCE("銷售金額"::numeric, 0)) as revenue,
SUM(COALESCE("數量"::numeric, 0)) as qty
FROM daily_sales_snapshot
WHERE snapshot_date >= :start_date AND snapshot_date <= :end_date
GROUP BY snapshot_date
ORDER BY snapshot_date ASC
"""
results = session.execute(text(sql), {"start_date": start_date, "end_date": end_date}).fetchall()
if not results:
return "查無 daily_sales_snapshot 的近 7 天數據。"
summary_parts = []
for row in results:
summary_parts.append(f"日期: {row[0]}, 總業績: {row[1]}, 總銷量: {row[2]}")
return "\n".join(summary_parts)
except Exception as e:
sys_log.warning(f"[OCStrategist] 獲取 7 天業績總結時發生異常 (資料表可能未備妥): {e}")
return "近 7 天業績數據暫無法取得。"
finally:
session.close()
def generate_weekly_strategy_report(force_tg_alert: bool = False) -> str:
"""
產生 EwoooC 高階經營決策週報 (Gemini 策略師)
核心流程:
1. 算出前 7 天的日期區間
2. 從 RAG 與 Sales DB 拉取過去一週的所有原始洞察與銷售數字
3. 利用 Gemini 分析與總結出決策報告
4. 將報表寫入 ai_insights 儲存 (Dual-Write)
5. 若 force_tg_alert=True 則推送至 Telegram
"""
sys_log.info("[OCStrategist] 開始產生每週策略報表...")
# 1. 決定時間區間 (看過去 7 天)
now = datetime.now()
end_dt = now - timedelta(days=1)
start_dt = end_dt - timedelta(days=6)
start_date_str = start_dt.strftime("%Y-%m-%d")
end_date_str = end_dt.strftime("%Y-%m-%d")
period_str = f"{now.year}-W{now.isocalendar()[1]}" # e.g., 2026-W16
# 2. 獲取 RAG & Sales 上下文
insights_context = build_rag_context_by_date(start_date_str, end_date_str)
sales_context = get_sales_summary_last_7d(start_date_str, end_date_str)
if not insights_context.strip():
insights_context = "(過去 7 天內無 AI 洞察告警紀錄)"
prompt = f"""
你是一位頂尖的電商行銷與定價策略師代號OpenClaw Gemini
你的任務是根據「過去 7 天系統紀錄的 AI 價格告警洞察」與「業績走勢」,撰寫一份給高階主管閱讀的【行銷與定價策略週報】。
### 資料區間
{start_date_str} ~ {end_date_str} ({period_str})
### [資料一] 過去 7 天業績走勢:
{sales_context}
### [資料二] 過去 7 天市場異常告警與洞察紀錄 (由 NemoTron & Hermes 提供)
{insights_context}
### 報告產出格式要求 (請嚴格遵守以 Markdown 輸出)
# 【EwoooC 每週 AI 策略報告】 ({period_str})
## 一、本週業績與市場總結
(概括過去 7 天的宏觀銷量表現與整體市場威脅概況)
## 二、關鍵威脅商品與定價挑戰
(從資料二中挑選出最嚴重的 3~5 項威脅商品,以條列式列出並指出競品價格差與我方影響)
## 三、行銷與操作建議 (下週 Action Items)
(根據上述現象,具體給出下週該執行的行銷加碼、特價活動或降價策略)
請用繁體中文,語氣保持專業、精煉、具備行動力。
在報告的每個具體數據或告警描述後,若來自「資料二」,請在句末標注【引用自 {start_date_str} {end_date_str} 的洞察】。
---
在報告最末尾,**必須**輸出以下標記行與 JSON若本週無明確降價建議則輸出空陣列
PRICE_DECISIONS_JSON:
[
{{
"product_sku": "貨號(若無則填空字串)",
"product_name": "商品名稱",
"current_price": 現價數字,
"suggested_price": 建議降至數字,
"reason": "一句話理由(中文)"
}}
]
只輸出純 JSON 陣列,不加 markdown 代碼塊,不加任何其他說明文字。
"""
# 3. 呼叫 Gemini
report_md = _call_gemini_flash(prompt)
# 附加 KM 引用來源區塊(無論 Gemini 是否成功,皆嘗試附加)
citation_footer = _build_citation_footer(start_date_str, end_date_str)
if citation_footer:
report_md = report_md + citation_footer
# 4. Dual-Write 存入 ai_insights 知識庫
if not report_md.startswith("⚠️ 呼叫 Gemini 失敗"):
insight_id = store_insight(
insight_type='weekly_meta',
content=report_md,
period=period_str,
metadata={"start_date": start_date_str, "end_date": end_date_str, "generated_by": "Gemini-2.0-Flash"}
)
sys_log.info(f"[OCStrategist] 週報產出成功並已雙寫存入 AI 知識庫 (ID: {insight_id})")
# 5. 解析降價決策並推送 Telegram Inline Keyboard
price_recs = _parse_price_recommendations(report_md)
if price_recs:
_send_price_decision_requests(price_recs, period_str, source_insight_id=insight_id)
# 6. 週報摘要通知 Telegram
if force_tg_alert:
_notify_telegram_group(report_md, period_str)
return report_md
def _parse_price_recommendations(report_md: str) -> list:
"""從 Gemini 週報中解析 PRICE_DECISIONS_JSON 區塊,回傳降價建議清單。"""
marker = "PRICE_DECISIONS_JSON:"
idx = report_md.find(marker)
if idx == -1:
return []
raw = report_md[idx + len(marker):].strip()
# 找最後一個 ] 確保取完整陣列(欄位值含 ] 時 find 會截斷)
end = raw.rfind("]")
if end == -1:
return []
raw = raw[: end + 1]
try:
recs = json.loads(raw)
if not isinstance(recs, list):
return []
valid = []
for r in recs:
if all(k in r for k in ("product_name", "current_price", "suggested_price", "reason")):
r.setdefault("product_sku", "")
try:
r["current_price"] = float(r["current_price"])
r["suggested_price"] = float(r["suggested_price"])
except (ValueError, TypeError):
continue
if r["suggested_price"] < r["current_price"]:
valid.append(r)
return valid
except json.JSONDecodeError as e:
sys_log.warning(f"[OCStrategist] price_recs JSON 解析失敗: {e}")
return []
def _send_price_decision_requests(recs: list, period_str: str, source_insight_id: int = None):
"""
對每筆降價建議:
1. 寫入 ai_insightsinsight_type='price_recommendation')取得 insight_id
2. 查詢所有 is_admin=true 的 Telegram 用戶
3. 用 TELEGRAM_BOT_TOKEN 發送含 ✅/❌ inline keyboard 的訊息
"""
bot_token = os.getenv('TELEGRAM_BOT_TOKEN')
if not bot_token:
sys_log.warning("[OCStrategist] TELEGRAM_BOT_TOKEN 未設定,略過降價決策通知")
return
# 查管理員 chat_id
session = get_session()
try:
rows = session.execute(
text("SELECT telegram_id FROM telegram_users WHERE is_active = true AND is_admin = true")
).fetchall()
except Exception as e:
sys_log.error(f"[OCStrategist] 查詢管理員失敗: {e}")
return
finally:
session.close()
if not rows:
sys_log.info("[OCStrategist] 無 is_admin 管理員,略過降價決策通知")
return
admin_ids = [row[0] for row in rows]
tg_url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
for rec in recs:
# 寫 KM
meta = {**rec, "period": period_str}
if source_insight_id:
meta["source_weekly_meta_id"] = source_insight_id
rec_insight_id = store_insight(
insight_type="price_recommendation",
content=f"建議 {rec['product_name']} 從 ${rec['current_price']:,.0f} 降至 ${rec['suggested_price']:,.0f}{rec['reason']}",
period=period_str,
product_sku=rec["product_sku"] or None,
metadata=meta,
)
if not rec_insight_id:
sys_log.warning(f"[OCStrategist] store_insight 失敗,略過 {rec['product_name']}")
continue
from services.telegram_templates import price_decision
msg, keyboard = price_decision(
product_name=rec["product_name"],
product_sku=rec["product_sku"],
current_price=rec["current_price"],
suggested_price=rec["suggested_price"],
reason=rec["reason"],
insight_id=rec_insight_id,
)
for chat_id in admin_ids:
try:
resp = requests.post(tg_url, json={
"chat_id": chat_id,
"text": msg,
"parse_mode": "Markdown",
"reply_markup": keyboard,
}, timeout=10)
if not resp.ok:
sys_log.warning(f"[OCStrategist] TG send 失敗 chat_id={chat_id}: {resp.text[:100]}")
except Exception as e:
sys_log.error(f"[OCStrategist] TG send 例外 chat_id={chat_id}: {e}")
sys_log.info(f"[OCStrategist] 降價決策推送 insight_id={rec_insight_id}{len(admin_ids)} 位管理員")
def _notify_telegram_group(report_md: str, period_str: str, report_type: str = "週報") -> None:
"""
推送策略報告至 Telegram 群組(已套用 telegram_templates.report() 統一格式)。
ADR-012 備註:週報類為 L3 OpenClaw 的週期性輸出,不經 event_router。
"""
bot_token = os.getenv("TELEGRAM_BOT_TOKEN") or os.getenv("OPENCLAW_BOT_TOKEN")
chat_id = os.getenv("OPENCLAW_GROUP_ID", "-1003940688311")
if not bot_token:
sys_log.warning("[OCStrategist] TELEGRAM_BOT_TOKEN 未設定,略過週報推播")
return
from services.telegram_templates import report as render_report
msg = render_report(
title="AI 策略報告已出爐",
report_type=report_type,
period=period_str,
content_md=report_md,
)
try:
requests.post(
f"https://api.telegram.org/bot{bot_token}/sendMessage",
json={"chat_id": chat_id, "text": msg, "parse_mode": "Markdown"},
timeout=10,
)
sys_log.info(f"[OCStrategist] Telegram {report_type}推送成功")
except Exception as e:
sys_log.error(f"[OCStrategist] Telegram {report_type}推送失敗: {e}")
def generate_meta_analysis_report() -> str:
"""
週日 02:00 OpenClaw 綜合 Meta-Analysis。
分析本週 AI 系統的「學習模式」與「告警效能」:
- 哪些 SKU 反覆觸發告警(高頻威脅)
- relearn 事件集中在哪些商品類型
- 各 Agent 分工占比Hermes/NemoTron/OpenClaw 貢獻度)
- 對下週 AI 排程策略的建議
輸出雙寫 ai_insightsinsight_type='meta_analysis')並推送 Telegram。
"""
sys_log.info("[OCStrategist] 開始產生週日 Meta-Analysis...")
now = datetime.now()
end_dt = now - timedelta(days=1) # 昨天
start_dt = end_dt - timedelta(days=6)
start_str = start_dt.strftime("%Y-%m-%d")
end_str = end_dt.strftime("%Y-%m-%d")
period_str = f"{now.year}-W{now.isocalendar()[1]}-meta"
# 從 DB 抽取本週 ai_insights 統計摘要
session = get_session()
stats_text = ""
try:
rows = session.execute(text("""
SELECT insight_type, product_sku, COUNT(*) AS cnt, AVG(avg_quality) AS avg_q
FROM ai_insights
WHERE DATE(created_at) BETWEEN :s AND :e
GROUP BY insight_type, product_sku
ORDER BY cnt DESC
LIMIT 30
"""), {"s": start_str, "e": end_str}).fetchall()
relearn_count = session.execute(text("""
SELECT COUNT(*) FROM ai_insights
WHERE status = 'relearn'
AND DATE(updated_at) BETWEEN :s AND :e
"""), {"s": start_str, "e": end_str}).scalar() or 0
archived_count = session.execute(text("""
SELECT COUNT(*) FROM ai_insights
WHERE status = 'archived'
AND DATE(updated_at) BETWEEN :s AND :e
"""), {"s": start_str, "e": end_str}).scalar() or 0
total_insights = session.execute(text("""
SELECT COUNT(*) FROM ai_insights
WHERE DATE(created_at) BETWEEN :s AND :e
"""), {"s": start_str, "e": end_str}).scalar() or 0
lines = [f"總洞察數:{total_insights} 筆 | relearn 標記:{relearn_count} 筆 | 本週歸檔:{archived_count}"]
for itype, sku, cnt, avg_q in rows:
sku_str = f"SKU={sku}" if sku else "(無 SKU"
lines.append(f"{itype} / {sku_str}{cnt} 次 (avg_quality={avg_q:.2f})")
stats_text = "\n".join(lines)
except Exception as e:
stats_text = f"DB 統計查詢失敗:{e}"
finally:
session.close()
prompt = f"""
你是 OpenClaw Gemini — EwoooC 三層 AI 競情系統的元分析師。
每週日凌晨,你負責審視本週 AI 系統自身的「學習效能」與「告警品質」,
並對下週的 AI 排程策略提出建議。
### 本週資料區間
{start_str} ~ {end_str} ({period_str})
### 本週 ai_insights 統計摘要(系統自動產生):
{stats_text}
### 請依以下格式產出 Meta-Analysis 報告(繁體中文):
# 【EwoooC AI 系統週報 Meta-Analysis】 ({period_str})
## 一、本週 AI 告警效能總覽
(總洞察量、各類型占比、品質分布概述)
## 二、高頻威脅 SKU 分析
(哪些 SKU 反覆觸發告警,是否已超出正常競價範圍)
## 三、relearn 事件洞察
(哪些商品類型的洞察被推翻,代表什麼市場信號)
## 四、AI 系統調優建議(下週)
(根據本週數據,建議調整 Hermes 閾值、NIM 配額分配、或 relearn 觸發條件)
語氣:分析師視角,精準、客觀,不誇大。
"""
report_md = _call_gemini_flash(prompt)
citation_footer = _build_citation_footer(start_str, end_str)
if citation_footer:
report_md = report_md + citation_footer
if not report_md.startswith("⚠️ 呼叫 Gemini 失敗"):
store_insight(
insight_type="meta_analysis",
content=report_md,
period=period_str,
metadata={
"start_date": start_str, "end_date": end_str,
"generated_by": "Gemini-2.0-Flash",
"total_insights": total_insights if 'total_insights' in dir() else 0,
},
)
_notify_telegram_group(report_md, period_str)
sys_log.info("[OCStrategist] Meta-Analysis 完成並推送 Telegram")
return report_md
if __name__ == "__main__":
# 手動測試用
print(generate_weekly_strategy_report(force_tg_alert=False))