All checks were successful
CD Pipeline / deploy (push) Successful in 1m14s
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>
476 lines
18 KiB
Python
476 lines
18 KiB
Python
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_insights(insight_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_insights(insight_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))
|