2962 lines
120 KiB
Python
2962 lines
120 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
services/openclaw_strategist_service.py
|
||
OpenClaw 戰略分析師(Ollama-first;Gemini 僅備援)
|
||
|
||
完整電商情報分析管線:
|
||
DB 爬蟲數據 + MCP 外部情報 → Ollama 三主機級聯 → Gemini 備援 → ai_insights 持久化 → Telegram 推播
|
||
|
||
提供:
|
||
generate_weekly_strategy_report() — 週報(每週一 06:00)
|
||
generate_meta_analysis_report() — AI 系統效能自我審視(每日 12:00, Phase 4 降頻)
|
||
|
||
分析維度:
|
||
1. 業績趨勢(MoM / WoW)
|
||
2. 競品價格比對
|
||
3. 定價策略建議
|
||
4. 行銷活動洞察
|
||
5. 季節性 / 節日機會
|
||
6. TOP 威脅 / 機會品項
|
||
7. 具體行動清單(48h 優先事項)
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import uuid
|
||
import requests
|
||
from datetime import datetime, timedelta
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from database.manager import get_session
|
||
from sqlalchemy import bindparam, text
|
||
|
||
from services.ai_call_logger import log_ai_call # Operation Ollama-First v5.0 P1
|
||
from services.gemini_guard import (
|
||
gemini_disabled_message,
|
||
get_gemini_api_key,
|
||
is_gemini_fallback_enabled,
|
||
)
|
||
from services.action_plan_dedupe import (
|
||
active_openclaw_recommendation_exists,
|
||
openclaw_action_metadata,
|
||
)
|
||
from services.rag_service import rag_service, is_rag_enabled # Phase 11 RAG-first(Q&A 限定)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Gemini 不可作為 OpenClaw 通用主路徑;所有週/月/meta/日報洞察都先走
|
||
# OllamaService 的 GCP-A → GCP-B → 111 級聯,Gemini 僅作最後備援。
|
||
STRATEGY_MODEL = os.getenv("OPENCLAW_MODEL", "gemini-2.5-flash")
|
||
NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY", "")
|
||
NVIDIA_NIM_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
|
||
NVIDIA_FALLBACK_MODEL = "meta/llama-3.3-70b-instruct"
|
||
TAIPEI_TZ_OFFSET = 8 # UTC+8
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Operation Ollama-First v5.0 — Gemini 僅 fallback
|
||
# - OPENCLAW_QA_OLLAMA_FIRST: 相容舊 env;false 只記 warning,不再允許 Gemini-first
|
||
# - OPENCLAW_QA_OLLAMA_MODEL: GCP Ollama 上的模型 tag(A2 推薦 qwen3:14b,9.3GB)
|
||
# - OPENCLAW_QA_OLLAMA_TIMEOUT: 單次 Ollama 呼叫超時(秒),低品質判定後仍會升級 Gemini
|
||
# OpenClaw Q&A 不提供單 caller host override;主機必須統一走 OllamaService 的
|
||
# GCP-A → GCP-B → 111 三主機級聯,避免 Telegram Q&A 被固定在單一 GCP 節點。
|
||
# 任何 deploy 不開 flag → Ollama-first;緊急時才顯式設 false 回 legacy。
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _qa_ollama_first_enabled() -> bool:
|
||
"""每次呼叫即時讀環境變數,允許 runtime toggle 灰度。"""
|
||
# 統帥 2026-05-03 23:30 指令:「都是要先以免費的優先!最後才是 Gemini」
|
||
# 預設 ON:Ollama (qwen3:14b @ GCP Primary 已拉) 為主,Gemini 為品質低時 fallback
|
||
# 緊急停用:export OPENCLAW_QA_OLLAMA_FIRST=false
|
||
return os.getenv('OPENCLAW_QA_OLLAMA_FIRST', 'true').strip().lower() in ('true', '1', 'yes', 'on')
|
||
|
||
|
||
OPENCLAW_QA_OLLAMA_MODEL = os.getenv('OPENCLAW_QA_OLLAMA_MODEL', 'qwen3:14b')
|
||
OPENCLAW_QA_OLLAMA_TIMEOUT = int(os.getenv('OPENCLAW_QA_OLLAMA_TIMEOUT', '60'))
|
||
OPENCLAW_STRATEGY_OLLAMA_MODEL = os.getenv(
|
||
'OPENCLAW_STRATEGY_OLLAMA_MODEL',
|
||
os.getenv('OPENCLAW_OLLAMA_MODEL', 'qwen2.5-coder:7b'),
|
||
)
|
||
OPENCLAW_STRATEGY_OLLAMA_TIMEOUT = int(os.getenv('OPENCLAW_STRATEGY_OLLAMA_TIMEOUT', '120'))
|
||
OPENCLAW_STRATEGY_OLLAMA_NUM_PREDICT = int(os.getenv('OPENCLAW_STRATEGY_OLLAMA_NUM_PREDICT', '2048'))
|
||
OPENCLAW_STRATEGY_OLLAMA_KEEP_ALIVE = os.getenv('OPENCLAW_STRATEGY_OLLAMA_KEEP_ALIVE', '5m')
|
||
|
||
# 繁體中文強制 system prompt(A2 黃燈警訊「Qwen 繁中短板」緩解策略)
|
||
QWEN3_TC_SYSTEM_PROMPT = """你是 momo 電商情報分析師「OpenClaw」。
|
||
|
||
【硬性規則】
|
||
1. 必須使用繁體中文(台灣用語),絕對禁止簡體字、大陸用語(例:寫「資料」不寫「数据」、寫「軟體」不寫「软件」)
|
||
2. 商品/品牌名稱保留原文不翻譯(如 momo / PChome / 蝦皮 / 全家)
|
||
3. 數字與貨幣保留原貌(NT$、%、件數、月份)
|
||
4. 若資料不足無法回答,明確說「資料不足,建議改問 ___」而非編造
|
||
|
||
【輸出風格】
|
||
- 直接回答,不要「以下是分析」開場白
|
||
- 結構化:用條列、表格、編號
|
||
- 控制在 300 字以內,除非統帥明確要求展開
|
||
"""
|
||
|
||
# 簡體字偵測樣本:A2 報告警訊核心檢查項;列出商業中文情境最常被簡體污染的單字
|
||
# 注意:避免列「於」「与」這類兩岸通用字;只取明確簡繁字差
|
||
_SIMPLIFIED_HINT_CHARS = frozenset([
|
||
# 商業/科技高頻簡繁差字(每字繁體對照於註解)
|
||
'设', # 設
|
||
'当', # 當
|
||
'点', # 點
|
||
'问', # 問
|
||
'获', # 獲
|
||
'为', # 為
|
||
'么', # 麼
|
||
'资', # 資
|
||
'产', # 產
|
||
'业', # 業
|
||
'务', # 務
|
||
'说', # 說
|
||
'听', # 聽
|
||
'关', # 關
|
||
'词', # 詞
|
||
'这', # 這
|
||
'过', # 過
|
||
'让', # 讓
|
||
'应', # 應
|
||
'亿', # 億
|
||
'请', # 請
|
||
'观', # 觀
|
||
'战', # 戰
|
||
'体', # 體
|
||
'价', # 價
|
||
'场', # 場
|
||
'动', # 動
|
||
'号', # 號
|
||
'团', # 團
|
||
'类', # 類
|
||
'广', # 廣
|
||
'处', # 處
|
||
'执', # 執
|
||
'决', # 決
|
||
'约', # 約
|
||
'级', # 級
|
||
'态', # 態
|
||
'势', # 勢
|
||
'运', # 運
|
||
'营', # 營
|
||
])
|
||
|
||
# 拒答訊號:模型表達「無法回答」即視為低品質
|
||
_REFUSAL_PATTERNS = (
|
||
'無法回答', '無法回覆', '我不知道', '我無從', "I cannot",
|
||
"I don't know", '抱歉,我無法', '抱歉,我無法',
|
||
'需要更多資訊', '需要更多信息', '無相關資料',
|
||
)
|
||
|
||
__all__ = [
|
||
"generate_daily_report",
|
||
"generate_weekly_strategy_report",
|
||
"generate_monthly_report",
|
||
"generate_meta_analysis_report",
|
||
"generate_strategy_response",
|
||
]
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Telegram NLP 互動入口(輕量查詢,不走完整報告管線)
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def generate_strategy_response(query: str, context: Optional[Dict[str, Any]] = None) -> str:
|
||
"""給 Telegram NLP 使用的輕量策略回覆。
|
||
|
||
Contract:
|
||
query: 使用者自然語言訊息(繁體中文)
|
||
context: 可選,{"intent": str, "user_id": int, ...}
|
||
Returns:
|
||
繁體中文回覆字串。所有 LLM 失敗時回降級訊息(永遠回字串、不拋例外)。
|
||
|
||
路由(Operation Ollama-First v5.0 — Phase 3):
|
||
GCP-A/GCP-B/111 Ollama qwen3 → 品質檢測 → fallback Gemini → NIM。
|
||
OPENCLAW_QA_OLLAMA_FIRST=false 已不再允許 Gemini-first,只保留 warning 相容舊設定。
|
||
"""
|
||
q = (query or "").strip()
|
||
if not q:
|
||
return "請輸入您的問題,例如:本週業績趨勢、競品價差分析、產出週報 PPT。"
|
||
|
||
request_id = f"qa-{uuid.uuid4().hex[:8]}"
|
||
|
||
# ── Phase 11 RAG-first(feature flag 預設 OFF;只在 Q&A 入口接,週月年報不接)──
|
||
# 高信心 RAG 命中 → 直接回 ai_insights 內容,避免 LLM 呼叫
|
||
# 低信心或 flag OFF → 走後續 Ollama-first / fallback 路徑
|
||
if is_rag_enabled():
|
||
try:
|
||
rag = rag_service.query(
|
||
text=q, caller='openclaw_qa',
|
||
threshold=0.85, request_id=request_id,
|
||
mark_saved_call=True,
|
||
)
|
||
if rag.has_high_confidence:
|
||
logger.info(
|
||
"[OpenClaw][QA] RAG hit request_id=%s top_score=%.3f → 跳過 LLM",
|
||
request_id, rag.hits[0].get('score', 0),
|
||
)
|
||
return rag.synthesize()
|
||
except Exception as exc:
|
||
logger.warning("[OpenClaw][QA] RAG query failed (%s), fallback LLM", exc)
|
||
|
||
# ── 主路徑:永遠 Ollama 優先。舊 flag=false 不再允許 Gemini-first。──
|
||
if not _qa_ollama_first_enabled():
|
||
logger.warning(
|
||
"[OpenClaw][QA] OPENCLAW_QA_OLLAMA_FIRST=false 已被忽略;Gemini 僅作 Ollama 失敗備援"
|
||
)
|
||
ollama_reply = _call_qwen3_qa(q, context, request_id)
|
||
if ollama_reply and not _is_low_quality_response(ollama_reply):
|
||
return ollama_reply
|
||
# 品質守門失敗或 Ollama 離線 → 降級 Gemini(fallback_to 已於 _call_qwen3_qa 內標記)
|
||
logger.info(
|
||
"[OpenClaw][QA] Ollama 主路徑未通過(無回應或低品質),fallback Gemini;request_id=%s",
|
||
request_id,
|
||
)
|
||
|
||
# ── 備援路徑:Gemini → NIM(A4 已接 ai_call_logger)──
|
||
return _legacy_gemini_first_qa(q, context, request_id=request_id)
|
||
|
||
|
||
def _legacy_gemini_first_qa(
|
||
q: str,
|
||
context: Optional[Dict[str, Any]],
|
||
request_id: Optional[str] = None,
|
||
) -> str:
|
||
"""Gemini 備援/緊急退路;正常情況只在 Ollama 主路徑失敗後使用。"""
|
||
system_prompt = (
|
||
"你是 MOMO Pro 電商情報策略師「OpenClaw」。以繁體中文(台灣用語)回覆使用者。"
|
||
"嚴禁簡體字,嚴禁空洞套話。若使用者要求的資料需即時查詢,"
|
||
"請告知使用者相關可用指令(例如 /daily、/weekly、/threats)。"
|
||
"回覆長度控制在 500 字內,可用 Markdown 條列。"
|
||
)
|
||
user_prompt = f"使用者問題:{q}\n上下文:{json.dumps(context or {}, ensure_ascii=False)}"
|
||
|
||
# Gemini 只在中央 guard 顯式解鎖時可用;無 key 或失敗時自動備援 NVIDIA NIM。
|
||
text_reply = None
|
||
if get_gemini_api_key("openclaw_strategy"):
|
||
try:
|
||
text_reply = _call_gemini(
|
||
system_prompt,
|
||
user_prompt,
|
||
temperature=0.5,
|
||
caller="openclaw_qa_gemini_fallback",
|
||
)
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] Gemini 呼叫失敗,備援 NVIDIA NIM:%s", e)
|
||
|
||
if not text_reply and NVIDIA_API_KEY:
|
||
try:
|
||
text_reply = _call_nvidia_nim(system_prompt, user_prompt, caller="openclaw_qa")
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] NVIDIA NIM 備援也失敗:%s", e)
|
||
|
||
if not text_reply:
|
||
return (
|
||
"策略師暫時無法回覆(Gemini 與 NVIDIA NIM 均離線)。\n"
|
||
"請改用:/daily、/weekly、/threats 取得結構化報告。"
|
||
)
|
||
return text_reply
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Phase 3 — Ollama Q&A 路徑 + 品質守門
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
def _call_qwen3_qa(
|
||
question: str,
|
||
context: Optional[Dict[str, Any]],
|
||
request_id: str,
|
||
) -> Optional[str]:
|
||
"""呼叫 GCP Ollama 上的 qwen3:14b(或環境變數指定的模型)回答 Telegram QA。
|
||
|
||
回傳 None 表示「呼叫失敗或回空」,呼叫端會自動 fallback Gemini。
|
||
本函式不負責品質判定(呼叫端用 `_is_low_quality_response` 判,避免邏輯耦合)。
|
||
全程包在 `log_ai_call` context manager;失敗時 set_error + fallback_to_caller。
|
||
"""
|
||
user_prompt = (
|
||
f"使用者問題:{question}\n"
|
||
f"上下文:{json.dumps(context or {}, ensure_ascii=False)}"
|
||
)
|
||
with log_ai_call(
|
||
caller='openclaw_qa',
|
||
provider='gcp_ollama',
|
||
model=OPENCLAW_QA_OLLAMA_MODEL,
|
||
request_id=request_id,
|
||
meta={
|
||
'flag': 'OPENCLAW_QA_OLLAMA_FIRST',
|
||
'route': 'ollama_first',
|
||
'temperature': 0.5,
|
||
},
|
||
) as ctx:
|
||
try:
|
||
from services.ollama_service import OllamaService, get_host_label, get_provider_tag
|
||
|
||
ctx.set_prompt_hash(user_prompt)
|
||
ollama = OllamaService(model=OPENCLAW_QA_OLLAMA_MODEL)
|
||
resp = ollama.generate(
|
||
prompt=user_prompt,
|
||
model=OPENCLAW_QA_OLLAMA_MODEL,
|
||
system_prompt=QWEN3_TC_SYSTEM_PROMPT,
|
||
temperature=0.5,
|
||
timeout=OPENCLAW_QA_OLLAMA_TIMEOUT,
|
||
)
|
||
actual_provider = get_provider_tag(resp.host or '')
|
||
ctx.set_provider(actual_provider)
|
||
ctx.set_model(resp.model or OPENCLAW_QA_OLLAMA_MODEL)
|
||
ctx.set_tokens(
|
||
input=resp.input_tokens,
|
||
output=resp.output_tokens,
|
||
)
|
||
ctx.add_meta('host', resp.host)
|
||
ctx.add_meta('host_label', get_host_label(resp.host or ''))
|
||
if resp.model and resp.model != OPENCLAW_QA_OLLAMA_MODEL:
|
||
ctx.add_meta('requested_model', OPENCLAW_QA_OLLAMA_MODEL)
|
||
if not resp.success:
|
||
ctx.set_error(resp.error or 'ollama generate failed')
|
||
ctx.fallback_to_caller('openclaw_qa_gemini_fallback')
|
||
logger.warning(
|
||
"[OpenClaw][QA] qwen3 三主機級聯失敗 request_id=%s host=%s: %s",
|
||
request_id, resp.host, resp.error,
|
||
)
|
||
return None
|
||
|
||
text_reply = (resp.content or '').strip()
|
||
if not text_reply:
|
||
ctx.set_error('empty_response')
|
||
ctx.fallback_to_caller('openclaw_qa_gemini_fallback')
|
||
return None
|
||
return text_reply
|
||
except Exception as e:
|
||
logger.warning(
|
||
"[OpenClaw][QA] qwen3 級聯呼叫例外 request_id=%s: %s",
|
||
request_id, e,
|
||
)
|
||
ctx.set_error(f"{type(e).__name__}: {str(e)[:200]}")
|
||
ctx.fallback_to_caller('openclaw_qa_gemini_fallback')
|
||
return None
|
||
|
||
|
||
# 低品質判定常數:避免 magic number 散落於規則裡
|
||
_QA_MIN_LENGTH = 50 # 規則 1:長度下限
|
||
_QA_SIMPLIFIED_THRESHOLD = 3 # 規則 2:簡體字數量門檻
|
||
_QA_FLOWING_TEXT_LENGTH = 200 # 規則 4:「200+ 字無斷行」流水帳判定
|
||
|
||
|
||
def _is_low_quality_response(text: Optional[str]) -> bool:
|
||
"""判斷 Ollama 回應品質低,需升級 Gemini。
|
||
|
||
觸發條件(任一即視為低品質):
|
||
1. 空字串或長度 < _QA_MIN_LENGTH 字元
|
||
2. 簡體字污染:>= _QA_SIMPLIFIED_THRESHOLD 個簡體 hint 字元(A2 黃燈警訊核心檢查)
|
||
3. 拒答訊號:包含「無法回答」「我不知道」等模式
|
||
4. 結構性差:> _QA_FLOWING_TEXT_LENGTH 字但完全沒有換行(流水帳)
|
||
|
||
Returns:
|
||
True → 低品質,呼叫端應 fallback Gemini
|
||
False → 可接受
|
||
"""
|
||
if not text:
|
||
return True
|
||
stripped = text.strip()
|
||
if len(stripped) < _QA_MIN_LENGTH:
|
||
return True
|
||
|
||
# 規則 2:簡體字污染(A2 警訊:Qwen 繁中短板)
|
||
simplified_count = sum(1 for c in stripped if c in _SIMPLIFIED_HINT_CHARS)
|
||
if simplified_count >= _QA_SIMPLIFIED_THRESHOLD:
|
||
logger.info("[OpenClaw][QA] 低品質:偵測 %d 個簡體字 hint", simplified_count)
|
||
return True
|
||
|
||
# 規則 3:拒答訊號
|
||
for pattern in _REFUSAL_PATTERNS:
|
||
if pattern in stripped:
|
||
logger.info("[OpenClaw][QA] 低品質:偵測拒答模式 '%s'", pattern)
|
||
return True
|
||
|
||
# 規則 4:結構性 — 200+ 字無斷行 = 流水帳
|
||
if len(stripped) > _QA_FLOWING_TEXT_LENGTH and stripped.count('\n') < 1:
|
||
logger.info("[OpenClaw][QA] 低品質:%d 字無斷行(流水帳)", len(stripped))
|
||
return True
|
||
|
||
# ─── Phase 17 (2026-05-04):強化規則(A2 警訊深化)───
|
||
# 規則 5:純英文回應(繁中問題不該用英文答;Qwen 偶有此問題)
|
||
han_chars = sum(1 for c in stripped if '一' <= c <= '鿿')
|
||
if len(stripped) > 80 and han_chars < len(stripped) * 0.3:
|
||
logger.info("[OpenClaw][QA] 低品質:中文字元占比 %.1f%% < 30%%(純英文回應)",
|
||
100 * han_chars / max(len(stripped), 1))
|
||
return True
|
||
|
||
# 規則 6:thinking-mode 漏洞(DeepSeek-R1 / Qwen3 reasoning model 偶將
|
||
# <think>...</think> 區塊洩漏到輸出,這種訊息不適合給統帥看)
|
||
if '<think>' in stripped or '</think>' in stripped:
|
||
logger.info("[OpenClaw][QA] 低品質:reasoning model thinking 區塊洩漏")
|
||
return True
|
||
|
||
# 規則 7:重複片段偵測(LLM 卡迴圈時會重複同段話 N 次)
|
||
if len(stripped) > 200:
|
||
head = stripped[:50]
|
||
if stripped.count(head) >= 3:
|
||
logger.info("[OpenClaw][QA] 低品質:偵測重複迴圈(前 50 字出現 %d 次)",
|
||
stripped.count(head))
|
||
return True
|
||
|
||
# 規則 8:佔位符未填充(template render 失敗會留 {{var}} / [TODO] 等 markers)
|
||
placeholder_markers = ['{{', '[todo]', '[TODO]', '{placeholder}', '<待填>', '尚未實作']
|
||
if any(m in stripped for m in placeholder_markers):
|
||
logger.info("[OpenClaw][QA] 低品質:偵測佔位符 / 未實作標記")
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# DB 數據讀取層
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def _fetch_sales_summary(days: int = 14) -> Dict[str, Any]:
|
||
"""近 N 天業績彙總(本期 / 前期 對比)"""
|
||
session = get_session()
|
||
try:
|
||
max_date_row = session.execute(text("SELECT MAX(snapshot_date::date) FROM daily_sales_snapshot")).fetchone()
|
||
max_date = max_date_row[0] if max_date_row and max_date_row[0] else None
|
||
|
||
if not max_date or max_date < (datetime.now().date() - timedelta(days=2)):
|
||
# ADR-019 Phase 2 / critic post-review BLOCKER #9:
|
||
# stale 分支必須 return 完整 shape,避免沒套 stale gate 的上游 caller
|
||
# 拿到 0 而靜默產出「NT$0 業績」報告。
|
||
# 數值欄位用 None(而非 0),讓 prompt template 的 `:,.0f` 在誤用時
|
||
# raise TypeError → 比靜默 0 更明顯,迫使呼叫端必須 `if sales.get("stale")` 擋下。
|
||
return {
|
||
"stale": True,
|
||
"last_date": str(max_date) if max_date else "None",
|
||
"daily": [],
|
||
"current_7d_revenue": None,
|
||
"prev_7d_revenue": None,
|
||
"wow_pct": None,
|
||
"sku_count": None,
|
||
}
|
||
|
||
rows = session.execute(text("""
|
||
SELECT
|
||
snapshot_date::date AS dt,
|
||
SUM(COALESCE("總業績"::numeric, 0)) AS revenue,
|
||
COUNT(DISTINCT "商品ID") AS sku_count
|
||
FROM daily_sales_snapshot
|
||
WHERE snapshot_date::date >= CURRENT_DATE - :days
|
||
GROUP BY dt
|
||
ORDER BY dt DESC
|
||
"""), {"days": days}).fetchall()
|
||
|
||
data = [{"date": str(r[0]), "revenue": float(r[1] or 0), "sku_count": int(r[2] or 0)}
|
||
for r in rows]
|
||
|
||
mid = len(data) // 2
|
||
curr_rev = sum(d["revenue"] for d in data[:mid]) if mid else 0
|
||
prev_rev = sum(d["revenue"] for d in data[mid:]) if mid else 0
|
||
wow = ((curr_rev - prev_rev) / prev_rev * 100) if prev_rev else 0
|
||
|
||
return {
|
||
"daily": data[:7],
|
||
"current_7d_revenue": curr_rev,
|
||
"prev_7d_revenue": prev_rev,
|
||
"wow_pct": round(wow, 1),
|
||
}
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] 業績數據讀取失敗: %s", e)
|
||
raise
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _fetch_top_threats(limit: int = 10) -> List[Dict]:
|
||
"""最新 TOP N 競價威脅(來自 Hermes 分析)"""
|
||
session = get_session()
|
||
try:
|
||
rows = session.execute(text("""
|
||
SELECT product_sku, content, confidence, metadata_json, created_at
|
||
FROM ai_insights
|
||
WHERE insight_type = 'price_alert'
|
||
AND status = 'approved'
|
||
AND created_at >= NOW() - INTERVAL '48 hours'
|
||
ORDER BY confidence DESC
|
||
LIMIT :lim
|
||
"""), {"lim": limit}).fetchall()
|
||
|
||
result = []
|
||
for r in rows:
|
||
meta = {}
|
||
try:
|
||
meta = json.loads(r[3]) if r[3] else {}
|
||
except Exception:
|
||
logger.debug("[OpenClaw] price_alert metadata_json decode failed sku=%s", r[0], exc_info=True)
|
||
result.append({
|
||
"sku": r[0],
|
||
"summary": (r[1] or "")[:200],
|
||
"confidence": float(r[2] or 0),
|
||
"gap_pct": meta.get("gap_pct", 0),
|
||
"sales_delta": meta.get("sales_7d_delta_pct", 0),
|
||
"momo_price": meta.get("momo_price"),
|
||
"pchome_price": meta.get("pchome_price"),
|
||
})
|
||
return result
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] 威脅數據讀取失敗: %s", e)
|
||
raise
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _fetch_top_recommendations(limit: int = 10) -> List[Dict]:
|
||
"""最新定價建議"""
|
||
session = get_session()
|
||
try:
|
||
rows = session.execute(text("""
|
||
SELECT sku, name, reason, strategy, confidence,
|
||
momo_price, pchome_price, gap_pct, sales_7d_delta
|
||
FROM ai_price_recommendations
|
||
WHERE status = 'pending'
|
||
AND created_at >= NOW() - INTERVAL '48 hours'
|
||
ORDER BY confidence DESC
|
||
LIMIT :lim
|
||
"""), {"lim": limit}).fetchall()
|
||
return [dict(zip(
|
||
["sku","name","reason","strategy","confidence","momo_price","pchome_price","gap_pct","sales_delta"],
|
||
r
|
||
)) for r in rows]
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] 建議數據讀取失敗: %s", e)
|
||
raise
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _fetch_category_breakdown(days: int = 7) -> List[Dict]:
|
||
"""品類業績分佈"""
|
||
session = get_session()
|
||
try:
|
||
rows = session.execute(text("""
|
||
SELECT p.category,
|
||
SUM(COALESCE(s."總業績"::numeric, 0)) AS revenue,
|
||
COUNT(DISTINCT p.i_code) AS sku_count
|
||
FROM daily_sales_snapshot s
|
||
JOIN products p ON p.name = s."商品名稱"
|
||
WHERE s.snapshot_date::date >= CURRENT_DATE - :days
|
||
AND p.status = 'ACTIVE'
|
||
GROUP BY p.category
|
||
ORDER BY revenue DESC
|
||
LIMIT 10
|
||
"""), {"days": days}).fetchall()
|
||
return [{"category": r[0], "revenue": float(r[1] or 0), "sku_count": int(r[2] or 0)}
|
||
for r in rows]
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] 品類數據讀取失敗: %s", e)
|
||
raise
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _fetch_competitor_summary() -> Dict[str, Any]:
|
||
"""競品價格整體概況"""
|
||
session = get_session()
|
||
try:
|
||
coverage: Dict[str, Any] = {}
|
||
review_decision_brief: Dict[str, Any] = {
|
||
"text": "(目前沒有待覆核決策信封)",
|
||
"lines": [],
|
||
"items": [],
|
||
"hitl_count": 0,
|
||
"auto_execute_blocked_count": 0,
|
||
}
|
||
if session.bind is not None:
|
||
try:
|
||
from services.competitor_intel_repository import (
|
||
fetch_competitor_coverage,
|
||
fetch_competitor_review_queue,
|
||
summarize_review_decision_envelopes,
|
||
)
|
||
|
||
coverage = fetch_competitor_coverage(session.bind) or {}
|
||
review_queue = fetch_competitor_review_queue(session.bind, limit=5) or []
|
||
review_decision_brief = summarize_review_decision_envelopes(review_queue, limit=5)
|
||
except Exception as repo_exc:
|
||
logger.warning("[OpenClaw] 競品覆核信封摘要讀取失敗,降級只讀正式價差: %s", repo_exc)
|
||
|
||
row = session.execute(text("""
|
||
SELECT
|
||
COUNT(*) AS total,
|
||
AVG((cp.price - pr.price) / pr.price * 100) AS avg_gap_pct,
|
||
SUM(CASE WHEN cp.price < pr.price * 0.9 THEN 1 ELSE 0 END) AS undercut_count,
|
||
SUM(CASE WHEN cp.price > pr.price * 1.1 THEN 1 ELSE 0 END) AS premium_count
|
||
FROM competitor_prices cp
|
||
JOIN products p ON p.i_code = cp.sku
|
||
JOIN (
|
||
SELECT DISTINCT ON (product_id) product_id, price
|
||
FROM price_records ORDER BY product_id, timestamp DESC
|
||
) pr ON pr.product_id = p.id
|
||
WHERE cp.expires_at > NOW()
|
||
AND COALESCE(cp.match_score, 0) >= 0.76
|
||
AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
|
||
""")).fetchone()
|
||
return {
|
||
"total_skus": int((row[0] if row else 0) or 0),
|
||
"avg_gap_pct": round(float((row[1] if row else 0) or 0), 1),
|
||
"undercut_count": int((row[2] if row else 0) or 0),
|
||
"premium_count": int((row[3] if row else 0) or 0),
|
||
"match_rate": float(coverage.get("match_rate") or 0),
|
||
"active_with_price": int(coverage.get("active_with_price") or 0),
|
||
"decision_ready_matches": int(
|
||
coverage.get("decision_ready_matches") or coverage.get("fresh_matches") or 0
|
||
),
|
||
"decision_ready_rate": float(coverage.get("decision_ready_rate") or 0),
|
||
"unit_comparable_count": int(coverage.get("unit_comparable_count") or 0),
|
||
"rescore_accepted_count": int(coverage.get("rescore_accepted_count") or 0),
|
||
"review_queue_count": int(coverage.get("actionable_review_count") or 0),
|
||
"manual_accept_count": int(coverage.get("manual_accept_count") or 0),
|
||
"manual_reject_count": int(coverage.get("manual_reject_count") or 0),
|
||
"manual_unit_price_count": int(coverage.get("manual_unit_price_count") or 0),
|
||
"manual_accept_rate": float(coverage.get("manual_accept_rate") or 0),
|
||
"review_decision_brief": review_decision_brief,
|
||
"review_decision_text": review_decision_brief.get("text") or "(目前沒有待覆核決策信封)",
|
||
}
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] 競品概況讀取失敗: %s", e)
|
||
raise
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# DB 寫入層
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def _save_to_ai_insights(
|
||
insight_type: str,
|
||
content: str,
|
||
confidence: float,
|
||
metadata: Dict[str, Any],
|
||
period: Optional[str] = None,
|
||
) -> Optional[int]:
|
||
"""將分析結果持久化到 ai_insights"""
|
||
session = get_session()
|
||
try:
|
||
row = session.execute(text("""
|
||
INSERT INTO ai_insights
|
||
(insight_type, content, confidence, created_by, status,
|
||
metadata_json, period, created_at)
|
||
VALUES (:type, :content, :conf, 'openclaw', 'active', :meta, :period, NOW())
|
||
RETURNING id
|
||
"""), {
|
||
"type": insight_type,
|
||
"content": content[:8000],
|
||
"conf": confidence,
|
||
"meta": json.dumps(metadata, ensure_ascii=False),
|
||
"period": period or datetime.now().strftime("%Y-%m-%d"),
|
||
}).fetchone()
|
||
session.commit()
|
||
insight_id = row[0] if row else None
|
||
if insight_id:
|
||
try:
|
||
from services.openclaw_learning_service import enqueue_insight_embedding
|
||
enqueue_insight_embedding(insight_id, insight_type, content[:8000], period or datetime.now().strftime("%Y-%m-%d"))
|
||
except Exception as embed_err:
|
||
logger.warning("[OpenClaw] embedding queue enqueue failed id=%s: %s", insight_id, embed_err)
|
||
logger.info("[OpenClaw] ai_insights 寫入成功 id=%s type=%s", insight_id, insight_type)
|
||
return insight_id
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] ai_insights 寫入失敗: %s", e)
|
||
session.rollback()
|
||
return None
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _send_data_stale_alert(report_type: str, last_date: str, period: str) -> bool:
|
||
"""資料停更告警(daily/weekly/monthly 共用,24h dedupe)。
|
||
|
||
critic post-review BLOCKER #1 抽取:daily/weekly/monthly 任一報告路徑檢查到
|
||
`_fetch_sales_summary` 回傳 `stale=True` 時呼叫此函式,避免:
|
||
1. 每天 09:00 daily 偵測到 stale → 立刻發 NT$0 報告
|
||
2. 多份報告同時偵測到 stale → 同一天送 N 次告警噪音
|
||
|
||
Dedupe 機制:以 ai_insights 表查近 24 小時是否已有同 report_type 的
|
||
`insight_type='data_stale_alert'` 紀錄;若有,跳過 telegram 發送但仍 return True
|
||
讓上游照常 return skipped。若無,發送 telegram 並寫入 ai_insights 留痕。
|
||
|
||
Args:
|
||
report_type: "daily_report" / "weekly_strategy" / "monthly_report"
|
||
last_date: daily_sales_snapshot 最後一筆 snapshot_date(字串)
|
||
period: 該報告原本的 period 字串(用於告警訊息可讀性)
|
||
|
||
Returns:
|
||
True — 告警已送出 或 已 dedupe 跳過(上游視為「停更已通知」)
|
||
False — DB / Telegram 都失敗(上游可選擇 fallback)
|
||
"""
|
||
# Step 1: dedupe 查詢
|
||
session = get_session()
|
||
try:
|
||
dedupe_row = session.execute(text("""
|
||
SELECT id FROM ai_insights
|
||
WHERE insight_type = 'data_stale_alert'
|
||
AND created_by = 'openclaw'
|
||
AND created_at >= NOW() - INTERVAL '24 hours'
|
||
AND metadata_json::jsonb->>'report_type' = :rt
|
||
ORDER BY created_at DESC
|
||
LIMIT 1
|
||
"""), {"rt": report_type}).fetchone()
|
||
except Exception as e:
|
||
# dedupe 查詢失敗不該擋告警(寧可重複也別漏報)
|
||
logger.warning("[OpenClaw] data_stale_alert dedupe 查詢失敗 rt=%s: %s", report_type, e)
|
||
dedupe_row = None
|
||
finally:
|
||
session.close()
|
||
|
||
if dedupe_row:
|
||
logger.info(
|
||
"[OpenClaw] data_stale_alert 已於 24h 內送過,跳過重複告警 rt=%s last_date=%s",
|
||
report_type, last_date,
|
||
)
|
||
return True
|
||
|
||
# Step 2: 發送 telegram
|
||
msg = (
|
||
f"⚠️ [資料停更告警] {report_type}\n"
|
||
f"daily_sales_snapshot 最後更新:{last_date}\n"
|
||
f"原訂報告期間:{period}\n"
|
||
f"請檢查人工上傳流程;本告警已自動跳過該報告產出。"
|
||
)
|
||
sent_ok = False
|
||
try:
|
||
from services.telegram_templates import _send_telegram_raw
|
||
_send_telegram_raw(msg)
|
||
sent_ok = True
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] data_stale_alert telegram 發送失敗 rt=%s: %s", report_type, e)
|
||
|
||
# Step 3: 寫入 ai_insights 留痕(即使 telegram 失敗也寫,下次 dedupe 才有依據)
|
||
try:
|
||
_save_to_ai_insights(
|
||
insight_type="data_stale_alert",
|
||
content=msg,
|
||
confidence=1.0,
|
||
metadata={
|
||
"report_type": report_type,
|
||
"last_date": last_date,
|
||
"period": period,
|
||
"telegram_sent": sent_ok,
|
||
},
|
||
period=datetime.now().strftime("%Y-%m-%d"),
|
||
)
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] data_stale_alert ai_insights 寫入失敗 rt=%s: %s", report_type, e)
|
||
|
||
return sent_ok
|
||
|
||
|
||
def _find_existing_weekly_strategy(
|
||
period: str,
|
||
sent_only: bool = False,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""查詢同一週期最新已啟用週報(不重複生成)。
|
||
|
||
`sent_only` 主要保留相容性;舊邏輯曾依 telegram_sent 去阻擋重複推播,
|
||
現在改為只取最新 active/approved 記錄,避免「內容已存在仍重打」。
|
||
"""
|
||
session = get_session()
|
||
try:
|
||
row = session.execute(text("""
|
||
SELECT id, created_at
|
||
FROM ai_insights
|
||
WHERE insight_type = 'weekly_strategy'
|
||
AND created_by = 'openclaw'
|
||
AND period = :period
|
||
AND status IN ('active', 'approved')
|
||
ORDER BY created_at DESC
|
||
LIMIT 1
|
||
"""), {"period": period}).fetchone()
|
||
if not row:
|
||
return None
|
||
return {"id": row[0], "created_at": row[1]}
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] 週報去重查詢失敗 period=%s: %s", period, e)
|
||
return None
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _load_weekly_strategy_payload(period: str) -> Optional[Dict[str, Any]]:
|
||
"""載入同一週期最新已啟用週報正文與 metadata(供重用/直接回傳)。"""
|
||
session = get_session()
|
||
try:
|
||
row = session.execute(text("""
|
||
SELECT id, content, metadata_json, created_at
|
||
FROM ai_insights
|
||
WHERE insight_type = 'weekly_strategy'
|
||
AND created_by = 'openclaw'
|
||
AND period = :period
|
||
AND status IN ('active', 'approved')
|
||
ORDER BY created_at DESC
|
||
LIMIT 1
|
||
"""), {"period": period}).fetchone()
|
||
if not row:
|
||
return None
|
||
meta = _normalize_weekly_strategy_metadata(row[2])
|
||
return {"id": row[0], "content": row[1], "metadata": meta, "created_at": row[3]}
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] 週報載入失敗 period=%s: %s", period, e)
|
||
return None
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _normalize_weekly_strategy_metadata(raw_meta: Any) -> Dict[str, Any]:
|
||
"""將 ai_insights metadata 轉成 dict,並補入預設欄位避免型別錯誤。"""
|
||
meta = raw_meta or {}
|
||
if isinstance(meta, str):
|
||
try:
|
||
meta = json.loads(meta)
|
||
except Exception:
|
||
meta = {}
|
||
if not isinstance(meta, dict):
|
||
meta = {}
|
||
if "telegram_sent" not in meta:
|
||
meta["telegram_sent"] = False
|
||
if "telegram_sending" not in meta:
|
||
meta["telegram_sending"] = False
|
||
return meta
|
||
|
||
|
||
def _set_weekly_strategy_metadata(insight_id: int, metadata: Dict[str, Any]) -> bool:
|
||
"""以 metadata 全量覆寫指定週報記錄,並回傳是否寫入成功。"""
|
||
if not insight_id:
|
||
return False
|
||
session = get_session()
|
||
try:
|
||
session.execute(
|
||
text("""
|
||
UPDATE ai_insights
|
||
SET metadata_json = :metadata
|
||
WHERE id = :id
|
||
"""),
|
||
{"id": insight_id, "metadata": json.dumps(metadata, ensure_ascii=False)},
|
||
)
|
||
session.commit()
|
||
return True
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] 更新週報 metadata 失敗 insight_id=%s: %s", insight_id, e)
|
||
session.rollback()
|
||
return False
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _set_weekly_strategy_telegram_locked(insight_id: int, *, telegram_sent: Optional[bool] = None,
|
||
telegram_sending: Optional[bool] = None, sent_at: Optional[datetime] = None) -> bool:
|
||
"""更新既有週報的發送狀態欄位(telegram_sent / telegram_sending)。"""
|
||
if not insight_id:
|
||
return False
|
||
session = get_session()
|
||
try:
|
||
row = session.execute(
|
||
text("SELECT metadata_json FROM ai_insights WHERE id = :id"),
|
||
{"id": insight_id},
|
||
).fetchone()
|
||
if not row:
|
||
return False
|
||
|
||
meta = _normalize_weekly_strategy_metadata(row[0])
|
||
if telegram_sent is not None:
|
||
meta["telegram_sent"] = bool(telegram_sent)
|
||
if telegram_sending is not None:
|
||
meta["telegram_sending"] = bool(telegram_sending)
|
||
if sent_at is None and telegram_sent:
|
||
meta.pop("telegram_sent_at", None)
|
||
elif sent_at is not None:
|
||
meta["telegram_sent_at"] = sent_at.isoformat()
|
||
|
||
session.execute(
|
||
text("""
|
||
UPDATE ai_insights
|
||
SET metadata_json = :metadata
|
||
WHERE id = :id
|
||
"""),
|
||
{"id": insight_id, "metadata": json.dumps(meta, ensure_ascii=False)},
|
||
)
|
||
session.commit()
|
||
return True
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] 更新週報 telegram metadata 失敗 insight_id=%s: %s", insight_id, e)
|
||
session.rollback()
|
||
return False
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _acquire_weekly_strategy_send_lock(insight_id: int) -> bool:
|
||
"""嘗試取得週報 Telegram 發送鎖。
|
||
|
||
若該筆已標記發送中或已發送,回傳 False。
|
||
"""
|
||
if not insight_id:
|
||
return False
|
||
session = get_session()
|
||
try:
|
||
row = session.execute(
|
||
text("SELECT metadata_json FROM ai_insights WHERE id = :id FOR UPDATE"),
|
||
{"id": insight_id},
|
||
).fetchone()
|
||
if not row:
|
||
return False
|
||
|
||
meta = _normalize_weekly_strategy_metadata(row[0])
|
||
if bool(meta.get("telegram_sending")) or bool(meta.get("telegram_sent")):
|
||
return False
|
||
|
||
meta["telegram_sending"] = True
|
||
meta["telegram_sent"] = False
|
||
session.execute(
|
||
text("""
|
||
UPDATE ai_insights
|
||
SET metadata_json = :metadata
|
||
WHERE id = :id
|
||
"""),
|
||
{"id": insight_id, "metadata": json.dumps(meta, ensure_ascii=False)},
|
||
)
|
||
session.commit()
|
||
return True
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] 取得週報 telegram 發送鎖失敗 insight_id=%s: %s", insight_id, e)
|
||
session.rollback()
|
||
return False
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _set_weekly_strategy_telegram_sent(insight_id: int) -> None:
|
||
"""更新已儲存週報的 telegram_sent 狀態,避免再次重複發送。"""
|
||
_set_weekly_strategy_telegram_locked(
|
||
insight_id,
|
||
telegram_sending=False,
|
||
telegram_sent=True,
|
||
sent_at=datetime.now(),
|
||
)
|
||
|
||
|
||
def _consolidate_weekly_strategy_records(period: str) -> Dict[str, int]:
|
||
"""同一週保留最新一筆,將舊重複紀錄標示為 superseded(保留內容)。"""
|
||
session = get_session()
|
||
kept_id = None
|
||
superseded_count = 0
|
||
total_count = 0
|
||
try:
|
||
rows = session.execute(text("""
|
||
SELECT id, created_at
|
||
FROM ai_insights
|
||
WHERE insight_type = 'weekly_strategy'
|
||
AND created_by = 'openclaw'
|
||
AND period = :period
|
||
ORDER BY created_at DESC, id DESC
|
||
"""), {"period": period}).fetchall()
|
||
total_count = len(rows)
|
||
if total_count <= 1:
|
||
return {"period": period, "total_count": total_count, "kept_id": None, "superseded_count": 0}
|
||
|
||
kept_id = rows[0][0]
|
||
old_ids = [int(r[0]) for r in rows[1:]]
|
||
if old_ids:
|
||
res = session.execute(text("""
|
||
UPDATE ai_insights
|
||
SET status = 'superseded'
|
||
WHERE id IN :ids
|
||
AND status IN ('active', 'approved')
|
||
""").bindparams(bindparam("ids", expanding=True)), {"ids": old_ids})
|
||
superseded_count = int(getattr(res, "rowcount", 0) or 0)
|
||
session.commit()
|
||
return {
|
||
"period": period,
|
||
"total_count": total_count,
|
||
"kept_id": kept_id,
|
||
"superseded_count": superseded_count,
|
||
}
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] 週報 dedupe 失敗 period=%s: %s", period, e)
|
||
session.rollback()
|
||
return {"period": period, "total_count": total_count, "kept_id": kept_id, "superseded_count": superseded_count}
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _save_action_items(actions: List[str], source_insight_id: Optional[int]) -> None:
|
||
"""將 AI 建議的行動項目寫入 action_plans"""
|
||
if not actions:
|
||
return
|
||
session = get_session()
|
||
try:
|
||
for i, action in enumerate(actions[:10]):
|
||
desc = action[:500]
|
||
if active_openclaw_recommendation_exists(session, desc):
|
||
logger.info("[OpenClaw] action_plans skip duplicate recommendation")
|
||
continue
|
||
session.execute(text("""
|
||
INSERT INTO action_plans
|
||
(action_type, description, status, priority, metadata_json, created_at)
|
||
VALUES ('openclaw_recommendation', :desc, 'pending', :priority, :meta, NOW())
|
||
"""), {
|
||
"desc": desc,
|
||
"priority": i + 1,
|
||
"meta": openclaw_action_metadata(source_insight_id, desc),
|
||
})
|
||
session.commit()
|
||
logger.info("[OpenClaw] action_plans 寫入 %d 筆", len(actions[:10]))
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] action_plans 寫入失敗: %s", e)
|
||
session.rollback()
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Gemini 呼叫層
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def _gemini_nim_fallback_caller(caller: str) -> str:
|
||
"""Gemini 失敗後的 NIM caller 命名;Q&A 備援要回到標準 openclaw_qa_nim。"""
|
||
if caller == "openclaw_qa_gemini_fallback":
|
||
return "openclaw_qa_nim"
|
||
return f"{caller}_nim"
|
||
|
||
|
||
def _call_gemini(
|
||
system_prompt: str,
|
||
user_prompt: str,
|
||
temperature: float = 0.4,
|
||
caller: str = "openclaw_qa",
|
||
) -> Optional[str]:
|
||
"""呼叫 Gemini,回傳文字;失敗回傳 None。
|
||
|
||
Args:
|
||
caller: ai_calls.caller — 由外層 generate_*_report 傳入細分 caller
|
||
(openclaw_daily / openclaw_weekly / openclaw_monthly / openclaw_meta /
|
||
openclaw_qa_gemini_fallback)
|
||
"""
|
||
if not is_gemini_fallback_enabled("openclaw_strategy"):
|
||
logger.info("[OpenClaw] %s", gemini_disabled_message("openclaw_strategy"))
|
||
return None
|
||
gemini_api_key = get_gemini_api_key("openclaw_strategy")
|
||
if not gemini_api_key:
|
||
logger.warning("[OpenClaw] GEMINI_API_KEY 未設定")
|
||
return None
|
||
with log_ai_call(
|
||
caller=caller,
|
||
provider='gemini',
|
||
model=STRATEGY_MODEL,
|
||
meta={'temperature': temperature},
|
||
) as _ctx:
|
||
try:
|
||
import google.generativeai as genai
|
||
genai.configure(api_key=gemini_api_key)
|
||
model = genai.GenerativeModel(
|
||
model_name=STRATEGY_MODEL,
|
||
generation_config=genai.types.GenerationConfig(
|
||
temperature=temperature,
|
||
max_output_tokens=4096,
|
||
),
|
||
system_instruction=system_prompt,
|
||
)
|
||
response = model.generate_content(
|
||
user_prompt,
|
||
request_options={"timeout": 180},
|
||
)
|
||
# Gemini SDK:response.usage_metadata.{prompt_token_count, candidates_token_count}
|
||
try:
|
||
usage = getattr(response, 'usage_metadata', None)
|
||
if usage is not None:
|
||
_ctx.set_tokens(
|
||
input=getattr(usage, 'prompt_token_count', 0) or 0,
|
||
output=getattr(usage, 'candidates_token_count', 0) or 0,
|
||
)
|
||
except Exception:
|
||
logger.debug("[OpenClaw] Gemini usage metadata parse failed caller=%s", caller, exc_info=True)
|
||
return response.text or ""
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] Gemini 呼叫失敗: %s", e)
|
||
_ctx.set_error(f"{type(e).__name__}: {e}")
|
||
_ctx.fallback_to_caller(_gemini_nim_fallback_caller(caller))
|
||
return None
|
||
|
||
|
||
def _call_ollama_strategy(
|
||
system_prompt: str,
|
||
user_prompt: str,
|
||
temperature: float = 0.4,
|
||
caller: str = "openclaw_strategy",
|
||
*,
|
||
model_name: Optional[str] = None,
|
||
timeout: Optional[int] = None,
|
||
num_predict: Optional[int] = None,
|
||
fallback_caller: Optional[str] = None,
|
||
) -> Optional[str]:
|
||
"""OpenClaw 敘事報告主路徑:OllamaService 三主機級聯。"""
|
||
model = model_name or OPENCLAW_STRATEGY_OLLAMA_MODEL
|
||
timeout_s = timeout or OPENCLAW_STRATEGY_OLLAMA_TIMEOUT
|
||
predict = num_predict or OPENCLAW_STRATEGY_OLLAMA_NUM_PREDICT
|
||
fallback = fallback_caller or f"{caller}_gemini_fallback"
|
||
|
||
with log_ai_call(
|
||
caller=caller,
|
||
provider="gcp_ollama",
|
||
model=model,
|
||
meta={
|
||
"route": "ollama_first",
|
||
"temperature": temperature,
|
||
"num_predict": predict,
|
||
},
|
||
) as ctx:
|
||
try:
|
||
from services.ollama_service import OllamaService, get_host_label, get_provider_tag
|
||
|
||
ctx.set_prompt_hash(user_prompt)
|
||
resp = OllamaService(model=model).generate(
|
||
prompt=user_prompt,
|
||
model=model,
|
||
system_prompt=system_prompt,
|
||
temperature=temperature,
|
||
timeout=timeout_s,
|
||
keep_alive=OPENCLAW_STRATEGY_OLLAMA_KEEP_ALIVE,
|
||
options={"num_predict": predict},
|
||
allow_111_fallback=False,
|
||
)
|
||
ctx.set_provider(get_provider_tag(resp.host or ""))
|
||
ctx.set_model(resp.model or model)
|
||
ctx.set_tokens(input=resp.input_tokens, output=resp.output_tokens)
|
||
ctx.add_meta("host", resp.host)
|
||
ctx.add_meta("host_label", get_host_label(resp.host or ""))
|
||
if resp.model and resp.model != model:
|
||
ctx.add_meta("requested_model", model)
|
||
if not resp.success:
|
||
ctx.set_error(resp.error or "ollama generate failed")
|
||
ctx.fallback_to_caller(fallback)
|
||
logger.warning(
|
||
"[OpenClaw] Ollama strategy cascade failed caller=%s host=%s error=%s",
|
||
caller,
|
||
resp.host,
|
||
resp.error,
|
||
)
|
||
return None
|
||
text = (resp.content or "").strip()
|
||
if not text:
|
||
ctx.set_error("empty_response")
|
||
ctx.fallback_to_caller(fallback)
|
||
return None
|
||
return text
|
||
except Exception as exc:
|
||
ctx.set_error(f"{type(exc).__name__}: {str(exc)[:240]}")
|
||
ctx.fallback_to_caller(fallback)
|
||
logger.warning("[OpenClaw] Ollama strategy exception caller=%s: %s", caller, exc)
|
||
return None
|
||
|
||
|
||
def _call_openclaw_llm_ollama_first(
|
||
system_prompt: str,
|
||
user_prompt: str,
|
||
*,
|
||
temperature: float,
|
||
caller: str,
|
||
num_predict: Optional[int] = None,
|
||
) -> Optional[str]:
|
||
"""OpenClaw 報告統一 LLM 入口:Ollama 主、Gemini 備援、NIM 最後備援。"""
|
||
gemini_caller = f"{caller}_gemini_fallback"
|
||
text_out = _call_ollama_strategy(
|
||
system_prompt,
|
||
user_prompt,
|
||
temperature=temperature,
|
||
caller=caller,
|
||
num_predict=num_predict,
|
||
fallback_caller=gemini_caller,
|
||
)
|
||
if text_out:
|
||
return text_out
|
||
|
||
if get_gemini_api_key("openclaw_strategy"):
|
||
text_out = _call_gemini(
|
||
system_prompt,
|
||
user_prompt,
|
||
temperature=temperature,
|
||
caller=gemini_caller,
|
||
)
|
||
if text_out:
|
||
return text_out
|
||
|
||
if NVIDIA_API_KEY:
|
||
return _call_nvidia_nim(system_prompt, user_prompt, temperature=temperature, caller=caller)
|
||
return None
|
||
|
||
|
||
def _call_nvidia_nim(
|
||
system_prompt: str,
|
||
user_prompt: str,
|
||
temperature: float = 0.5,
|
||
caller: str = "openclaw_qa",
|
||
) -> Optional[str]:
|
||
"""Gemini 離線時備援 NVIDIA NIM,回傳文字;失敗回傳 None。
|
||
|
||
Args:
|
||
caller: 由外層細分 caller,最終會以 ``{caller}_nim`` 紀錄到 ai_calls。
|
||
"""
|
||
if not NVIDIA_API_KEY:
|
||
return None
|
||
nim_caller = f"{caller}_nim"
|
||
with log_ai_call(
|
||
caller=nim_caller,
|
||
provider='nim',
|
||
model=NVIDIA_FALLBACK_MODEL,
|
||
meta={'temperature': temperature},
|
||
) as _ctx:
|
||
try:
|
||
resp = requests.post(
|
||
NVIDIA_NIM_URL,
|
||
headers={
|
||
"Authorization": f"Bearer {NVIDIA_API_KEY}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={
|
||
"model": NVIDIA_FALLBACK_MODEL,
|
||
"messages": [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_prompt},
|
||
],
|
||
"temperature": temperature,
|
||
"max_tokens": 1024,
|
||
},
|
||
timeout=60,
|
||
)
|
||
resp.raise_for_status()
|
||
body = resp.json()
|
||
usage = body.get("usage", {}) or {}
|
||
_ctx.set_tokens(
|
||
input=usage.get("prompt_tokens", 0),
|
||
output=usage.get("completion_tokens", 0),
|
||
)
|
||
return body["choices"][0]["message"]["content"]
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] NVIDIA NIM 呼叫失敗: %s", e)
|
||
_ctx.set_error(f"{type(e).__name__}: {e}")
|
||
return None
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Telegram 推播
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def _send_strategy_telegram(title: str, report_type: str, period: str, content: str) -> bool:
|
||
"""發送週報到 Telegram。成功時回傳 True。"""
|
||
try:
|
||
from services.telegram_templates import report as tpl_report, _send_telegram_raw
|
||
|
||
# Telegram 訊息長度限制 4096,分段發送
|
||
header = tpl_report(title, report_type, period, "")
|
||
chunks = _split_message(content, max_len=3800 - len(header))
|
||
|
||
for i, chunk in enumerate(chunks):
|
||
msg = tpl_report(title, report_type, period, chunk) if i == 0 else chunk
|
||
_send_telegram_raw(msg)
|
||
return True
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] Telegram 推播失敗: %s", e)
|
||
return False
|
||
|
||
|
||
def _split_message(text: str, max_len: int = 3800) -> List[str]:
|
||
if len(text) <= max_len:
|
||
return [text]
|
||
chunks = []
|
||
while text:
|
||
chunks.append(text[:max_len])
|
||
text = text[max_len:]
|
||
return chunks
|
||
|
||
|
||
def _push_report_with_charts(
|
||
header: str,
|
||
body: str,
|
||
charts: List[tuple],
|
||
report_label: str,
|
||
) -> None:
|
||
"""daily/monthly 圖文報告共用推播:有圖走 send_report_with_charts,無圖走 raw。
|
||
|
||
Operation Ollama-First v5.0 Phase 4 抽出(純結構重構,行為與原 inline 樣板一致):
|
||
1. 三處原 inline 邏輯(_legacy_full_gemini_daily_report / generate_monthly_report /
|
||
_generate_daily_report_hermes_template)完全相同:header + "\\n\\n" + body,
|
||
有 charts 用圖文 API,無則 raw。
|
||
2. 失敗只 log warning(非阻塞),與原行為一致。
|
||
|
||
Args:
|
||
header: telegram_templates 已渲染的 header 字串
|
||
body: 報告主文(Ollama-first,Gemini 僅備援)
|
||
charts: list of (filename, png_bytes, caption) tuples,可為空 list
|
||
report_label: log 訊息辨識用,例如 "日報" / "月報" / "日報(模板模式)"
|
||
"""
|
||
try:
|
||
if charts:
|
||
from services.telegram_templates import (
|
||
send_report_with_charts,
|
||
_get_chat_ids,
|
||
)
|
||
full_msg = header + "\n\n" + body
|
||
send_report_with_charts(full_msg, charts, _get_chat_ids())
|
||
else:
|
||
from services.telegram_templates import _send_telegram_raw
|
||
_send_telegram_raw(header + "\n\n" + body)
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] %s Telegram 推播失敗: %s", report_label, e)
|
||
|
||
|
||
def _collect_mcp_intel(label: str) -> Dict[str, Any]:
|
||
"""weekly/monthly 共用 MCP 外部情報收集(純結構重構,無行為變更)。
|
||
|
||
Args:
|
||
label: log 訊息辨識用,例如 "週報" / "月報"
|
||
|
||
Returns:
|
||
dict: ``{"mcp_data": {...}, "holiday_ctx": str, "seasonal_ctx": str}``
|
||
失敗時三欄位皆回空字串/空 dict(非阻塞,與原 inline 行為一致)。
|
||
"""
|
||
try:
|
||
from services.mcp_collector_service import mcp_collector
|
||
return {
|
||
"mcp_data": mcp_collector.collect_all() or {},
|
||
"holiday_ctx": mcp_collector.get_holiday_context() or "",
|
||
"seasonal_ctx": mcp_collector.get_seasonal_context() or "",
|
||
}
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] %s MCP 收集失敗(非阻塞): %s", label, e)
|
||
return {"mcp_data": {}, "holiday_ctx": "", "seasonal_ctx": ""}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# 主要公開函式
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def generate_weekly_strategy_report(
|
||
context: Optional[Any] = None,
|
||
force_tg_alert: bool = False,
|
||
force_generate: bool = False,
|
||
) -> dict:
|
||
"""
|
||
OpenClaw 全景電商週報(每週一 06:00)
|
||
|
||
流程:
|
||
1. 讀取 DB:業績 / 競品 / 威脅 / 建議 / 品類
|
||
2. MCP 收集:外部市場趨勢 / 節日 / 競品動態
|
||
3. Gemini 2.5 Flash 深度分析
|
||
4. 持久化 → ai_insights + action_plans
|
||
5. Telegram 推播
|
||
"""
|
||
now = datetime.now()
|
||
period = f"{now.strftime('%Y年第%W週')} ({now.strftime('%m/%d')})"
|
||
period_key = now.strftime("%Y-%W")
|
||
logger.info("[OpenClaw] 週報任務啟動 period=%s", period)
|
||
|
||
existing = _load_weekly_strategy_payload(period_key)
|
||
if existing and not force_generate:
|
||
# 已有同週報告則沿用既有內容,不再重新呼叫 Gemini
|
||
sent_metadata = bool(existing.get("metadata", {}).get("telegram_sent"))
|
||
sending_metadata = bool(existing.get("metadata", {}).get("telegram_sending"))
|
||
if force_tg_alert:
|
||
if sending_metadata:
|
||
logger.info(
|
||
"[OpenClaw] 本週週報正在發送中,略過重複推播 period=%s insight_id=%s",
|
||
period_key,
|
||
existing["id"],
|
||
)
|
||
return {
|
||
"status": "skipped",
|
||
"report_type": "weekly_strategy",
|
||
"reason": "weekly_strategy_send_in_progress",
|
||
"insight_id": existing["id"],
|
||
"period": period,
|
||
}
|
||
|
||
if not sent_metadata and existing.get("content"):
|
||
if _acquire_weekly_strategy_send_lock(existing["id"]):
|
||
send_ok = _send_strategy_telegram(
|
||
title="OpenClaw 電商全景週報",
|
||
report_type="weekly_strategy",
|
||
period=period,
|
||
content=existing["content"],
|
||
)
|
||
if send_ok:
|
||
_set_weekly_strategy_telegram_locked(
|
||
existing["id"],
|
||
telegram_sent=True,
|
||
telegram_sending=False,
|
||
sent_at=datetime.now(),
|
||
)
|
||
return {
|
||
"status": "sent",
|
||
"report_type": "weekly_strategy",
|
||
"reason": "weekly_strategy_reused_from_cache",
|
||
"insight_id": existing["id"],
|
||
"period": period,
|
||
}
|
||
|
||
_set_weekly_strategy_telegram_locked(
|
||
existing["id"],
|
||
telegram_sent=False,
|
||
telegram_sending=False,
|
||
)
|
||
return {
|
||
"status": "error",
|
||
"report_type": "weekly_strategy",
|
||
"reason": "weekly_strategy_send_failed",
|
||
"insight_id": existing["id"],
|
||
"period": period,
|
||
}
|
||
|
||
logger.warning(
|
||
"[OpenClaw] 取得週報發送鎖失敗 period=%s insight_id=%s",
|
||
period_key,
|
||
existing["id"],
|
||
)
|
||
return {
|
||
"status": "skipped",
|
||
"report_type": "weekly_strategy",
|
||
"reason": "weekly_strategy_send_in_progress",
|
||
"insight_id": existing["id"],
|
||
"period": period,
|
||
}
|
||
|
||
logger.info(
|
||
"[OpenClaw] 本週週報已存在且已發送,跳過重複推播 period=%s insight_id=%s",
|
||
period_key,
|
||
existing["id"],
|
||
)
|
||
return {
|
||
"status": "skipped",
|
||
"report_type": "weekly_strategy",
|
||
"reason": "weekly_strategy_already_generated",
|
||
"insight_id": existing["id"],
|
||
"period": period,
|
||
}
|
||
|
||
logger.info(
|
||
"[OpenClaw] 本週週報已存在,跳過重複產生 period=%s insight_id=%s",
|
||
period_key,
|
||
existing["id"],
|
||
)
|
||
return {
|
||
"status": "skipped",
|
||
"report_type": "weekly_strategy",
|
||
"reason": "weekly_strategy_already_generated",
|
||
"insight_id": existing["id"],
|
||
"period": period,
|
||
}
|
||
|
||
# ── Step 1:DB 數據收集 ──────────────────────────────────────────────────
|
||
sales = _fetch_sales_summary(14)
|
||
if sales.get("stale"):
|
||
# critic post-review BLOCKER #1:改用統一 _send_data_stale_alert(24h dedupe)
|
||
# weekly return shape 維持不動(status="error"),避免動到 Phase 2 已建立的
|
||
# weekly dedupe/cache 機制下游語意。
|
||
_send_data_stale_alert(
|
||
report_type="weekly_strategy",
|
||
last_date=str(sales.get("last_date")),
|
||
period=period,
|
||
)
|
||
return {"status": "error", "reason": "data_stale"}
|
||
|
||
threats = _fetch_top_threats(10)
|
||
recommendations = _fetch_top_recommendations(10)
|
||
categories = _fetch_category_breakdown(7)
|
||
competitor_summary = _fetch_competitor_summary()
|
||
|
||
# ── Step 2:MCP 外部情報 ─────────────────────────────────────────────────
|
||
_mcp = _collect_mcp_intel("週報")
|
||
mcp_data = _mcp["mcp_data"]
|
||
holiday_ctx = _mcp["holiday_ctx"]
|
||
seasonal_ctx = _mcp["seasonal_ctx"]
|
||
|
||
# ── Step 3:組建 Gemini Prompt ───────────────────────────────────────────
|
||
system_prompt = """你是 OpenClaw,一位台灣頂尖電商戰略分析師,專精於 momo 購物平台。
|
||
你的任務是根據真實業績數據、競品情報、外部市場趨勢,產出一份具體可執行的週報。
|
||
|
||
語言規定:
|
||
- 所有輸出必須使用繁體中文(台灣用語)
|
||
- 數字格式:金額用 NT$ 標示,百分比保留1位小數
|
||
- 語氣:專業但不失親切,適合匯報給電商運營主管
|
||
|
||
分析原則:
|
||
- 每個洞察必須有數據支撐,禁止憑空推測
|
||
- 建議必須具體(時間、對象、行動、預期效益)
|
||
- 優先關注「可在 48 小時內執行」的行動項目"""
|
||
|
||
db_section = f"""
|
||
【DB 即時數據】
|
||
業績概況:
|
||
本週營收:NT${sales.get('current_7d_revenue', 0):,.0f}
|
||
前週營收:NT${sales.get('prev_7d_revenue', 0):,.0f}
|
||
週成長率:{sales.get('wow_pct', 0):+.1f}%
|
||
|
||
競品比對概況:
|
||
監控SKU總數:{competitor_summary.get('total_skus', 0)}
|
||
平均價差:{competitor_summary.get('avg_gap_pct', 0):+.1f}%
|
||
被競品削價數:{competitor_summary.get('undercut_count', 0)} 個
|
||
我方具優勢數:{competitor_summary.get('premium_count', 0)} 個
|
||
需單位價覆核:{competitor_summary.get('unit_comparable_count', 0)} 個
|
||
重算待人工覆核:{competitor_summary.get('rescore_accepted_count', 0)} 個
|
||
人工覆核採用率:{competitor_summary.get('manual_accept_rate', 0):.1f}%
|
||
|
||
PChome 覆核決策信封(HITL,不可自動寫正式價差):
|
||
{competitor_summary.get('review_decision_text', '(目前沒有待覆核決策信封)')}
|
||
|
||
TOP 威脅品項(近48h Hermes 偵測):
|
||
{_format_threats(threats)}
|
||
|
||
待處理定價建議:
|
||
{_format_recommendations(recommendations)}
|
||
|
||
品類業績分佈(本週):
|
||
{_format_categories(categories)}
|
||
"""
|
||
|
||
mcp_section = f"""
|
||
【MCP 外部情報】
|
||
市場趨勢:
|
||
{mcp_data.get('market_trends', '(未取得)')[:600]}
|
||
|
||
競品動態:
|
||
{mcp_data.get('competitor_intel', '(未取得)')[:500]}
|
||
|
||
消費者情緒:
|
||
{mcp_data.get('consumer_sentiment', '(未取得)')[:400]}
|
||
|
||
定價策略參考:
|
||
{mcp_data.get('pricing_strategy', '(未取得)')[:400]}
|
||
|
||
節日行事曆:
|
||
{holiday_ctx}
|
||
{mcp_data.get('holiday_calendar', '')[:300]}
|
||
|
||
季節性洞察:
|
||
{seasonal_ctx}
|
||
{mcp_data.get('seasonal_insights', '')[:300]}
|
||
"""
|
||
|
||
user_prompt = f"""請根據以下數據,產出本週電商全景戰略週報:
|
||
|
||
{db_section}
|
||
{mcp_section}
|
||
|
||
請按以下結構輸出(每節使用 HTML <b> 標題,內容精簡扼要):
|
||
|
||
<b>📊 本週業績總結</b>
|
||
(關鍵指標 + WoW 變化 + 異常警示)
|
||
|
||
<b>🏆 TOP 機會品項</b>
|
||
(具備提價或強推空間的品項,3-5個,含具體建議)
|
||
|
||
<b>⚠️ TOP 威脅品項</b>
|
||
(最需緊急處理的競品削價風險,3-5個,含建議行動)
|
||
|
||
<b>💰 本週定價策略建議</b>
|
||
(整體定價方向 + 品類重點調整 + 心理定價應用)
|
||
|
||
<b>📢 行銷活動洞察</b>
|
||
(節日/季節機會 + 推薦活動形式 + 投放時機)
|
||
|
||
<b>📦 品類熱度分析</b>
|
||
(成長品類 vs 衰退品類 + 庫存備貨建議)
|
||
|
||
<b>🔮 市場競爭洞察</b>
|
||
(競品最新動態 + 平台策略差異 + 我方應對)
|
||
|
||
<b>🎯 48小時優先行動清單</b>
|
||
(5-8條具體可執行任務,格式:[優先度] 行動說明 → 預期效益)
|
||
|
||
<b>📈 下週展望</b>
|
||
(風險提示 + 機會預告 + 需人工決策事項)
|
||
|
||
重要:語言必須是繁體中文,數據必須引用上方提供的實際數字。
|
||
"""
|
||
|
||
# ── Step 4:Ollama-first 生成 ─────────────────────────────────────────────
|
||
logger.info("[OpenClaw] 呼叫 Ollama 三主機級聯生成週報...")
|
||
report_content = _call_openclaw_llm_ollama_first(
|
||
system_prompt,
|
||
user_prompt,
|
||
temperature=0.35,
|
||
caller="openclaw_weekly",
|
||
num_predict=4096,
|
||
)
|
||
|
||
if not report_content:
|
||
logger.error("[OpenClaw] 週報生成失敗:Ollama/Gemini/NIM 均不可用")
|
||
return {"status": "error", "report_type": "weekly_strategy", "error": "Ollama/Gemini/NIM 呼叫失敗"}
|
||
|
||
# ── Step 5:解析行動清單 ─────────────────────────────────────────────────
|
||
action_items = _extract_action_items(report_content)
|
||
|
||
# ── Step 6:持久化 DB ────────────────────────────────────────────────────
|
||
metadata = {
|
||
"period": period,
|
||
"model": STRATEGY_MODEL,
|
||
"wow_pct": sales.get("wow_pct", 0),
|
||
"threat_count": len(threats),
|
||
"recommendation_count": len(recommendations),
|
||
"mcp_topics_collected": sum(1 for v in mcp_data.values() if v),
|
||
"action_count": len(action_items),
|
||
"generated_at": now.isoformat(),
|
||
"telegram_sent": False,
|
||
"telegram_sending": False,
|
||
}
|
||
insight_id = _save_to_ai_insights(
|
||
insight_type="weekly_strategy",
|
||
content=report_content,
|
||
confidence=0.88,
|
||
metadata=metadata,
|
||
period=period_key,
|
||
)
|
||
_save_action_items(action_items, insight_id)
|
||
_consolidate_weekly_strategy_records(period_key)
|
||
|
||
# ── Step 7:Telegram 推播 ────────────────────────────────────────────────
|
||
if force_tg_alert:
|
||
latest_payload = _load_weekly_strategy_payload(period_key)
|
||
send_target_id = latest_payload["id"] if latest_payload else insight_id
|
||
send_content = latest_payload["content"] if latest_payload else report_content
|
||
|
||
if _acquire_weekly_strategy_send_lock(send_target_id):
|
||
send_ok = _send_strategy_telegram(
|
||
title="OpenClaw 電商全景週報",
|
||
report_type="weekly_strategy",
|
||
period=period,
|
||
content=send_content,
|
||
)
|
||
if send_ok:
|
||
_set_weekly_strategy_telegram_locked(
|
||
send_target_id,
|
||
telegram_sent=True,
|
||
telegram_sending=False,
|
||
sent_at=datetime.now(),
|
||
)
|
||
else:
|
||
_set_weekly_strategy_telegram_locked(
|
||
send_target_id,
|
||
telegram_sent=False,
|
||
telegram_sending=False,
|
||
)
|
||
return {
|
||
"status": "error",
|
||
"report_type": "weekly_strategy",
|
||
"reason": "weekly_strategy_send_failed",
|
||
"insight_id": send_target_id,
|
||
"period": period,
|
||
}
|
||
else:
|
||
logger.info(
|
||
"[OpenClaw] 本週週報發送已被其他執行緒持有,跳過推播 period=%s latest_id=%s",
|
||
period_key,
|
||
send_target_id,
|
||
)
|
||
return {
|
||
"status": "skipped",
|
||
"report_type": "weekly_strategy",
|
||
"reason": "weekly_strategy_send_in_progress",
|
||
"insight_id": send_target_id,
|
||
"period": period,
|
||
}
|
||
|
||
logger.info("[OpenClaw] 週報完成 insight_id=%s actions=%d", insight_id, len(action_items))
|
||
return {
|
||
"status": "ok",
|
||
"report_type": "weekly_strategy",
|
||
"insight_id": insight_id,
|
||
"period": period,
|
||
"action_count": len(action_items),
|
||
"summary": report_content[:300],
|
||
}
|
||
|
||
|
||
def _daily_hermes_template_enabled() -> bool:
|
||
"""Operation Ollama-First v5.0 Phase 3 — Hermes 模板模式 feature flag.
|
||
|
||
每次呼叫即時讀取,允許 runtime toggle 灰度(不需重啟 scheduler)。
|
||
預設 true → Hermes/Ollama 模板模式;false 才回 _legacy_full_gemini_daily_report。
|
||
"""
|
||
# 統帥 2026-05-03 23:30 指令:「免費優先」
|
||
# 預設 ON:Hermes 算 KPI + 模板填充,Gemini 只寫 200 字洞察(戰前 28K → ~8K tokens, -71%)
|
||
# 緊急停用(回 Gemini 全文版):export OPENCLAW_DAILY_HERMES_TEMPLATE=false
|
||
return os.getenv('OPENCLAW_DAILY_HERMES_TEMPLATE', 'true').strip().lower() in ('true', '1', 'yes', 'on')
|
||
|
||
|
||
def generate_daily_report() -> dict:
|
||
"""
|
||
OpenClaw 電商日報(每日 09:00)— Operation Ollama-First v5.0 Phase 3 路由層。
|
||
|
||
依 ``OPENCLAW_DAILY_HERMES_TEMPLATE`` 分流:
|
||
- true(預設):``_generate_daily_report_hermes_template``,Hermes 算 KPI + 模板填充 +
|
||
Ollama-first 寫 200 字洞察
|
||
- false:``_legacy_full_gemini_daily_report`` 相容舊名,但仍走 Ollama-first 生成全文
|
||
|
||
回傳合約兩條路徑一致:``{status, report_type, insight_id, period, ...}``
|
||
cron 不需修改;ai_insights schema 不變(仍 type='daily_report')。
|
||
"""
|
||
if _daily_hermes_template_enabled():
|
||
try:
|
||
return _generate_daily_report_hermes_template()
|
||
except Exception as e:
|
||
# 模板模式異常 → 自動降級回 legacy,不讓 09:00 cron 整個掛掉
|
||
logger.error(
|
||
"[OpenClaw] 日報 Hermes 模板模式異常,自動降級回 legacy: %s", e,
|
||
exc_info=True,
|
||
)
|
||
return _legacy_full_gemini_daily_report()
|
||
return _legacy_full_gemini_daily_report()
|
||
|
||
|
||
def _legacy_full_gemini_daily_report() -> dict:
|
||
"""OpenClaw 日報舊版(Gemini 全文)— Phase 3 前的原始實作,保留為 baseline 對照組。
|
||
|
||
流程:
|
||
1. 讀取昨日業績快照 + TOP 競品威脅 + 定價建議
|
||
2. Gemini 快速日報分析(溫度 0.3,精簡版)
|
||
3. 生成圖表:近7日營收趨勢 + 競品價差柱圖
|
||
4. 持久化 ai_insights(type='daily_report')
|
||
5. Telegram 圖文推播
|
||
"""
|
||
now = datetime.now()
|
||
yesterday = now - timedelta(days=1)
|
||
period = yesterday.strftime("%Y年%m月%d日")
|
||
logger.info("[OpenClaw] 日報任務啟動 period=%s", period)
|
||
|
||
# ── Step 1:DB 數據收集 ──────────────────────────────────────────────────
|
||
sales = _fetch_sales_summary(7)
|
||
|
||
# critic post-review BLOCKER #1:daily 路徑必須與 weekly 對齊套 stale gate,
|
||
# 否則 daily_sales_snapshot 過期時會發出 NT$0 的偽日報(每天 09:00 復發)。
|
||
if sales.get("stale"):
|
||
last_date = str(sales.get("last_date"))
|
||
logger.warning(
|
||
"[OpenClaw] 日報任務跳過:daily_sales_snapshot 已停更 last_date=%s period=%s",
|
||
last_date, period,
|
||
)
|
||
_send_data_stale_alert(
|
||
report_type="daily_report",
|
||
last_date=last_date,
|
||
period=period,
|
||
)
|
||
return {
|
||
"status": "skipped",
|
||
"report_type": "daily_report",
|
||
"reason": "data_stale",
|
||
"last_date": last_date,
|
||
"period": period,
|
||
}
|
||
|
||
threats = _fetch_top_threats(5)
|
||
recommendations = _fetch_top_recommendations(5)
|
||
competitor_summary = _fetch_competitor_summary()
|
||
|
||
# 昨日單日業績
|
||
yesterday_sales = _fetch_yesterday_sales()
|
||
|
||
# ── Step 2:組建 Gemini Prompt ───────────────────────────────────────────
|
||
system_prompt = """你是 OpenClaw 日報分析師,負責每日電商業績快報。
|
||
語言:繁體中文(台灣用語)。風格:精簡、數字導向、可執行。
|
||
每個洞察必須有數字支撐,禁止空泛描述。"""
|
||
|
||
user_prompt = f"""請根據以下數據,產出今日電商日報({period}):
|
||
|
||
【昨日業績】
|
||
總業績:NT${yesterday_sales.get('revenue', 0):,.0f}
|
||
成交SKU數:{yesterday_sales.get('sku_count', 0)} 個
|
||
訂單數:{yesterday_sales.get('order_count', 0)} 筆
|
||
|
||
【近7日趨勢】
|
||
本週累計:NT${sales.get('current_7d_revenue', 0):,.0f}
|
||
前週同期:NT${sales.get('prev_7d_revenue', 0):,.0f}
|
||
WoW變化:{sales.get('wow_pct', 0):+.1f}%
|
||
|
||
【競品警示(近24h Hermes偵測)】
|
||
{_format_threats(threats)}
|
||
|
||
【待處理定價建議(TOP 5)】
|
||
{_format_recommendations(recommendations)}
|
||
|
||
【競品整體概況】
|
||
監控SKU:{competitor_summary.get('total_skus', 0)} 個
|
||
被削價風險:{competitor_summary.get('undercut_count', 0)} 個(價差超過10%)
|
||
平均價差:{competitor_summary.get('avg_gap_pct', 0):+.1f}%
|
||
單位價/身份覆核隊列:{competitor_summary.get('review_queue_count', 0)} 個
|
||
重算待人工覆核:{competitor_summary.get('rescore_accepted_count', 0)} 個
|
||
|
||
【PChome 覆核決策信封(HITL,不可自動寫正式價差)】
|
||
{competitor_summary.get('review_decision_text', '(目前沒有待覆核決策信封)')}
|
||
|
||
請按以下結構輸出(使用 HTML <b> 標題):
|
||
|
||
<b>📅 {period} 電商日報</b>
|
||
|
||
<b>📊 昨日業績快報</b>
|
||
(昨日關鍵數字 + 與近期均值比較 + 異常點說明)
|
||
|
||
<b>⚠️ 今日最高優先威脅</b>
|
||
(最緊急的 1-3 個競品削價威脅,含具體行動建議)
|
||
|
||
<b>💰 今日定價行動建議</b>
|
||
(1-3 條今日應執行的調價動作,格式:SKU → 建議行動 → 預期效果)
|
||
|
||
<b>🎯 今日 3 件事</b>
|
||
(最重要的 3 件可執行任務,24h內完成)
|
||
|
||
語言:繁體中文,全文200字以內,精準扼要。
|
||
"""
|
||
|
||
# ── Step 3:Ollama-first 生成 ─────────────────────────────────────────────
|
||
logger.info("[OpenClaw] 呼叫 Ollama 三主機級聯生成日報...")
|
||
report_content = _call_openclaw_llm_ollama_first(
|
||
system_prompt,
|
||
user_prompt,
|
||
temperature=0.3,
|
||
caller="openclaw_daily",
|
||
num_predict=2048,
|
||
)
|
||
|
||
if not report_content:
|
||
logger.error("[OpenClaw] 日報生成失敗:Ollama/Gemini/NIM 均不可用")
|
||
return {"status": "error", "report_type": "daily_report", "error": "Ollama/Gemini/NIM 呼叫失敗"}
|
||
|
||
# ── Step 4:生成圖表 ─────────────────────────────────────────────────────
|
||
charts = []
|
||
try:
|
||
from services.chart_generator_service import (
|
||
revenue_trend_chart,
|
||
price_gap_bar_chart,
|
||
)
|
||
rev_chart = revenue_trend_chart(7, "近7日")
|
||
if rev_chart:
|
||
charts.append(("revenue_7d.png", rev_chart, "📈 近7日營收趨勢"))
|
||
|
||
if threats:
|
||
gap_chart = price_gap_bar_chart(threats, "競品價差警示(TOP 5)")
|
||
if gap_chart:
|
||
charts.append(("price_gap.png", gap_chart, "⚠️ 競品價差分析"))
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] 日報圖表生成失敗(非阻塞): %s", e)
|
||
|
||
# ── Step 5:持久化 DB ────────────────────────────────────────────────────
|
||
metadata = {
|
||
"period": period,
|
||
"model": STRATEGY_MODEL,
|
||
"yesterday_revenue": yesterday_sales.get("revenue", 0),
|
||
"wow_pct": sales.get("wow_pct", 0),
|
||
"threat_count": len(threats),
|
||
"chart_count": len(charts),
|
||
"generated_at": now.isoformat(),
|
||
}
|
||
insight_id = _save_to_ai_insights(
|
||
insight_type="daily_report",
|
||
content=report_content,
|
||
confidence=0.85,
|
||
metadata=metadata,
|
||
period=yesterday.strftime("%Y-%m-%d"),
|
||
)
|
||
action_items = _extract_action_items_daily(report_content)
|
||
_save_action_items(action_items, insight_id)
|
||
|
||
# ── Step 6:Telegram 推播(圖文)────────────────────────────────────────
|
||
try:
|
||
from services.telegram_templates import daily_report_header
|
||
header = daily_report_header(
|
||
date_str=period,
|
||
revenue=yesterday_sales.get("revenue", 0),
|
||
wow=sales.get("wow_pct", 0),
|
||
threat_count=len(threats),
|
||
opportunity_count=competitor_summary.get("premium_count", 0),
|
||
)
|
||
_push_report_with_charts(header, report_content, charts, "日報")
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] 日報 header 組裝失敗: %s", e)
|
||
|
||
logger.info("[OpenClaw] 日報完成 insight_id=%s charts=%d", insight_id, len(charts))
|
||
return {
|
||
"status": "ok",
|
||
"report_type": "daily_report",
|
||
"insight_id": insight_id,
|
||
"period": period,
|
||
"chart_count": len(charts),
|
||
"action_count": len(action_items),
|
||
}
|
||
|
||
|
||
def generate_monthly_report() -> dict:
|
||
"""
|
||
OpenClaw 電商月報(每月1日 07:00)
|
||
|
||
流程:
|
||
1. 讀取上月完整業績 + 品類分佈 + 競品趨勢
|
||
2. MCP 收集月度外部情報
|
||
3. Gemini 深度月度分析(完整版)
|
||
4. 生成圖表:月度概覽 + 品類橫條 + 價格熱圖
|
||
5. 持久化 ai_insights(type='monthly_report')
|
||
6. Telegram 圖文推播
|
||
"""
|
||
now = datetime.now()
|
||
# 上個月
|
||
first_of_this_month = now.replace(day=1)
|
||
last_month_end = first_of_this_month - timedelta(days=1)
|
||
last_month_start = last_month_end.replace(day=1)
|
||
period = last_month_end.strftime("%Y年%m月")
|
||
logger.info("[OpenClaw] 月報任務啟動 period=%s", period)
|
||
|
||
# ── Step 1:DB 數據收集(上月完整數據)─────────────────────────────────
|
||
days_in_month = (first_of_this_month - last_month_start).days
|
||
sales = _fetch_monthly_sales_summary(last_month_start, last_month_end)
|
||
|
||
# critic post-review BLOCKER #1:monthly 路徑同樣加 stale gate。
|
||
# _fetch_monthly_sales_summary 沒有內建 stale 檢查(查固定日期區間),
|
||
# 若上月 daily_sales_snapshot 完全沒匯入則 revenue=0、sku_count=0,
|
||
# 會產出「NT$0 月報」誤導決策。以「revenue=0 且 sku=0」當資料缺失訊號。
|
||
if (sales.get("revenue", 0) or 0) == 0 and (sales.get("sku_count", 0) or 0) == 0:
|
||
last_date_str = last_month_end.strftime("%Y-%m-%d")
|
||
logger.warning(
|
||
"[OpenClaw] 月報任務跳過:上月 daily_sales_snapshot 無資料 period=%s",
|
||
period,
|
||
)
|
||
_send_data_stale_alert(
|
||
report_type="monthly_report",
|
||
last_date=last_date_str,
|
||
period=period,
|
||
)
|
||
return {
|
||
"status": "skipped",
|
||
"report_type": "monthly_report",
|
||
"reason": "data_stale",
|
||
"last_date": last_date_str,
|
||
"period": period,
|
||
}
|
||
|
||
categories = _fetch_category_breakdown(days_in_month)
|
||
threats = _fetch_top_threats(10)
|
||
competitor_summary = _fetch_competitor_summary()
|
||
price_trend_data = _fetch_price_trend_summary(days_in_month)
|
||
|
||
# ── Step 2:MCP 外部情報(月度版)───────────────────────────────────────
|
||
_mcp = _collect_mcp_intel("月報")
|
||
mcp_data = _mcp["mcp_data"]
|
||
holiday_ctx = _mcp["holiday_ctx"]
|
||
seasonal_ctx = _mcp["seasonal_ctx"]
|
||
|
||
# ── Step 3:組建 Gemini Prompt ───────────────────────────────────────────
|
||
system_prompt = """你是 OpenClaw 月報首席分析師,負責 momo 平台電商月度深度報告。
|
||
語言:繁體中文(台灣用語)。格式:HTML標題 + 條列式數據。
|
||
每個洞察必須有月度數字支撐,並與上月/去年同期比較。
|
||
重點:月度趨勢、品類策略、定價最佳化、下月行動計畫。"""
|
||
|
||
db_section = f"""
|
||
【{period} 業績總覽】
|
||
月營收:NT${sales.get('revenue', 0):,.0f}
|
||
MoM 變化:{sales.get('mom_pct', 0):+.1f}%
|
||
YoY 變化:{sales.get('yoy_pct', 0):+.1f}%
|
||
活躍SKU數:{sales.get('sku_count', 0)} 個
|
||
平均客單價:NT${sales.get('avg_order_value', 0):,.0f}
|
||
|
||
【品類業績分佈(TOP 10)】
|
||
{_format_categories(categories)}
|
||
|
||
【競品整體概況】
|
||
監控SKU:{competitor_summary.get('total_skus', 0)} 個
|
||
月均價差:{competitor_summary.get('avg_gap_pct', 0):+.1f}%
|
||
被削價風險SKU:{competitor_summary.get('undercut_count', 0)} 個
|
||
需單位價覆核SKU:{competitor_summary.get('unit_comparable_count', 0)} 個
|
||
重算待人工覆核SKU:{competitor_summary.get('rescore_accepted_count', 0)} 個
|
||
|
||
PChome 覆核決策信封(HITL,不可自動寫正式價差):
|
||
{competitor_summary.get('review_decision_text', '(目前沒有待覆核決策信封)')}
|
||
|
||
【價格變動概況】
|
||
本月調價次數:{price_trend_data.get('price_changes', 0)} 次
|
||
平均調幅:{price_trend_data.get('avg_change_pct', 0):+.1f}%
|
||
主動降價SKU:{price_trend_data.get('price_cuts', 0)} 個
|
||
主動提價SKU:{price_trend_data.get('price_raises', 0)} 個
|
||
"""
|
||
|
||
mcp_section = f"""
|
||
【MCP 外部情報(月度)】
|
||
市場趨勢:
|
||
{mcp_data.get('market_trends', '(未取得)')[:600]}
|
||
|
||
競品動態:
|
||
{mcp_data.get('competitor_intel', '(未取得)')[:500]}
|
||
|
||
下月節日行事曆:
|
||
{holiday_ctx}
|
||
{mcp_data.get('holiday_calendar', '')[:400]}
|
||
|
||
季節性洞察:
|
||
{seasonal_ctx}
|
||
{mcp_data.get('seasonal_insights', '')[:400]}
|
||
"""
|
||
|
||
user_prompt = f"""請根據以下數據,產出 {period} 電商月度策略報告:
|
||
|
||
{db_section}
|
||
{mcp_section}
|
||
|
||
請按以下結構輸出(使用 HTML <b> 標題,詳細分析):
|
||
|
||
<b>📅 {period} 電商月度報告</b>
|
||
|
||
<b>📊 月度業績總結</b>
|
||
(月營收 + MoM/YoY 變化 + 超出/低於預期分析 + 關鍵驅動因素)
|
||
|
||
<b>🏆 本月品類贏家 vs 輸家</b>
|
||
(成長最快3個品類 vs 衰退最嚴重3個品類,含原因分析)
|
||
|
||
<b>💰 本月定價策略回顧</b>
|
||
(調價效果評估 + 最佳定價案例 + 失誤案例 + 改進建議)
|
||
|
||
<b>⚔️ 競品月度分析</b>
|
||
(主要競爭對手動態 + 我方優劣勢 + 市場份額評估)
|
||
|
||
<b>📢 行銷效益評估</b>
|
||
(本月活動效果 + ROI 估算 + 最佳行銷時機分析)
|
||
|
||
<b>🔮 下月趨勢預測</b>
|
||
(季節性機會 + 節日活動規劃 + 風險預警 + 庫存建議)
|
||
|
||
<b>🎯 下月優先行動計畫</b>
|
||
(8-10條具體可執行任務,含時間節點和負責方向,
|
||
格式:[週次/日期] 行動說明 → 預期效益)
|
||
|
||
<b>📈 Q{((now.month-1)//3)+1} 策略展望</b>
|
||
(季度目標設定 + 關鍵里程碑 + 需人工決策事項)
|
||
|
||
語言:繁體中文,數據必須引用上方提供的實際數字。
|
||
"""
|
||
|
||
# ── Step 4:Ollama-first 生成 ─────────────────────────────────────────────
|
||
logger.info("[OpenClaw] 呼叫 Ollama 三主機級聯生成月報...")
|
||
report_content = _call_openclaw_llm_ollama_first(
|
||
system_prompt,
|
||
user_prompt,
|
||
temperature=0.35,
|
||
caller="openclaw_monthly",
|
||
num_predict=4096,
|
||
)
|
||
|
||
if not report_content:
|
||
logger.error("[OpenClaw] 月報生成失敗:Ollama/Gemini/NIM 均不可用")
|
||
return {"status": "error", "report_type": "monthly_report", "error": "Ollama/Gemini/NIM 呼叫失敗"}
|
||
|
||
# ── Step 5:生成圖表 ─────────────────────────────────────────────────────
|
||
charts = []
|
||
try:
|
||
from services.chart_generator_service import (
|
||
monthly_overview_chart,
|
||
category_revenue_chart,
|
||
price_history_heatmap,
|
||
)
|
||
overview_chart = monthly_overview_chart(6)
|
||
if overview_chart:
|
||
charts.append(("monthly_overview.png", overview_chart, f"📊 近6個月營收趨勢"))
|
||
|
||
cat_chart = category_revenue_chart(days_in_month, period)
|
||
if cat_chart:
|
||
charts.append(("category_revenue.png", cat_chart, "🏆 品類業績分佈"))
|
||
|
||
heatmap = price_history_heatmap(days_in_month)
|
||
if heatmap:
|
||
charts.append(("price_heatmap.png", heatmap, "🔥 品類價格熱圖"))
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] 月報圖表生成失敗(非阻塞): %s", e)
|
||
|
||
# ── Step 6:持久化 DB ────────────────────────────────────────────────────
|
||
action_items = _extract_action_items(report_content)
|
||
metadata = {
|
||
"period": period,
|
||
"model": STRATEGY_MODEL,
|
||
"monthly_revenue": sales.get("revenue", 0),
|
||
"mom_pct": sales.get("mom_pct", 0),
|
||
"yoy_pct": sales.get("yoy_pct", 0),
|
||
"category_count": len(categories),
|
||
"chart_count": len(charts),
|
||
"mcp_topics_collected": sum(1 for v in mcp_data.values() if v),
|
||
"action_count": len(action_items),
|
||
"generated_at": now.isoformat(),
|
||
}
|
||
insight_id = _save_to_ai_insights(
|
||
insight_type="monthly_report",
|
||
content=report_content,
|
||
confidence=0.90,
|
||
metadata=metadata,
|
||
period=last_month_end.strftime("%Y-%m"),
|
||
)
|
||
_save_action_items(action_items, insight_id)
|
||
|
||
# ── Step 7:Telegram 推播(圖文)────────────────────────────────────────
|
||
try:
|
||
from services.telegram_templates import monthly_report_header
|
||
top3 = [c.get("category", "N/A") for c in categories[:3]] or ["N/A"]
|
||
header = monthly_report_header(
|
||
month_str=period,
|
||
revenue=sales.get("revenue", 0),
|
||
mom=sales.get("mom_pct", 0),
|
||
yoy=sales.get("yoy_pct", 0),
|
||
top3_categories=top3,
|
||
)
|
||
_push_report_with_charts(header, report_content, charts, "月報")
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] 月報 header 組裝失敗: %s", e)
|
||
|
||
logger.info("[OpenClaw] 月報完成 insight_id=%s charts=%d actions=%d",
|
||
insight_id, len(charts), len(action_items))
|
||
return {
|
||
"status": "ok",
|
||
"report_type": "monthly_report",
|
||
"insight_id": insight_id,
|
||
"period": period,
|
||
"chart_count": len(charts),
|
||
"action_count": len(action_items),
|
||
}
|
||
|
||
|
||
def generate_meta_analysis_report() -> str:
|
||
"""
|
||
AI 系統效能自我審視(每日 12:00 run_openclaw_meta_analysis_task 呼叫;Phase 4 降頻 6h → 24h)
|
||
|
||
分析 ai_insights 近期累積資料,評估:
|
||
- 各 Agent 預測準確率
|
||
- 價格建議執行率
|
||
- 告警品質與誤報率
|
||
- 系統盲區與改進方向
|
||
|
||
結果持久化至 ai_insights(type='meta_analysis'),並推播 Telegram。
|
||
"""
|
||
now = datetime.now()
|
||
period = now.strftime("%Y-%m-%d %H:00")
|
||
logger.info("[OpenClaw] Meta-Analysis 任務啟動 %s", period)
|
||
|
||
# ── 讀取近期 ai_insights 摘要 ────────────────────────────────────────────
|
||
session = get_session()
|
||
try:
|
||
stats = session.execute(text("""
|
||
SELECT
|
||
insight_type,
|
||
created_by,
|
||
COUNT(*) AS total,
|
||
AVG(confidence) AS avg_conf,
|
||
SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) AS active_cnt,
|
||
SUM(CASE WHEN status='relearn' THEN 1 ELSE 0 END) AS relearn_cnt,
|
||
MAX(created_at) AS latest
|
||
FROM ai_insights
|
||
WHERE created_at >= NOW() - INTERVAL '24 hours'
|
||
GROUP BY insight_type, created_by
|
||
ORDER BY total DESC
|
||
""")).fetchall()
|
||
|
||
action_stats = session.execute(text("""
|
||
SELECT status, COUNT(*) AS cnt
|
||
FROM action_plans
|
||
WHERE created_at >= NOW() - INTERVAL '24 hours'
|
||
GROUP BY status
|
||
""")).fetchall()
|
||
|
||
reco_stats = session.execute(text("""
|
||
SELECT status, COUNT(*) AS cnt, AVG(confidence) AS avg_conf
|
||
FROM ai_price_recommendations
|
||
WHERE created_at >= NOW() - INTERVAL '24 hours'
|
||
GROUP BY status
|
||
""")).fetchall()
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] Meta 數據讀取失敗: %s", e)
|
||
stats, action_stats, reco_stats = [], [], []
|
||
finally:
|
||
session.close()
|
||
|
||
# ── 組建 Prompt ───────────────────────────────────────────────────────────
|
||
system_prompt = """你是 OpenClaw 自我審視模組,負責分析 AI 多智能體系統的近期表現。
|
||
請用繁體中文,以電商 AI 系統架構師的視角撰寫分析報告,語氣客觀、聚焦問題與改進。"""
|
||
|
||
stats_text = "\n".join([
|
||
f" {r[0]} ({r[1]}): 共{r[2]}筆, 平均信心{r[3]:.2f}, 活躍{r[4]}, 重學{r[5]}"
|
||
for r in stats
|
||
]) or " (無近期數據)"
|
||
|
||
action_text = "\n".join([
|
||
f" {r[0]}: {r[1]} 筆" for r in action_stats
|
||
]) or " (無近期數據)"
|
||
|
||
reco_text = "\n".join([
|
||
f" {r[0]}: {r[1]} 筆, 平均信心{r[2]:.2f}" for r in reco_stats
|
||
]) or " (無近期數據)"
|
||
|
||
user_prompt = f"""請分析以下 AI 系統近 24 小時運作數據,產出自我審視報告:
|
||
|
||
【ai_insights 產出統計】
|
||
{stats_text}
|
||
|
||
【action_plans 執行狀況】
|
||
{action_text}
|
||
|
||
【ai_price_recommendations 建議狀況】
|
||
{reco_text}
|
||
|
||
【分析時間】{period}
|
||
|
||
請按以下結構輸出:
|
||
|
||
<b>🤖 AI 系統效能自我審視報告</b>
|
||
時間:{period}
|
||
|
||
<b>📊 各 Agent 產出統計</b>
|
||
(逐一評估 Hermes/NemoTron/OpenClaw/ElephantAlpha 的輸出品質)
|
||
|
||
<b>⚠️ 偵測到的系統問題</b>
|
||
(誤報、漏報、重學事件分析)
|
||
|
||
<b>💡 盲區與改進建議</b>
|
||
(哪些場景 AI 表現不足?建議優化方向)
|
||
|
||
<b>✅ 本週期亮點</b>
|
||
(表現良好的分析案例)
|
||
|
||
<b>🔧 技術債與優化優先順序</b>
|
||
(1-3 項具體技術改進建議)
|
||
|
||
語言:繁體中文,200字以內,精簡扼要。
|
||
"""
|
||
|
||
# ── Ollama-first 生成 ───────────────────────────────────────────────────
|
||
report_content = _call_openclaw_llm_ollama_first(
|
||
system_prompt,
|
||
user_prompt,
|
||
temperature=0.3,
|
||
caller="openclaw_meta",
|
||
num_predict=3072,
|
||
)
|
||
if not report_content:
|
||
logger.error("[OpenClaw] Meta-Analysis 生成失敗:Ollama/Gemini/NIM 均不可用")
|
||
return "(Meta-Analysis 生成失敗)"
|
||
|
||
# ── 持久化 DB ─────────────────────────────────────────────────────────────
|
||
metadata = {
|
||
"period": period,
|
||
"model": STRATEGY_MODEL,
|
||
"insight_types_analyzed": len(stats),
|
||
"generated_at": now.isoformat(),
|
||
}
|
||
insight_id = _save_to_ai_insights(
|
||
insight_type="meta_analysis",
|
||
content=report_content,
|
||
confidence=0.85,
|
||
metadata=metadata,
|
||
period=now.strftime("%Y-%m-%d"),
|
||
)
|
||
|
||
# ── Telegram 推播 ────────────────────────────────────────────────────────
|
||
try:
|
||
from services.telegram_templates import _send_telegram_raw
|
||
_send_telegram_raw(report_content)
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] Meta-Analysis Telegram 推播失敗: %s", e)
|
||
|
||
logger.info("[OpenClaw] Meta-Analysis 完成 insight_id=%s", insight_id)
|
||
return report_content
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# 輔助格式化函式
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def _fetch_yesterday_sales() -> Dict[str, Any]:
|
||
"""昨日單日業績"""
|
||
session = get_session()
|
||
try:
|
||
row = session.execute(text("""
|
||
SELECT
|
||
SUM(COALESCE("總業績"::numeric, 0)) AS revenue,
|
||
COUNT(DISTINCT "商品ID") AS sku_count,
|
||
COUNT(*) AS order_count
|
||
FROM daily_sales_snapshot
|
||
WHERE snapshot_date::date = CURRENT_DATE - 1
|
||
""")).fetchone()
|
||
if row:
|
||
return {
|
||
"revenue": float(row[0] or 0),
|
||
"sku_count": int(row[1] or 0),
|
||
"order_count": int(row[2] or 0),
|
||
}
|
||
return {"revenue": 0, "sku_count": 0, "order_count": 0}
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] 昨日業績讀取失敗: %s", e)
|
||
raise
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _fetch_monthly_sales_summary(start_date: datetime, end_date: datetime) -> Dict[str, Any]:
|
||
"""上月業績彙總,含 MoM / YoY 比較"""
|
||
session = get_session()
|
||
try:
|
||
row = session.execute(text("""
|
||
SELECT
|
||
SUM(COALESCE("總業績"::numeric, 0)) AS revenue,
|
||
COUNT(DISTINCT "商品ID") AS sku_count,
|
||
COUNT(*) AS order_count,
|
||
AVG(COALESCE("總業績"::numeric, 0)) AS avg_order_value
|
||
FROM daily_sales_snapshot
|
||
WHERE snapshot_date::date BETWEEN :start AND :end
|
||
"""), {"start": start_date.date(), "end": end_date.date()}).fetchone()
|
||
|
||
revenue = float(row[0] or 0) if row else 0
|
||
sku_count = int(row[1] or 0) if row else 0
|
||
avg_order_value = float(row[3] or 0) if row else 0
|
||
|
||
# 上上月(MoM)
|
||
prev_start = (start_date - timedelta(days=1)).replace(day=1)
|
||
prev_end = start_date - timedelta(days=1)
|
||
prev_row = session.execute(text("""
|
||
SELECT SUM(COALESCE("總業績"::numeric, 0)) AS revenue
|
||
FROM daily_sales_snapshot
|
||
WHERE snapshot_date::date BETWEEN :start AND :end
|
||
"""), {"start": prev_start.date(), "end": prev_end.date()}).fetchone()
|
||
prev_revenue = float(prev_row[0] or 0) if prev_row else 0
|
||
mom_pct = ((revenue - prev_revenue) / prev_revenue * 100) if prev_revenue else 0
|
||
|
||
# 去年同月(YoY)
|
||
yoy_start = start_date.replace(year=start_date.year - 1)
|
||
yoy_end = end_date.replace(year=end_date.year - 1)
|
||
yoy_row = session.execute(text("""
|
||
SELECT SUM(COALESCE("總業績"::numeric, 0)) AS revenue
|
||
FROM daily_sales_snapshot
|
||
WHERE snapshot_date::date BETWEEN :start AND :end
|
||
"""), {"start": yoy_start.date(), "end": yoy_end.date()}).fetchone()
|
||
yoy_revenue = float(yoy_row[0] or 0) if yoy_row else 0
|
||
yoy_pct = ((revenue - yoy_revenue) / yoy_revenue * 100) if yoy_revenue else 0
|
||
|
||
return {
|
||
"revenue": revenue,
|
||
"sku_count": sku_count,
|
||
"order_count": int(row[2] or 0) if row else 0,
|
||
"avg_order_value": avg_order_value,
|
||
"mom_pct": round(mom_pct, 1),
|
||
"yoy_pct": round(yoy_pct, 1),
|
||
}
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] 月度業績讀取失敗: %s", e)
|
||
raise
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _fetch_price_trend_summary(days: int = 30) -> Dict[str, Any]:
|
||
"""近N天價格異動統計"""
|
||
session = get_session()
|
||
try:
|
||
row = session.execute(text("""
|
||
SELECT
|
||
COUNT(*) AS total_changes,
|
||
AVG(ABS(pr2.price - pr1.price) / pr1.price * 100) AS avg_change_pct,
|
||
SUM(CASE WHEN pr2.price < pr1.price THEN 1 ELSE 0 END) AS price_cuts,
|
||
SUM(CASE WHEN pr2.price > pr1.price THEN 1 ELSE 0 END) AS price_raises
|
||
FROM price_records pr2
|
||
JOIN price_records pr1 ON pr1.product_id = pr2.product_id
|
||
AND pr1.timestamp = (
|
||
SELECT MAX(timestamp) FROM price_records
|
||
WHERE product_id = pr2.product_id
|
||
AND timestamp < pr2.timestamp - INTERVAL '1 day'
|
||
)
|
||
WHERE pr2.timestamp >= NOW() - :days * INTERVAL '1 day'
|
||
AND ABS(pr2.price - pr1.price) / pr1.price > 0.005
|
||
"""), {"days": days}).fetchone()
|
||
if row and row[0]:
|
||
return {
|
||
"price_changes": int(row[0]),
|
||
"avg_change_pct": round(float(row[1] or 0), 1),
|
||
"price_cuts": int(row[2] or 0),
|
||
"price_raises": int(row[3] or 0),
|
||
}
|
||
return {"price_changes": 0, "avg_change_pct": 0, "price_cuts": 0, "price_raises": 0}
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] 價格趨勢統計讀取失敗: %s", e)
|
||
raise
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _extract_action_items_daily(report_text: str) -> List[str]:
|
||
"""從日報文字中解析「今日3件事」行動項目"""
|
||
lines = report_text.split("\n")
|
||
items = []
|
||
in_action_section = False
|
||
for line in lines:
|
||
if "今日" in line and ("3件" in line or "行動" in line or "優先" in line):
|
||
in_action_section = True
|
||
continue
|
||
if in_action_section:
|
||
stripped = line.strip()
|
||
if stripped.startswith(("•", "-", "1.", "2.", "3.", "①", "②", "③")):
|
||
items.append(stripped.lstrip("•-①②③").strip().lstrip("123.").strip())
|
||
elif stripped.startswith("<b>") and items:
|
||
break
|
||
return items[:5]
|
||
|
||
|
||
def _format_threats(threats: List[Dict]) -> str:
|
||
if not threats:
|
||
return " (無近期競價威脅)"
|
||
lines = []
|
||
for t in threats[:5]:
|
||
lines.append(
|
||
f" • SKU {t['sku']}:價差 {t.get('gap_pct', 0):+.1f}%,"
|
||
f"業績週變化 {t.get('sales_delta', 0):+.1f}%,"
|
||
f"信心 {t.get('confidence', 0):.2f}"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _format_recommendations(recs: List[Dict]) -> str:
|
||
if not recs:
|
||
return " (無待處理定價建議)"
|
||
lines = []
|
||
for r in recs[:5]:
|
||
lines.append(
|
||
f" • {r.get('name', r.get('sku', ''))[:30]}:{r.get('strategy', '')},"
|
||
f"信心 {r.get('confidence', 0):.2f}"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _format_categories(cats: List[Dict]) -> str:
|
||
if not cats:
|
||
return " (無品類數據)"
|
||
lines = []
|
||
for c in cats[:5]:
|
||
lines.append(
|
||
f" • {c.get('category', '未分類')}:"
|
||
f"NT${c.get('revenue', 0):,.0f},{c.get('sku_count', 0)} 個 SKU"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _extract_action_items(report_text: str) -> List[str]:
|
||
"""從報告文字中解析行動清單(48小時優先行動)"""
|
||
lines = report_text.split("\n")
|
||
items = []
|
||
in_action_section = False
|
||
for line in lines:
|
||
if "48小時" in line or "優先行動" in line:
|
||
in_action_section = True
|
||
continue
|
||
if in_action_section:
|
||
stripped = line.strip()
|
||
if stripped.startswith("•") or stripped.startswith("-") or stripped.startswith("["):
|
||
items.append(stripped.lstrip("•-").strip())
|
||
elif stripped.startswith("<b>") and items:
|
||
break
|
||
return items[:8]
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Operation Ollama-First v5.0 Phase 3 — Hermes 模板模式(A8 fullstack)
|
||
#
|
||
# 設計理念:日報 70% 是結構化 KPI(純 SQL 算),20% 是 Ollama-first 寫的洞察段落,
|
||
# 10% 是 Hermes 規則引擎產的行動清單。整體 token 從 ~28K → ~8K(-71%)。
|
||
#
|
||
# Token 預算(單次日報):
|
||
# - Legacy 全文:system + user prompt ~3K,輸出 ~1.5K,含 raw KPI 嵌 prompt → ~28K 總用量
|
||
# - Hermes 模板:KPI 已預算好,prompt 僅含「精簡 KPI 摘要 + 寫 200 字」 ~600 tokens prompt
|
||
# 輸出 ~400 tokens,總計 ~8K(含 ai_call_logger 的 meta + retry buffer)
|
||
#
|
||
# 規則:
|
||
# 1. 模板模式失敗 → 上層 generate_daily_report 自動降級回 legacy(不讓 cron 整個掛掉)
|
||
# 2. ai_insights schema 不變(仍 type='daily_report'),metadata_json 加 mode='hermes_template' 區分
|
||
# 3. _call_gemini caller 用 'openclaw_daily_insight',方便 ai_calls 統計區分新舊
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
# 上限常數 — 避免 magic number 散落
|
||
DAILY_TOP_SKU_LIMIT = 10
|
||
DAILY_PRICE_GAP_LIMIT = 5
|
||
DAILY_INSIGHT_MAX_TOKENS = 400
|
||
DAILY_INSIGHT_TIMEOUT_S = 60
|
||
|
||
|
||
def _compute_daily_kpi(target_date) -> Dict[str, Any]:
|
||
"""純 SQL + Hermes 規則計算當日所有結構化 KPI;不走 LLM。
|
||
|
||
Args:
|
||
target_date: ``datetime.date``,要分析的目標日(一般為昨日)。
|
||
|
||
Returns:
|
||
dict: 給 Jinja2 模板的完整渲染上下文,含:
|
||
revenue / orders / top_skus / price_gaps /
|
||
inventory_alerts / priority_actions
|
||
"""
|
||
from datetime import date as _date_cls
|
||
if hasattr(target_date, 'date') and not isinstance(target_date, _date_cls):
|
||
target_date = target_date.date()
|
||
if not isinstance(target_date, _date_cls):
|
||
raise TypeError(f"target_date 必須是 date,實得 {type(target_date)}")
|
||
|
||
return {
|
||
'revenue': _query_revenue_kpi(target_date),
|
||
'orders': _query_orders_kpi(target_date),
|
||
'top_skus': _query_top_skus(target_date, limit=DAILY_TOP_SKU_LIMIT),
|
||
'price_gaps': _query_competitor_price_alerts(target_date, limit=DAILY_PRICE_GAP_LIMIT),
|
||
'inventory_alerts': _query_inventory_anomalies(target_date),
|
||
'priority_actions': _generate_priority_actions(target_date),
|
||
}
|
||
|
||
|
||
def _query_revenue_kpi(target_date) -> Dict[str, Any]:
|
||
"""營收 KPI:今日 / 昨日 / 7日均 + 變化%。"""
|
||
session = get_session()
|
||
try:
|
||
row = session.execute(text("""
|
||
SELECT
|
||
SUM(CASE WHEN snapshot_date::date = :d THEN
|
||
COALESCE("總業績"::numeric, 0) ELSE 0 END) AS today,
|
||
SUM(CASE WHEN snapshot_date::date = :d - 1 THEN
|
||
COALESCE("總業績"::numeric, 0) ELSE 0 END) AS yesterday,
|
||
SUM(CASE WHEN snapshot_date::date BETWEEN :d - 7 AND :d - 1 THEN
|
||
COALESCE("總業績"::numeric, 0) ELSE 0 END) / 7.0 AS avg_7d
|
||
FROM daily_sales_snapshot
|
||
WHERE snapshot_date::date BETWEEN :d - 7 AND :d
|
||
"""), {"d": target_date}).fetchone()
|
||
|
||
today = float((row[0] or 0) if row else 0)
|
||
yesterday = float((row[1] or 0) if row else 0)
|
||
avg_7d = float((row[2] or 0) if row else 0)
|
||
dod_pct = ((today - yesterday) / yesterday * 100.0) if yesterday else 0.0
|
||
wow_pct = ((today - avg_7d) / avg_7d * 100.0) if avg_7d else 0.0
|
||
|
||
return {
|
||
"today": today,
|
||
"yesterday": yesterday,
|
||
"avg_7d": avg_7d,
|
||
"dod_pct": round(dod_pct, 1),
|
||
"wow_pct": round(wow_pct, 1),
|
||
}
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] revenue KPI 讀取失敗: %s", e)
|
||
return {"today": 0.0, "yesterday": 0.0, "avg_7d": 0.0, "dod_pct": 0.0, "wow_pct": 0.0}
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _query_orders_kpi(target_date) -> Dict[str, Any]:
|
||
"""訂單數 / SKU 數 / 平均客單價 KPI。
|
||
|
||
Note: daily_sales_snapshot 並無 order_id 欄位,「訂單數」以 row 數近似
|
||
(與 legacy `_fetch_yesterday_sales` 同義;保留行為一致性)。
|
||
"""
|
||
session = get_session()
|
||
try:
|
||
row = session.execute(text("""
|
||
SELECT
|
||
COUNT(*) AS today_rows,
|
||
COUNT(DISTINCT "商品ID") AS today_sku,
|
||
AVG(NULLIF(COALESCE("總業績"::numeric, 0), 0)) AS avg_value_today
|
||
FROM daily_sales_snapshot
|
||
WHERE snapshot_date::date = :d
|
||
"""), {"d": target_date}).fetchone()
|
||
|
||
prev = session.execute(text("""
|
||
SELECT COUNT(*) FROM daily_sales_snapshot
|
||
WHERE snapshot_date::date = :d - 1
|
||
"""), {"d": target_date}).fetchone()
|
||
|
||
today_rows = int((row[0] or 0) if row else 0)
|
||
today_sku = int((row[1] or 0) if row else 0)
|
||
avg_value = float((row[2] or 0) if row else 0)
|
||
yesterday_rows = int((prev[0] or 0) if prev else 0)
|
||
dod_pct = ((today_rows - yesterday_rows) / yesterday_rows * 100.0) if yesterday_rows else 0.0
|
||
|
||
return {
|
||
"today": today_rows,
|
||
"yesterday": yesterday_rows,
|
||
"sku_count": today_sku,
|
||
"avg_order_value": avg_value,
|
||
"dod_pct": round(dod_pct, 1),
|
||
}
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] orders KPI 讀取失敗: %s", e)
|
||
return {"today": 0, "yesterday": 0, "sku_count": 0, "avg_order_value": 0.0, "dod_pct": 0.0}
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _query_top_skus(target_date, limit: int = 10) -> List[Dict[str, Any]]:
|
||
"""當日 TOP N 熱銷 SKU。"""
|
||
session = get_session()
|
||
try:
|
||
rows = session.execute(text("""
|
||
SELECT
|
||
"商品ID" AS sku,
|
||
"商品名稱" AS name,
|
||
SUM(COALESCE("總業績"::numeric, 0)) AS revenue,
|
||
COUNT(*) AS qty
|
||
FROM daily_sales_snapshot
|
||
WHERE snapshot_date::date = :d
|
||
GROUP BY "商品ID", "商品名稱"
|
||
ORDER BY revenue DESC
|
||
LIMIT :lim
|
||
"""), {"d": target_date, "lim": limit}).fetchall()
|
||
return [
|
||
{
|
||
"sku": r[0] or "",
|
||
"name": (r[1] or "")[:60],
|
||
"revenue": float(r[2] or 0),
|
||
"qty": int(r[3] or 0),
|
||
}
|
||
for r in rows
|
||
]
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] top SKUs 讀取失敗: %s", e)
|
||
return []
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _query_competitor_price_alerts(target_date, limit: int = 5) -> List[Dict[str, Any]]:
|
||
"""TOP N 競品價差警示(沿用 _fetch_top_threats 並補完 SKU 名稱)。"""
|
||
threats = _fetch_top_threats(limit)
|
||
if not threats:
|
||
return []
|
||
|
||
sku_codes = [t.get("sku") for t in threats if t.get("sku")]
|
||
name_map: Dict[str, str] = {}
|
||
if sku_codes:
|
||
session = get_session()
|
||
try:
|
||
rows = session.execute(
|
||
text("""
|
||
SELECT i_code, name FROM products
|
||
WHERE i_code = ANY(:skus)
|
||
""").bindparams(bindparam("skus", expanding=True)),
|
||
{"skus": sku_codes},
|
||
).fetchall()
|
||
name_map = {r[0]: (r[1] or "")[:60] for r in rows}
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] 競品 SKU 名稱補完失敗(非阻塞): %s", e)
|
||
finally:
|
||
session.close()
|
||
|
||
alerts: List[Dict[str, Any]] = []
|
||
for t in threats:
|
||
sku = t.get("sku") or ""
|
||
gap = float(t.get("gap_pct") or 0)
|
||
alerts.append({
|
||
"sku": sku,
|
||
"sku_name": name_map.get(sku, sku),
|
||
"competitor": "PChome/蝦皮",
|
||
"gap_pct": round(gap, 1),
|
||
"momo_price": t.get("momo_price"),
|
||
"competitor_price": t.get("pchome_price"),
|
||
"confidence": float(t.get("confidence") or 0),
|
||
})
|
||
return alerts
|
||
|
||
|
||
def _query_inventory_anomalies(target_date) -> List[Dict[str, Any]]:
|
||
"""庫存 / 退單異常品項(Hermes/NemoTron 寫入的 ai_insights 子類)。"""
|
||
session = get_session()
|
||
try:
|
||
rows = session.execute(text("""
|
||
SELECT product_sku, content, insight_type, confidence, metadata_json
|
||
FROM ai_insights
|
||
WHERE insight_type IN ('inventory_alert', 'return_alert', 'stock_anomaly')
|
||
AND status = 'approved'
|
||
AND created_at >= NOW() - INTERVAL '24 hours'
|
||
ORDER BY confidence DESC
|
||
LIMIT 10
|
||
""")).fetchall()
|
||
out: List[Dict[str, Any]] = []
|
||
for r in rows:
|
||
meta = {}
|
||
try:
|
||
meta = json.loads(r[4]) if r[4] else {}
|
||
except Exception:
|
||
meta = {}
|
||
out.append({
|
||
"sku": r[0] or "",
|
||
"summary": (r[1] or "")[:120],
|
||
"type": r[2] or "",
|
||
"confidence": float(r[3] or 0),
|
||
"extra": meta,
|
||
})
|
||
return out
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] 庫存異常讀取失敗(非阻塞): %s", e)
|
||
return []
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
def _generate_priority_actions(target_date) -> List[str]:
|
||
"""規則引擎產生 48h 優先事項(純規則,無 LLM)。"""
|
||
actions: List[str] = []
|
||
|
||
threats = _fetch_top_threats(limit=DAILY_PRICE_GAP_LIMIT)
|
||
for t in threats[:3]:
|
||
gap = float(t.get("gap_pct") or 0)
|
||
if abs(gap) >= 10.0:
|
||
actions.append(
|
||
f"⚠️ SKU {t.get('sku') or ''} 競品價差 {gap:+.1f}%,建議 48h 內檢視跟降"
|
||
)
|
||
|
||
recs = _fetch_top_recommendations(limit=3)
|
||
for r in recs:
|
||
actions.append(
|
||
f"💰 {(r.get('name') or r.get('sku') or '')[:40]}:{r.get('strategy') or '待覆核'}"
|
||
f"(信心 {float(r.get('confidence') or 0):.2f})"
|
||
)
|
||
|
||
invs = _query_inventory_anomalies(target_date)
|
||
for inv in invs[:2]:
|
||
actions.append(f"📦 SKU {inv.get('sku')}:{(inv.get('summary') or '')[:60]}")
|
||
|
||
if not actions:
|
||
actions.append("✅ 今日無高優先警示,維持現有策略執行。")
|
||
|
||
return actions[:8]
|
||
|
||
|
||
def _compute_gemini_insight(kpi: Dict[str, Any], period: str) -> str:
|
||
"""給 Gemini 純結構化 KPI(已算好),請其寫 150-200 字繁中策略洞察。
|
||
|
||
Token 控制:精簡 prompt(~600 tokens)+ ``max_output_tokens=DAILY_INSIGHT_MAX_TOKENS``。
|
||
Gemini 失敗時回降級訊息(不拋例外,模板仍可渲染)。
|
||
"""
|
||
revenue = kpi.get("revenue") or {}
|
||
orders = kpi.get("orders") or {}
|
||
top_skus = kpi.get("top_skus") or []
|
||
price_gaps = kpi.get("price_gaps") or []
|
||
inv_alerts = kpi.get("inventory_alerts") or []
|
||
|
||
top_names = [s.get("name", "") for s in top_skus[:3]]
|
||
|
||
system_prompt = (
|
||
"你是 OpenClaw 日報洞察分析師。語言:繁體中文(台灣用語)。"
|
||
"嚴禁簡體字、嚴禁套話、嚴禁重複數字。聚焦『解讀』與『明日行動』。"
|
||
)
|
||
user_prompt = (
|
||
f"根據以下今日 ({period}) KPI(已計算),寫 150-200 字策略洞察:\n\n"
|
||
f"營收:NT${revenue.get('today', 0):,.0f}(DoD {revenue.get('dod_pct', 0):+.1f}%、"
|
||
f"vs 7日均 {revenue.get('wow_pct', 0):+.1f}%)\n"
|
||
f"訂單數:{orders.get('today', 0)}(DoD {orders.get('dod_pct', 0):+.1f}%)\n"
|
||
f"TOP 商品:{top_names}\n"
|
||
f"價差警示:{len(price_gaps)} 件\n"
|
||
f"庫存異常:{len(inv_alerts)} 件\n\n"
|
||
"請聚焦:(1) 今日最值得統帥注意的 1-2 件事;(2) 明日建議行動。"
|
||
"不要重複上面的數字本身,專注解讀。控制 200 字內。"
|
||
)
|
||
|
||
text_out = _call_openclaw_llm_ollama_first(
|
||
system_prompt,
|
||
user_prompt,
|
||
temperature=0.35,
|
||
caller="openclaw_daily_insight",
|
||
num_predict=512,
|
||
)
|
||
if text_out:
|
||
return text_out.strip()
|
||
|
||
direction = "上升" if revenue.get("dod_pct", 0) > 0 else "下滑" if revenue.get("dod_pct", 0) < 0 else "持平"
|
||
return (
|
||
f"今日營收 NT${revenue.get('today', 0):,.0f},與昨日相較{direction} "
|
||
f"{revenue.get('dod_pct', 0):+.1f}%;vs 7日均 {revenue.get('wow_pct', 0):+.1f}%。"
|
||
f"TOP 商品集中在 {', '.join([n for n in top_names if n]) if top_names else '—'}。"
|
||
f"今有 {len(price_gaps)} 件競品價差警示與 {len(inv_alerts)} 件庫存異常待處理。"
|
||
"(AI 洞察生成暫時不可用,已回退至規則性摘要。)"
|
||
)
|
||
|
||
|
||
def _render_daily_template_v2(context: Dict[str, Any]) -> str:
|
||
"""以 Jinja2 渲染 daily_report_v2.j2;缺欄位優雅降級為「—」。
|
||
|
||
將 Jinja Environment 集中於此,方便 unit test 直接呼叫無需 Flask app context。
|
||
"""
|
||
from jinja2 import Environment, FileSystemLoader, Undefined
|
||
|
||
template_dir = os.path.join(
|
||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
'templates',
|
||
)
|
||
|
||
class _SafeUndefined(Undefined):
|
||
"""缺欄位回 '—' 而非 raise UndefinedError,符合「優雅降級」需求。"""
|
||
def __str__(self) -> str:
|
||
return "—"
|
||
def __html__(self) -> str:
|
||
return "—"
|
||
def __getattr__(self, name: str):
|
||
return _SafeUndefined()
|
||
|
||
env = Environment(
|
||
loader=FileSystemLoader(template_dir),
|
||
undefined=_SafeUndefined,
|
||
autoescape=False, # 日報純文字 + emoji,無 HTML 注入面
|
||
trim_blocks=True,
|
||
lstrip_blocks=True,
|
||
)
|
||
|
||
def _fmt_currency(v: Any) -> str:
|
||
try:
|
||
# 容錯:Undefined / None / 空字串 → 降級為 —
|
||
if v is None or isinstance(v, Undefined) or v == "":
|
||
return "—"
|
||
return f"NT${float(v):,.0f}"
|
||
except (TypeError, ValueError):
|
||
return "—"
|
||
|
||
def _fmt_pct(v: Any) -> str:
|
||
try:
|
||
if v is None or isinstance(v, Undefined) or v == "":
|
||
return "—"
|
||
return f"{float(v):+.1f}%"
|
||
except (TypeError, ValueError):
|
||
return "—"
|
||
|
||
env.filters['format_currency'] = _fmt_currency
|
||
env.filters['format_pct'] = _fmt_pct
|
||
|
||
template = env.get_template('daily_report_v2.j2')
|
||
return template.render(**context)
|
||
|
||
|
||
def _generate_daily_report_hermes_template() -> dict:
|
||
"""Hermes 模板模式日報 — 結構化 KPI + 200 字 Gemini 洞察 + 規則行動清單。
|
||
|
||
流程:
|
||
1. 取昨日業績 stale gate(與 legacy 同邏輯)
|
||
2. _compute_daily_kpi 純 SQL 算齊所有結構化 KPI
|
||
3. _compute_gemini_insight 取 200 字洞察(精簡 prompt)
|
||
4. Jinja2 渲染 daily_report_v2.j2
|
||
5. 持久化 ai_insights(type='daily_report',metadata.mode='hermes_template')
|
||
6. Telegram 推播(沿用 legacy 圖表生成)
|
||
|
||
回傳合約與 legacy 完全一致。
|
||
"""
|
||
now = datetime.now()
|
||
yesterday = now - timedelta(days=1)
|
||
period = yesterday.strftime("%Y年%m月%d日")
|
||
target_date = yesterday.date()
|
||
weekday_map = ['週一', '週二', '週三', '週四', '週五', '週六', '週日']
|
||
weekday = weekday_map[target_date.weekday()]
|
||
logger.info("[OpenClaw] 日報任務啟動(Hermes 模板模式)period=%s", period)
|
||
|
||
# ── Step 1:stale gate(與 legacy 對齊)─────────────────────────────────
|
||
sales = _fetch_sales_summary(7)
|
||
if sales.get("stale"):
|
||
last_date = str(sales.get("last_date"))
|
||
logger.warning(
|
||
"[OpenClaw] 日報任務跳過(模板模式):daily_sales_snapshot 已停更 last_date=%s period=%s",
|
||
last_date, period,
|
||
)
|
||
_send_data_stale_alert(report_type="daily_report", last_date=last_date, period=period)
|
||
return {
|
||
"status": "skipped",
|
||
"report_type": "daily_report",
|
||
"reason": "data_stale",
|
||
"last_date": last_date,
|
||
"period": period,
|
||
}
|
||
|
||
# ── Step 2:純 SQL 算 KPI ─────────────────────────────────────────────
|
||
kpi = _compute_daily_kpi(target_date)
|
||
|
||
# ── Step 3:Ollama-first 寫 200 字洞察(caller 細分)───────────────────
|
||
insight_text = _compute_gemini_insight(kpi, period)
|
||
|
||
# ── Step 4:Jinja2 渲染 ───────────────────────────────────────────────
|
||
report_content = _render_daily_template_v2({
|
||
"today": period,
|
||
"weekday": weekday,
|
||
"revenue": kpi["revenue"],
|
||
"orders": kpi["orders"],
|
||
"top_skus": kpi["top_skus"],
|
||
"price_gaps": kpi["price_gaps"],
|
||
"inventory_alerts": kpi["inventory_alerts"],
|
||
"priority_actions": kpi["priority_actions"],
|
||
"gemini_insight": insight_text,
|
||
})
|
||
|
||
# ── Step 5:圖表(沿用 legacy,非阻塞)─────────────────────────────────
|
||
charts: List[tuple] = []
|
||
try:
|
||
from services.chart_generator_service import (
|
||
revenue_trend_chart,
|
||
price_gap_bar_chart,
|
||
)
|
||
rev_chart = revenue_trend_chart(7, "近7日")
|
||
if rev_chart:
|
||
charts.append(("revenue_7d.png", rev_chart, "📈 近7日營收趨勢"))
|
||
if kpi["price_gaps"]:
|
||
gap_chart = price_gap_bar_chart(
|
||
[{"sku": a["sku"], "gap_pct": a["gap_pct"]} for a in kpi["price_gaps"]],
|
||
"競品價差警示(TOP 5)",
|
||
)
|
||
if gap_chart:
|
||
charts.append(("price_gap.png", gap_chart, "⚠️ 競品價差分析"))
|
||
except Exception as e:
|
||
logger.warning("[OpenClaw] 日報圖表生成失敗(非阻塞): %s", e)
|
||
|
||
# ── Step 6:持久化 ──────────────────────────────────────────────────────
|
||
metadata = {
|
||
"period": period,
|
||
"model": STRATEGY_MODEL,
|
||
"mode": "hermes_template", # ← 區分新舊模式關鍵欄位
|
||
"template_version": "daily_report_v2",
|
||
"today_revenue": kpi["revenue"].get("today", 0),
|
||
"dod_pct": kpi["revenue"].get("dod_pct", 0),
|
||
"wow_pct": kpi["revenue"].get("wow_pct", 0),
|
||
"top_sku_count": len(kpi["top_skus"]),
|
||
"price_gap_count": len(kpi["price_gaps"]),
|
||
"inventory_alert_count": len(kpi["inventory_alerts"]),
|
||
"priority_action_count": len(kpi["priority_actions"]),
|
||
"chart_count": len(charts),
|
||
"generated_at": now.isoformat(),
|
||
}
|
||
insight_id = _save_to_ai_insights(
|
||
insight_type="daily_report",
|
||
content=report_content,
|
||
confidence=0.85,
|
||
metadata=metadata,
|
||
period=target_date.strftime("%Y-%m-%d"),
|
||
)
|
||
|
||
action_items = list(kpi["priority_actions"])
|
||
_save_action_items(action_items, insight_id)
|
||
|
||
# ── Step 7:Telegram 推播 ──────────────────────────────────────────────
|
||
try:
|
||
from services.telegram_templates import daily_report_header
|
||
header = daily_report_header(
|
||
date_str=period,
|
||
revenue=kpi["revenue"].get("today", 0),
|
||
wow=kpi["revenue"].get("wow_pct", 0),
|
||
threat_count=len(kpi["price_gaps"]),
|
||
opportunity_count=0,
|
||
)
|
||
_push_report_with_charts(header, report_content, charts, "日報(模板模式)")
|
||
except Exception as e:
|
||
logger.error("[OpenClaw] 日報 header 組裝失敗(模板模式): %s", e)
|
||
|
||
logger.info(
|
||
"[OpenClaw] 日報完成(Hermes 模板模式)insight_id=%s charts=%d actions=%d",
|
||
insight_id, len(charts), len(action_items),
|
||
)
|
||
return {
|
||
"status": "ok",
|
||
"report_type": "daily_report",
|
||
"insight_id": insight_id,
|
||
"period": period,
|
||
"chart_count": len(charts),
|
||
"action_count": len(action_items),
|
||
"mode": "hermes_template",
|
||
}
|