Files
ewoooc/routes/openclaw_bot_routes.py
2026-06-25 18:05:48 +08:00

9423 lines
444 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
OpenClaw Telegram 群組智能助理 v5
─────────────────────────────────────────
核心功能:
• 群組自然對話Ollama-first 三主機級聯Gemini 僅備援
• Inline Keyboard 15 個功能入口
• 全商品查詢帶出商品ID
• AI 分析強制比對內部DB + 外部MCP情報
v5 新增2026-04-16
• 每日早報 08:30 / 晚報 21:00 / 週一週報
• 異常偵測4次/日,偏差>30%即告警)
• 目標達成率(日/月目標設定)
• 分類業績、同期比較(上週/上月)
• 商品策略矩陣(加碼/機會/收割/觀察/持穩)
• 商品健康分析(異常+策略分佈)
• 趨勢圖 + 熱銷商品橫條圖matplotlib
• 完整報表 PDF 下載fpdf2 → reportlab → CSV
"""
from __future__ import annotations
import os
import json
import re
import threading
import hashlib # Operation Ollama-First v5.0 P1: H6 PII fix — chat_id 進 meta 改 hash[:8]
from contextvars import ContextVar
from contextlib import contextmanager
import requests
from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, jsonify
from sqlalchemy import text, or_
from database.manager import DatabaseManager
from services.logger_manager import SystemLogger
from services.telegram_update_guard import is_duplicate_update as is_global_duplicate_update
from services.mcp_context_service import (
build_mcp_context, MCPRouter, query_mcp,
get_tw_media_news, get_ecommerce_news, get_taiwan_trends,
get_dcard_trends, get_youtube_trending, get_taiwan_weather,
get_twbank_exchange_rates, get_upcoming_events,
)
from services.openclaw_bot.telegram_api import (
_tg,
answer_callback,
edit_message_text,
send_document,
send_message,
send_photo,
send_typing,
)
from services.ai_call_logger import log_ai_call # Operation Ollama-First v5.0 P1
from services.gemini_guard import get_gemini_api_key, is_gemini_fallback_enabled
from services.openclaw_bot.menu_keyboards import (
_BACK,
_SUBMENUS,
_chunk_rows,
_submenu_goals,
_submenu_market,
_submenu_sales,
_submenu_trend,
_row,
configure_menu_keyboards,
quick_menu_keyboard,
main_menu_keyboard,
)
try:
from services.openclaw_learning_service import (
build_rag_context, store_conversation, store_insight,
update_feedback, get_learning_stats,
)
_LEARNING_ENABLED = True
except Exception as _le:
_LEARNING_ENABLED = False
sys_log = __import__('logging').getLogger('openclaw')
sys_log.warning(f"[OCLearn] learning service unavailable: {_le}")
try:
from services.pchome_crawler import (
compare_product as pchome_compare,
batch_compare_top as pchome_batch,
save_matches as pchome_save,
fmt_compare_msg as pchome_fmt_compare,
fmt_daily_report as pchome_fmt_report,
search_pchome as pchome_search,
ensure_tables as pchome_ensure_tables,
)
_PCHOME_AVAILABLE = True
except ImportError:
_PCHOME_AVAILABLE = False
# V-New: 引入 Ollama 探測機制
try:
from services.ollama_service import OllamaService, get_host_label, get_provider_tag
_OLLAMA_AVAILABLE = True
except ImportError:
_OLLAMA_AVAILABLE = False
# AI 引擎Ollama 三主機級聯 → NIM → Gemini emergency fallback。
# Gemini fallback default is hard-disabled by GEMINI_API_HARD_DISABLED=true.
GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models'
GEMINI_MODEL = 'gemini-2.0-flash'
IMAGE_VISION_OLLAMA_MODEL = os.getenv(
'OPENCLAW_IMAGE_VISION_MODEL',
os.getenv('PPT_VISION_MODEL', 'minicpm-v:latest'),
)
IMAGE_VISION_GEMINI_MODEL = os.getenv('OPENCLAW_IMAGE_GEMINI_MODEL', 'gemini-1.5-flash')
PPT_CACHE_TTL_HOURS = max(1, int(os.getenv('OPENCLAW_PPT_CACHE_TTL_HOURS', '24')))
TAIPEI_TZ = timezone(timedelta(hours=8))
sys_log = SystemLogger("OpenClawBot").get_logger()
openclaw_bot_bp = Blueprint('openclaw_bot', __name__)
def _gemini_fallback_api_key(context: str) -> str:
return get_gemini_api_key(context)
def _gemini_fallback_allowed(context: str) -> bool:
return is_gemini_fallback_enabled(context) and bool(_gemini_fallback_api_key(context))
# ── Telegram retry 去重 (update_id 快取,最多保留 500 筆) ─────
BOT_TOKEN = os.getenv('OPENCLAW_BOT_TOKEN') or os.getenv('TELEGRAM_BOT_TOKEN', '')
BOT_API_URL = f"https://api.telegram.org/bot{BOT_TOKEN}"
ALLOWED_GROUP = int(os.getenv('OPENCLAW_GROUP_ID', '-1003940688311'))
MOMO_BASE_URL = os.getenv('MOMO_BASE_URL', 'https://mo.wooo.work')
NVIDIA_API_KEY = os.getenv('NVIDIA_API_KEY', '')
NVIDIA_BASE_URL = 'https://integrate.api.nvidia.com/v1'
CHAT_MODEL = 'deepseek-ai/deepseek-v3.2'
BOT_USERNAME = os.getenv('OPENCLAW_BOT_USERNAME') or os.getenv('TELEGRAM_BOT_USERNAME', '@OpenClawAwoooI_Bot')
# ── 存取控制白名單 ─────────────────────────────────────────────
# OPENCLAW_ALLOWED_USERS逗號分隔的 Telegram user_id整數
# 空字串 + OPENCLAW_ALLOW_PRIVATE_WITHOUT_WHITELIST=1 => 允許任何私訊(舊行為)
# OPENCLAW_ALLOW_PRIVATE_WITHOUT_WHITELIST 未開啟時,空白名單會拒絕私訊
# 例:'123456789,987654321'
_allowed_users_raw = os.getenv('OPENCLAW_ALLOWED_USERS', '')
ALLOWED_USERS: set = (
{int(uid.strip()) for uid in _allowed_users_raw.split(',') if uid.strip().isdigit()}
if _allowed_users_raw.strip() else set()
)
_ALLOW_PRIVATE_WITHOUT_WHITELIST = (
os.getenv('OPENCLAW_ALLOW_PRIVATE_WITHOUT_WHITELIST', '1').strip().lower()
in {'1', 'true', 'yes', 'on'}
)
# ADR-019 Phase 3: Feature-flagged agent dispatch
# 預設 OFF啟用步驟
# export OPENCLAW_AGENT_DISPATCH=1
# export OPENCLAW_AGENT_DISPATCH_CMDS=sales,top,vendor (逗號分隔白名單)
# 啟用後白名單內 cmd 改走 OpenClaw NL agentagent 自決查資料/詢問用戶/答覆
_OPENCLAW_AGENT_DISPATCH_ENABLED = (
os.getenv('OPENCLAW_AGENT_DISPATCH', '0').strip().lower()
in {'1', 'true', 'yes', 'on'}
)
_AGENT_DISPATCH_CMDS = {
c.strip() for c in os.getenv('OPENCLAW_AGENT_DISPATCH_CMDS', '').split(',')
if c.strip()
}
# ── fail-closed 統一授權檢查 ───────────────────────────────────
# 規則(任一滿足即通過,否則一律拒絕):
# 1. group/supergroup 且 chat_id == ALLOWED_GROUP
# 2. private 且 user_id ∈ ALLOWED_USERSenv 未設 → 空 set → 全拒)
# channel / 未知 chat_type / 缺欄位 → 拒絕
# 修補 C3callback handler 原本只擋 group/supergroup 不匹配private 完全放行;
# message handler `if ALLOWED_USERS and ...` 空 set 時整段失效。
def _is_authorized(chat_type: str, chat_id, user_id) -> bool:
try:
cid = int(chat_id) if chat_id is not None else None
uid = int(user_id) if user_id is not None else None
except (TypeError, ValueError):
return False
if chat_type in ('group', 'supergroup'):
return cid == ALLOWED_GROUP
if chat_type == 'private':
if uid is None:
return False
if ALLOWED_USERS:
return uid in ALLOWED_USERS
return _ALLOW_PRIVATE_WITHOUT_WHITELIST
return False
# ── 速率限制(每用戶每分鐘最多 30 次 AI 呼叫)──────────────────
import time as _time_mod
_rate_tracker: dict = {} # {user_id: [timestamp, ...]}
_RATE_LIMIT_PER_MIN = 30 # 每分鐘上限
_RATE_WINDOW_SEC = 60
# V-Fixcallback 期間覆寫 send_message 會跨 request 競態,全域鎖可避免重入干擾。
_CALLBACK_SEND_LOCK = threading.Lock()
_CMD_FROM_CALLBACK_CTX = ContextVar('openclaw_cmd_from_callback', default=False)
# critic Medium-3當前請求的 user_id ContextVarwebhook 入口設定handle_cmd 讀取)
# 用 ContextVar 而非改 handle_cmd 簽名 — 避免動到 30+ 處呼叫端。
_CURRENT_USER_ID_CTX = ContextVar('openclaw_current_user_id', default=None)
# 管理員白名單:可執行破壞性指令(/cache flush all / cleanup confirm 等)
# OPENCLAW_ADMIN_USER_IDS 未設 → 退回 ALLOWED_USERS保持向後兼容
# 建議部署時明確設定,避免群組內所有人都能動快取
_admin_users_raw = os.getenv('OPENCLAW_ADMIN_USER_IDS', '')
ADMIN_USER_IDS: set = (
{int(uid.strip()) for uid in _admin_users_raw.split(',') if uid.strip().isdigit()}
if _admin_users_raw.strip() else set()
)
def _is_admin(user_id) -> bool:
"""判定 user_id 是否為管理員。
優先用 ADMIN_USER_IDS明確設定未設時退回 ALLOWED_USERS兼容
回傳 True 才允許執行破壞性指令。
"""
try:
uid = int(user_id) if user_id is not None else None
except (TypeError, ValueError):
return False
if uid is None:
return False
if ADMIN_USER_IDS:
return uid in ADMIN_USER_IDS
# 兼容ADMIN 未設時,退回 ALLOWED_USERS私訊白名單即視為 admin
return bool(ALLOWED_USERS) and uid in ALLOWED_USERS
def _check_rate_limit(user_id: int) -> bool:
"""回傳 True = 允許False = 超過速率限制"""
now = _time_mod.time()
window = _rate_tracker.setdefault(user_id, [])
# 清掉 60 秒以前的紀錄
_rate_tracker[user_id] = [t for t in window if now - t < _RATE_WINDOW_SEC]
if len(_rate_tracker[user_id]) >= _RATE_LIMIT_PER_MIN:
return False
_rate_tracker[user_id].append(now)
return True
def _is_duplicate_update(update_id) -> bool:
"""Telegram webhook 可能重送同一 update_id優先以 DB 導向去重,再回退本機快取。"""
return is_global_duplicate_update(update_id, namespace="telegram_update")
def _is_non_modified_error(response) -> bool:
"""Telegram API 回覆若是 `message is not modified` 則視為可忽略。"""
if not isinstance(response, dict):
return False
desc = (response.get("description") or "").lower()
return "message is not modified" in desc
def _is_non_editable_message_error(response) -> bool:
"""V-Fix若訊息已刪除、不可編輯或過舊僅回報不再 fallback。"""
if not isinstance(response, dict):
return False
desc = (response.get("description") or "").lower()
markers = (
"message to edit not found",
"message can't be edited",
"message_id_invalid",
"message id is invalid",
"message identifier is invalid",
"message is too old to edit",
"not found",
"reply message not found",
"message to delete not found",
)
return any(m in desc for m in markers)
@contextmanager
def _run_with_callback_cmd_context():
"""在 callback 路徑處理指令時,暫時封鎖 NL agent dispatch。"""
token = _CMD_FROM_CALLBACK_CTX.set(True)
try:
yield
finally:
_CMD_FROM_CALLBACK_CTX.reset(token)
def _build_callback_dedupe_key(update_id, cq_id, message_id=None, data=None, chat_id=None, user_id=None) -> str:
"""V-Fix多維度組成 callback key降低重複回報機率。"""
key_parts = []
if cq_id:
key_parts.append(f"cbq:{cq_id}")
elif update_id is not None:
key_parts.append(f"uid:{update_id}")
if chat_id is not None:
key_parts.append(f"chat:{chat_id}")
if user_id is not None:
key_parts.append(f"user:{user_id}")
if message_id is not None:
key_parts.append(f"msg:{message_id}")
if data:
key_parts.append(f"data:{data}")
if key_parts:
return "cb:" + "|".join(key_parts)
base = f"cb-query:{cq_id}"
if message_id is not None:
base += f":msg:{message_id}"
if data:
base += f":{data}"
return base
def _should_fallback_send_message(edit_result) -> bool:
"""V-Fix判斷 editMessageText 失敗是否要改走 sendMessage。"""
if not isinstance(edit_result, dict):
return True
if edit_result.get("ok"):
return False
if _is_non_modified_error(edit_result):
return False
if _is_non_editable_message_error(edit_result):
return False
return True
def _normalize_callback_data(data: str) -> str:
"""V-Fix兼容舊版 callback_data例如 menu_main、menu_trend、await_date...)。"""
if not data:
return data
data = data.strip()
if data.startswith(("menu:", "cmd:", "await:")):
return data
if data.startswith("menu_"):
key = data[5:]
if key in _SUBMENUS:
return f"menu:{key}"
elif data.startswith("await_"):
key = data[6:]
if key in _AWAIT_PROMPTS:
return f"await:{key}"
elif data.startswith("cmd_"):
return f"cmd:{data[4:]}"
return data
# 群組內回應觸發(包含這些字才回應;若為空則全部回應)
# 設為空 list = 所有訊息都回應
TRIGGER_KEYWORDS = [] # 空 = 全部回應(小龍蝦是專用業務群組)
# ── 目標管理(記憶體,跨 session 用 DB 儲存)─────────────────────
_GOALS: dict = {} # {'daily','monthly','quarterly','half','yearly': float}
_scheduler = None
# ── 輸入等待狀態機chat_id → pending action────────────────────
_input_pending: dict = {} # {chat_id: {'action': str, 'label': str}}
# ── Excel 匯入暫存chat_id → pending import info────────────────
_excel_pending: dict = {} # {chat_id: {'file_path': str, 'filename': str, 'preview': str}}
# ── 中文字型搜尋 ──────────────────────────────────────────────
_CHINESE_FONT_PATHS = [
'/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc',
'/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
'/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc',
'/tmp/ocbot_chinese_font.ttf',
]
_FONT_DL_URL = (
'https://github.com/googlefonts/noto-cjk/raw/main/'
'Sans/SubsetOTF/SC/NotoSansSC-Regular.otf'
)
def _get_chinese_font() -> str:
"""取得中文字型路徑(找不到就嘗試下載),回傳路徑或空字串"""
for p in _CHINESE_FONT_PATHS:
if os.path.exists(p):
return p
# 嘗試下載 Noto Sans SC約 3MB
cache = '/tmp/ocbot_chinese_font.ttf'
try:
r = requests.get(_FONT_DL_URL, timeout=20)
if r.ok:
with open(cache, 'wb') as f:
f.write(r.content)
return cache
except Exception:
sys_log.debug('[OpenClawBot] Chinese font download failed', exc_info=True)
return ''
# ── 報表產生Excel→ CSV備援────────────────────────────
def generate_daily_pdf(date_str: str) -> str:
"""
產生日報:優先用 openpyxl 生成 .xlsx支援中文、可直接在 Excel/Numbers 開啟)。
openpyxl 未安裝時 fallback CSV。
"""
import tempfile
sales = query_sales(date_str)
products = query_top_products(date_str, 20)
vendors = query_top_vendors(date_str, 10)
weekly = query_weekly_trend()
now_str = datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M')
safe_date = date_str.replace('/', '-')
# ── openpyxl Excel主要支援中文秒產生─────────────────
try:
from openpyxl import Workbook
from openpyxl.styles import (Font, PatternFill, Alignment,
Border, Side, numbers)
from openpyxl.utils import get_column_letter
wb = Workbook()
HDR = Font(bold=True, color='FFFFFF', size=11)
HDR_FILL = PatternFill('solid', fgColor='1565C0') # 深藍
SUB_FILL = PatternFill('solid', fgColor='E3F2FD') # 淡藍
ORG_FILL = PatternFill('solid', fgColor='FFF8E1') # 淡黃
thin = Side(style='thin', color='BDBDBD')
border = Border(left=thin, right=thin, top=thin, bottom=thin)
center = Alignment(horizontal='center', vertical='center')
right = Alignment(horizontal='right', vertical='center')
def _hdr(ws, row, cols, fills=HDR_FILL):
for c in cols:
ws.cell(row, c).font = HDR
ws.cell(row, c).fill = fills
ws.cell(row, c).alignment = center
def _border_row(ws, row, max_col):
for c in range(1, max_col + 1):
ws.cell(row, c).border = border
# ════ Sheet 1業績摘要 ════
ws1 = wb.active
ws1.title = '業績摘要'
ws1.column_dimensions['A'].width = 18
ws1.column_dimensions['B'].width = 22
ws1.merge_cells('A1:B1')
ws1['A1'] = f'📊 業績報表 — {date_str}'
ws1['A1'].font = Font(bold=True, size=14, color='1565C0')
ws1['A1'].alignment = center
ws1.merge_cells('A2:B2')
ws1['A2'] = f'產生時間:{now_str} (UTC+8) | OpenClaw AI'
ws1['A2'].font = Font(size=9, color='757575')
ws1['A2'].alignment = center
r = 4
if sales.get('found'):
headers = ['項目', '數值']
for i, h in enumerate(headers, 1):
ws1.cell(r, i, h)
_hdr(ws1, r, [1, 2])
_border_row(ws1, r, 2)
r += 1
rows_data = [
('💰 總業績', f"NT$ {float(sales.get('revenue', 0)):,.0f}"),
('📦 訂單數', f"{sales.get('orders', '-')}"),
('🛒 客單價', f"NT$ {sales.get('avg_order', 0):,.0f}"),
('📈 毛利率', f"{sales.get('gross_margin', 0):.1f}%"),
('🛍️ 商品數', f"{sales.get('products', '-')}"),
]
for i, (k, v) in enumerate(rows_data):
ws1.cell(r, 1, k)
ws1.cell(r, 2, v)
fill = SUB_FILL if i % 2 == 0 else None
if fill:
ws1.cell(r, 1).fill = fill
ws1.cell(r, 2).fill = fill
ws1.cell(r, 2).alignment = right
_border_row(ws1, r, 2)
r += 1
# ── 近7日趨勢 ──
if weekly:
r += 1
ws1.cell(r, 1, '📅 近7日業績趨勢').font = Font(bold=True, size=11, color='1565C0')
r += 1
for h, hdr in enumerate(['日期', '業績', '環比'], 1):
ws1.cell(r, h, hdr)
_hdr(ws1, r, [1, 2, 3])
_border_row(ws1, r, 3)
r += 1
for idx, w in enumerate(weekly):
prev = weekly[idx - 1]['revenue'] if idx > 0 else None
rev = w['revenue']
pct = f"{'' if rev >= prev else ''}{abs((rev - prev) / prev * 100):.1f}%" \
if prev else ''
ws1.cell(r, 1, w['date'])
ws1.cell(r, 2, f"NT$ {rev:,.0f}")
ws1.cell(r, 3, pct)
ws1.cell(r, 2).alignment = right
ws1.cell(r, 3).alignment = center
if idx % 2 == 0:
for c in range(1, 4):
ws1.cell(r, c).fill = ORG_FILL
_border_row(ws1, r, 3)
r += 1
ws1.column_dimensions['C'].width = 10
# ════ Sheet 2熱銷商品 ════
if products:
ws2 = wb.create_sheet('熱銷商品 TOP20')
cols = ['排名', '商品ID', '商品名稱', '業績 (NT$)', '數量 (件)', '佔比 %']
widths = [6, 28, 30, 16, 10, 10]
for i, (h, w) in enumerate(zip(cols, widths), 1):
ws2.cell(1, i, h)
ws2.column_dimensions[get_column_letter(i)].width = w
_hdr(ws2, 1, list(range(1, len(cols) + 1)))
_border_row(ws2, 1, len(cols))
total_rev = sum(p['revenue'] for p in products)
for idx, p in enumerate(products, 1):
pct = p['revenue'] / total_rev * 100 if total_rev else 0
row_data = [idx, p.get('id', ''), p['name'],
p['revenue'], p['qty'], round(pct, 2)]
for c, val in enumerate(row_data, 1):
ws2.cell(idx + 1, c, val)
ws2.cell(idx + 1, c).border = border
ws2.cell(idx + 1, 4).number_format = '#,##0'
ws2.cell(idx + 1, 5).number_format = '#,##0'
ws2.cell(idx + 1, 6).number_format = '0.00"%"'
ws2.cell(idx + 1, 4).alignment = right
ws2.cell(idx + 1, 5).alignment = right
ws2.cell(idx + 1, 6).alignment = right
if idx % 2 == 0:
for c in range(1, len(cols) + 1):
ws2.cell(idx + 1, c).fill = SUB_FILL
# ════ Sheet 3熱銷廠商 ════
if vendors:
ws3 = wb.create_sheet('熱銷廠商 TOP10')
vcols = ['排名', '廠商名稱', '業績 (NT$)', '佔比 %']
vwidths = [6, 32, 16, 10]
for i, (h, w) in enumerate(zip(vcols, vwidths), 1):
ws3.cell(1, i, h)
ws3.column_dimensions[get_column_letter(i)].width = w
_hdr(ws3, 1, list(range(1, len(vcols) + 1)))
_border_row(ws3, 1, len(vcols))
total_vrev = sum(v['revenue'] for v in vendors)
for idx, v in enumerate(vendors, 1):
pct = v['revenue'] / total_vrev * 100 if total_vrev else 0
for c, val in enumerate([idx, v['name'], v['revenue'], round(pct, 2)], 1):
ws3.cell(idx + 1, c, val)
ws3.cell(idx + 1, c).border = border
ws3.cell(idx + 1, 3).number_format = '#,##0'
ws3.cell(idx + 1, 4).number_format = '0.00"%"'
ws3.cell(idx + 1, 3).alignment = right
ws3.cell(idx + 1, 4).alignment = right
if idx % 2 == 0:
for c in range(1, len(vcols) + 1):
ws3.cell(idx + 1, c).fill = ORG_FILL
# ════ Sheet 4分類業績 ════
try:
cats = query_category_sales(date_str, lim=15)
if cats:
ws4 = wb.create_sheet('分類業績 TOP15')
ccols = ['排名', '分類名稱', '業績 (NT$)', '訂單數', '佔比 %']
cwidths = [6, 28, 16, 10, 10]
for i, (h, w) in enumerate(zip(ccols, cwidths), 1):
ws4.cell(1, i, h)
ws4.column_dimensions[get_column_letter(i)].width = w
_hdr(ws4, 1, list(range(1, len(ccols) + 1)))
_border_row(ws4, 1, len(ccols))
total_crev = sum(c.get('revenue', 0) for c in cats)
for idx, c in enumerate(cats, 1):
pct = c.get('revenue', 0) / total_crev * 100 if total_crev else 0
for col, val in enumerate([idx, c.get('cat', c.get('category','')),
c.get('revenue',0), c.get('qty', c.get('orders',0)),
round(pct,2)], 1):
ws4.cell(idx+1, col, val)
ws4.cell(idx+1, col).border = border
ws4.cell(idx+1, 3).number_format = '#,##0'
ws4.cell(idx+1, 5).number_format = '0.00"%"'
if idx % 2 == 0:
for col in range(1, len(ccols)+1):
ws4.cell(idx+1, col).fill = SUB_FILL
except Exception as _e:
sys_log.warning(f"[Excel] 分類業績 sheet 失敗: {_e}")
# ════ Sheet 5同期比較 ════
try:
cmp = query_comparison(date_str)
if cmp:
ws5 = wb.create_sheet('同期比較')
ws5.column_dimensions['A'].width = 18
ws5.column_dimensions['B'].width = 20
ws5.column_dimensions['C'].width = 20
ws5.column_dimensions['D'].width = 12
ws5.cell(1, 1, '指標'); ws5.cell(1, 2, '本期'); ws5.cell(1, 3, '對比期'); ws5.cell(1, 4, '增減 %')
_hdr(ws5, 1, [1,2,3,4])
_border_row(ws5, 1, 4)
periods = ['today', 'yesterday', 'last_week', 'last_month']
labels = ['今日', '昨日', '上週同日', '上月同日']
metrics = [('業績 (NT$)', 'revenue'), ('訂單數', 'orders'),
('客單價 (NT$)', 'avg_order'), ('毛利率 (%)', 'gross_margin')]
base = cmp.get('today', {})
r = 2
for period, label in zip(periods[1:], labels[1:]):
comp = cmp.get(period, {})
if not comp:
continue
ws5.cell(r, 1, f'vs {label}').font = Font(bold=True, color='1565C0')
ws5.cell(r, 1).fill = PatternFill('solid', fgColor='E3F2FD')
_border_row(ws5, r, 4)
r += 1
for mlabel, mkey in metrics:
bv = float(base.get(mkey, 0) or 0)
cv = float(comp.get(mkey, 0) or 0)
pct = f"{'' if bv >= cv else ''}{abs((bv-cv)/cv*100):.1f}%" if cv else ''
ws5.cell(r, 1, mlabel); ws5.cell(r, 2, f'{bv:,.1f}')
ws5.cell(r, 3, f'{cv:,.1f}'); ws5.cell(r, 4, pct)
for col in range(1,5): ws5.cell(r,col).border = border
ws5.cell(r,4).alignment = center
r += 1
except Exception as _e:
sys_log.warning(f"[Excel] 同期比較 sheet 失敗: {_e}")
# ════ Sheet 6目標達成率 ════
try:
goal = get_goal_status(date_str)
if goal:
ws6 = wb.create_sheet('目標達成率')
ws6.column_dimensions['A'].width = 16
ws6.column_dimensions['B'].width = 20
ws6.column_dimensions['C'].width = 20
ws6.column_dimensions['D'].width = 14
ws6.cell(1, 1, '週期'); ws6.cell(1, 2, '目標 (NT$)'); ws6.cell(1, 3, '實際 (NT$)'); ws6.cell(1, 4, '達成率')
_hdr(ws6, 1, [1,2,3,4])
_border_row(ws6, 1, 4)
r = 2
for period, label in [('daily',''), ('monthly',''), ('quarterly',''),
('half','半年'), ('yearly','')]:
tgt = float(goal.get(f'{period}_target', 0) or 0)
act = float(goal.get(f'{period}_actual', 0) or 0)
if tgt <= 0: continue
rate = act / tgt * 100
ws6.cell(r,1,label); ws6.cell(r,2,tgt); ws6.cell(r,3,act)
ws6.cell(r,4,f'{rate:.1f}%')
ws6.cell(r,2).number_format = '#,##0'
ws6.cell(r,3).number_format = '#,##0'
color = '1B5E20' if rate >= 100 else ('E65100' if rate < 70 else '1565C0')
ws6.cell(r,4).font = Font(bold=True, color=color)
for col in range(1,5): ws6.cell(r,col).border = border
r += 1
except Exception as _e:
sys_log.warning(f"[Excel] 目標達成率 sheet 失敗: {_e}")
tmp = tempfile.NamedTemporaryFile(
suffix=f'_{safe_date}.xlsx', prefix='momo_report_', delete=False, dir='/tmp'
)
wb.save(tmp.name)
sys_log.info(f"[OpenClawBot] Excel report generated: {tmp.name}")
return tmp.name
except ImportError:
sys_log.warning("[OpenClawBot] openpyxl 未安裝fallback CSV")
except Exception as e:
sys_log.error(f"[OpenClawBot] openpyxl error: {e}")
# ── FallbackCSV ──────────────────────────────────────────
try:
import csv
tmp = tempfile.NamedTemporaryFile(
suffix=f'_{safe_date}.csv', prefix='momo_report_', delete=False,
dir='/tmp', mode='w', encoding='utf-8-sig', newline=''
)
writer = csv.writer(tmp)
writer.writerow(['業績報表', date_str, f'產生時間:{now_str}'])
writer.writerow([])
if sales.get('found'):
writer.writerow(['=== 業績摘要 ==='])
writer.writerow(['總業績', float(sales.get('revenue', 0))])
writer.writerow(['訂單數', sales.get('orders', '-')])
writer.writerow(['商品數', sales.get('products', '-')])
writer.writerow([])
if products:
writer.writerow(['=== 熱銷商品 TOP20 ==='])
writer.writerow(['排名', '商品ID', '商品名稱', '業績', '數量'])
for i, p in enumerate(products, 1):
writer.writerow([i, p.get('id', ''), p['name'],
p['revenue'], p['qty']])
writer.writerow([])
if vendors:
writer.writerow(['=== 熱銷廠商 TOP10 ==='])
writer.writerow(['排名', '廠商名稱', '業績'])
for i, v in enumerate(vendors, 1):
writer.writerow([i, v['name'], v['revenue']])
tmp.close()
sys_log.info(f"[OpenClawBot] CSV fallback generated: {tmp.name}")
return tmp.name
except Exception as e:
sys_log.error(f"[OpenClawBot] CSV fallback error: {e}")
return ''
# ══════════════════════════════════════════════════════════════
# v5 — 進階業績智能功能
# ══════════════════════════════════════════════════════════════
# ── 新增 DB 查詢 ──────────────────────────────────────────────
def query_category_sales(date_str, lim=10):
"""按商品分類查業績(優先用 商品分類L1fallback 小分類)"""
d = normalize_date(date_str)
for col in ('"商品分類L1"', '"商品分類L2"', '"小分類"', '"商品分類"', '"類別"', '"category"'):
try:
with _db().connect() as c:
rows = c.execute(text(f"""
SELECT COALESCE({col}, '未分類') as cat,
COUNT(DISTINCT "商品ID") as products,
SUM(CAST("總業績" AS FLOAT)) as revenue,
SUM(CAST("數量" AS INTEGER)) as qty
FROM realtime_sales_monthly WHERE "日期"=:d
GROUP BY cat ORDER BY revenue DESC LIMIT :lim
"""), {'d': d, 'lim': lim}).fetchall()
if rows:
return [{'cat': r[0], 'products': r[1], 'revenue': r[2], 'qty': r[3]}
for r in rows]
except Exception:
continue
return []
def query_category_monthly(year: int, month: int, lim: int = 10) -> list:
"""按月份查分類業績(用 LIKE YYYY/MM/%"""
prefix = f"{year}/{month:02d}/%"
for col in ('"商品分類L1"', '"商品分類L2"', '"小分類"', '"商品分類"'):
try:
with _db().connect() as c:
rows = c.execute(text(f"""
SELECT COALESCE({col}, '未分類') as cat,
COUNT(DISTINCT "商品ID") as products,
SUM(CAST("總業績" AS FLOAT)) as revenue,
SUM(CAST("數量" AS INTEGER)) as qty
FROM realtime_sales_monthly WHERE "日期" LIKE :prefix
GROUP BY cat ORDER BY revenue DESC LIMIT :lim
"""), {'prefix': prefix, 'lim': lim}).fetchall()
if rows:
return [{'cat': r[0], 'products': int(r[1]), 'revenue': float(r[2]), 'qty': int(r[3])}
for r in rows]
except Exception:
continue
return []
def query_comparison(date_str):
"""今日 vs 上週同日 vs 上月同日"""
from datetime import datetime as dt
try:
d = dt.strptime(normalize_date(date_str).replace('/', '-'), '%Y-%m-%d').date()
lw_str = (d - timedelta(days=7)).strftime('%Y/%m/%d')
lm_str = (d - timedelta(days=30)).strftime('%Y/%m/%d')
def _fetch(day_s):
try:
with _db().connect() as c:
row = c.execute(text("""
SELECT SUM(CAST("總業績" AS FLOAT)),
COUNT(DISTINCT "商品ID")
FROM realtime_sales_monthly WHERE "日期"=:d
"""), {'d': day_s}).fetchone()
if row and row[0]:
return {'date': day_s, 'revenue': float(row[0]), 'products': row[1]}
except Exception:
sys_log.exception('[OpenClawBot] trend comparison fetch failed for date=%s', day_s)
return {'date': day_s, 'revenue': 0, 'products': 0}
return {
'today': _fetch(normalize_date(date_str)),
'last_week': _fetch(lw_str),
'last_month': _fetch(lm_str),
}
except Exception as e:
sys_log.error(f"[OpenClawBot] query_comparison: {e}")
return None
def query_daily_history(days=14):
"""取得近N天日業績用於趨勢圖"""
try:
with _db().connect() as c:
rows = c.execute(text(f"""
SELECT "日期", SUM(CAST("總業績" AS FLOAT)) as rev
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{int(days)} days'
GROUP BY "日期" ORDER BY "日期" ASC
""")).fetchall()
return [{'date': r[0], 'revenue': float(r[1])} for r in rows if r[1]]
except Exception as e:
sys_log.error(f"[OpenClawBot] query_daily_history: {e}")
return []
def query_restock_forecast(top_n: int = 20) -> list:
"""基於近7日銷售速度的補貨預測高速+加速商品優先)"""
try:
with _db().connect() as c:
rows = c.execute(text("""
WITH recent AS (
SELECT "商品ID", "商品名稱",
SUM(CAST("數量" AS INTEGER)) AS qty7,
SUM(CAST("總業績" AS FLOAT)) AS rev7,
COUNT(DISTINCT "日期") AS active_days
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY "商品ID", "商品名稱"
),
older AS (
SELECT "商品ID",
COALESCE(SUM(CAST("數量" AS INTEGER)), 0) AS qty_prev7
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN
CURRENT_DATE - INTERVAL '14 days' AND
CURRENT_DATE - INTERVAL '8 days'
GROUP BY "商品ID"
)
SELECT r."商品ID", r."商品名稱",
r.qty7, r.active_days, r.rev7,
COALESCE(o.qty_prev7, 0) AS qty_prev7
FROM recent r
LEFT JOIN older o ON r."商品ID" = o."商品ID"
WHERE r.active_days >= 3 AND r.qty7 > 0
ORDER BY r.qty7 DESC
LIMIT :n
"""), {'n': top_n}).fetchall()
result = []
for row in rows:
pid, name, qty7, active, rev7, qty_prev7 = row
daily_vel = qty7 / 7
prev_vel = qty_prev7 / 7 if qty_prev7 else daily_vel
accel = (daily_vel - prev_vel) / prev_vel * 100 if prev_vel > 0 else 0
urgency = ('HIGH' if daily_vel >= 5 and accel >= 15
else 'MED' if daily_vel >= 2 or accel >= 30
else 'LOW')
result.append({
'id': pid, 'name': name,
'daily_vel': round(daily_vel, 1),
'qty7': int(qty7), 'rev7': rev7,
'accel': round(accel, 1),
'urgency': urgency,
})
return result
except Exception as e:
sys_log.error(f"[restock] {e}")
return []
def query_category_detail(category: str, date_str: str = '', limit: int = 10) -> list:
"""查詢指定L1分類的L2細項業績"""
try:
# 決定查詢範圍有日期查單日否則查近7天
if date_str:
d = normalize_date(date_str)
where_clause = '"日期" = :d'
params = {'d': d, 'lim': limit, 'cat': category}
else:
where_clause = 'CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL \'7 days\''
params = {'lim': limit, 'cat': category}
for l2_col in ('"商品分類L2"', '"小分類"', '"商品分類"'):
try:
with _db().connect() as c:
rows = c.execute(text(f"""
SELECT COALESCE({l2_col}, '其他') AS sub_cat,
COUNT(DISTINCT "商品ID") AS products,
SUM(CAST("總業績" AS FLOAT)) AS revenue,
SUM(CAST("數量" AS INTEGER)) AS qty
FROM realtime_sales_monthly
WHERE "商品分類L1" = :cat AND {where_clause}
GROUP BY sub_cat ORDER BY revenue DESC LIMIT :lim
"""), params).fetchall()
if rows:
return [{'cat': r[0], 'products': r[1], 'revenue': float(r[2]), 'qty': int(r[3])}
for r in rows]
except Exception:
continue
return []
except Exception as e:
sys_log.error(f"[category_detail] {e}")
return []
def query_promo_comparison(start_str: str, end_str: str) -> dict:
"""查詢促銷期間 vs 前同天數業績比較"""
try:
from datetime import datetime as _dt
start = _dt.strptime(start_str.replace('/', '-'), '%Y-%m-%d').date()
end = _dt.strptime(end_str.replace('/', '-'), '%Y-%m-%d').date()
days = (end - start).days + 1
# 前期:同等天數
from datetime import timedelta as _td
pre_end = start - _td(days=1)
pre_start = pre_end - _td(days=days - 1)
def _fetch_period(s, e):
try:
with _db().connect() as c:
row = c.execute(text("""
SELECT COUNT(DISTINCT "訂單編號"),
COALESCE(SUM(CAST("總業績" AS FLOAT)), 0),
COALESCE(SUM(CAST("總成本" AS FLOAT)), 0),
COUNT(DISTINCT "商品ID")
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
"""), {'s': str(s), 'e': str(e)}).fetchone()
tops = c.execute(text("""
SELECT "商品名稱", SUM(CAST("總業績" AS FLOAT)) AS rev
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
GROUP BY "商品名稱" ORDER BY rev DESC LIMIT 5
"""), {'s': str(s), 'e': str(e)}).fetchall()
orders, rev, cost, prods = row
margin = (rev - cost) / rev * 100 if rev else 0
return {
'start': str(s), 'end': str(e), 'days': days,
'orders': int(orders or 0), 'revenue': float(rev),
'margin': round(margin, 1), 'products': int(prods or 0),
'top_products': [{'name': r[0], 'revenue': float(r[1])} for r in tops],
}
except Exception as ex:
sys_log.error(f"[promo_cmp] {ex}")
return {'start': str(s), 'end': str(e), 'days': days,
'orders': 0, 'revenue': 0, 'margin': 0, 'products': 0, 'top_products': []}
promo = _fetch_period(start, end)
pre = _fetch_period(pre_start, pre_end)
rev_lift = (promo['revenue'] - pre['revenue']) / pre['revenue'] * 100 if pre['revenue'] else 0
ord_lift = (promo['orders'] - pre['orders']) / pre['orders'] * 100 if pre['orders'] else 0
return {
'promo': promo, 'pre': pre,
'rev_lift': round(rev_lift, 1),
'ord_lift': round(ord_lift, 1),
}
except Exception as e:
sys_log.error(f"[promo_comparison] {e}")
return {}
def query_anomalies(date_str):
"""偵測當日業績異常商品vs 7日均值偏差>30%"""
d = normalize_date(date_str)
try:
with _db().connect() as c:
rows = c.execute(text("""
WITH today AS (
SELECT "商品ID", "商品名稱",
SUM(CAST("總業績" AS FLOAT)) AS today_rev
FROM realtime_sales_monthly WHERE "日期"=:d
GROUP BY "商品ID", "商品名稱"
),
avg7 AS (
SELECT "商品ID", AVG(day_rev) AS avg_rev
FROM (
SELECT "商品ID", "日期",
SUM(CAST("總業績" AS FLOAT)) AS day_rev
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE)
BETWEEN CAST(:d AS DATE) - INTERVAL '7 days'
AND CAST(:d AS DATE) - INTERVAL '1 day'
GROUP BY "商品ID", "日期"
) sub
GROUP BY "商品ID"
)
SELECT t."商品ID", t."商品名稱", t.today_rev, a.avg_rev,
(t.today_rev - a.avg_rev) / NULLIF(a.avg_rev, 0) * 100 AS pct
FROM today t
JOIN avg7 a ON t."商品ID" = a."商品ID"
WHERE ABS((t.today_rev - a.avg_rev) / NULLIF(a.avg_rev, 0)) > 0.3
ORDER BY ABS((t.today_rev - a.avg_rev) / NULLIF(a.avg_rev, 0)) DESC
LIMIT 10
"""), {'d': d}).fetchall()
return [{'id': r[0], 'name': r[1], 'today': r[2], 'avg7': r[3], 'pct': r[4]}
for r in rows]
except Exception as e:
sys_log.error(f"[OpenClawBot] query_anomalies: {e}")
return []
# ── 目標管理 ──────────────────────────────────────────────────
def get_goal_status(date_str: str) -> dict:
d = normalize_date(date_str)
sales = query_sales(date_str)
today_rev = float(sales.get('revenue', 0)) if sales.get('found') else 0.0
# 月業績
try:
month_prefix = d[:7] # e.g. '2026/04'
with _db().connect() as c:
row = c.execute(text("""
SELECT SUM(CAST("總業績" AS FLOAT))
FROM realtime_sales_monthly WHERE "日期" LIKE :prefix
"""), {'prefix': f"{month_prefix}%"}).fetchone()
month_rev = float(row[0]) if row and row[0] else 0.0
except Exception:
month_rev = 0.0
daily_goal = _GOALS.get('daily', 0)
monthly_goal = _GOALS.get('monthly', 0)
quarterly_goal = _GOALS.get('quarterly', 0)
half_goal = _GOALS.get('half', 0)
yearly_goal = _GOALS.get('yearly', 0)
# P9 — 週目標自動推算:若未手動設定,從月目標 ÷ 4.3 推算
weekly_goal = _GOALS.get('weekly', 0)
if not weekly_goal and monthly_goal:
weekly_goal = round(monthly_goal / 4.3)
# 季/半年/年業績
year_s = d[:4]
try:
with _db().connect() as c:
row_y = c.execute(text("""
SELECT SUM(CAST("總業績" AS FLOAT))
FROM realtime_sales_monthly WHERE "日期" LIKE :y
"""), {'y': f"{year_s}/%"}).fetchone()
year_rev = float(row_y[0]) if row_y and row_y[0] else 0.0
except Exception:
year_rev = 0.0
# 近7日業績週目標達成率用
try:
weekly_rows = query_weekly_trend()
week_rev = sum(w['revenue'] for w in weekly_rows) if weekly_rows else 0.0
except Exception:
week_rev = 0.0
return {
'date': d,
'today_rev': today_rev,
'daily_goal': daily_goal,
'daily_pct': today_rev / daily_goal * 100 if daily_goal > 0 else None,
'week_rev': week_rev,
'weekly_goal': weekly_goal,
'weekly_pct': week_rev / weekly_goal * 100 if weekly_goal > 0 else None,
'weekly_auto': not bool(_GOALS.get('weekly', 0)) and bool(monthly_goal), # 是否自動推算
'month_rev': month_rev,
'monthly_goal': monthly_goal,
'monthly_pct': month_rev / monthly_goal * 100 if monthly_goal > 0 else None,
'year_rev': year_rev,
'quarterly_goal': quarterly_goal,
'quarterly_pct': year_rev / 4 / quarterly_goal * 100 if quarterly_goal > 0 else None,
'half_goal': half_goal,
'half_pct': year_rev / 2 / half_goal * 100 if half_goal > 0 else None,
'yearly_goal': yearly_goal,
'yearly_pct': year_rev / yearly_goal * 100 if yearly_goal > 0 else None,
# 月目標追蹤輔助欄位
'days_elapsed': int(d[8:10]) if len(d) >= 10 else 0,
'days_in_month': __import__('calendar').monthrange(int(d[:4]), int(d[5:7]))[1] if len(d) >= 7 else 30,
}
# ── 圖表產生matplotlib────────────────────────────────────
_MPL_FONT_SETUP_DONE = False
def _setup_mpl_chinese():
"""確保 matplotlib 使用中文字型(只執行一次)"""
global _MPL_FONT_SETUP_DONE
if _MPL_FONT_SETUP_DONE:
return
try:
import matplotlib.font_manager as fm
import matplotlib as mpl
font_paths = [
'/usr/local/lib/python3.11/site-packages/matplotlib/mpl-data/fonts/ttf/WQYZenHei.ttf',
'/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc',
'/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
]
for fp in font_paths:
if os.path.exists(fp):
fm.fontManager.addfont(fp)
prop = fm.FontProperties(fname=fp)
mpl.rcParams['font.family'] = prop.get_name()
mpl.rcParams['axes.unicode_minus'] = False
_MPL_FONT_SETUP_DONE = True
sys_log.info(f"[OpenClawBot] matplotlib 中文字型: {fp}")
return
sys_log.warning("[OpenClawBot] 找不到中文字型,圖表文字可能亂碼")
except Exception as e:
sys_log.warning(f"[OpenClawBot] _setup_mpl_chinese: {e}")
def gen_trend_chart(days=14, data_points=None, title=None) -> str:
"""產生業績趨勢折線圖 PNG — 上升紅色/下降綠色,每日金額標註
data_points: 若提供則使用此資料 [{'date': ..., 'revenue': ...}],否則查近 days 日
title: 自訂圖表標題
"""
try:
import matplotlib
matplotlib.use('Agg')
_setup_mpl_chinese()
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import matplotlib.patches as mpatches
from datetime import datetime as dt
import tempfile
history = data_points if data_points else query_daily_history(days)
if not history:
return ''
dates = [dt.strptime(r['date'].replace('/', '-'), '%Y-%m-%d') for r in history]
revenues = [r['revenue'] / 10000 for r in history] # 萬元
# 台灣股市慣例:上升=紅,下降=綠
UP_COLOR = '#E53935' # 上升 紅
DOWN_COLOR = '#43A047' # 下降 綠
NEUTRAL = '#1565C0' # 第一點 藍
fig, ax = plt.subplots(figsize=(14, 6))
fig.patch.set_facecolor('#FAFAFA')
ax.set_facecolor('#FAFAFA')
# 逐段繪製(每段依漲跌著色)
for i in range(1, len(dates)):
seg_color = UP_COLOR if revenues[i] >= revenues[i - 1] else DOWN_COLOR
ax.plot([dates[i - 1], dates[i]], [revenues[i - 1], revenues[i]],
'-', color=seg_color, linewidth=2.8, solid_capstyle='round')
# 每日節點(顏色依和前日比較)
for i, (d, r) in enumerate(zip(dates, revenues)):
if i == 0:
pt_color = NEUTRAL
elif r >= revenues[i - 1]:
pt_color = UP_COLOR
else:
pt_color = DOWN_COLOR
ax.plot(d, r, 'o', color=pt_color, markersize=8, zorder=5,
markeredgecolor='white', markeredgewidth=1.5)
# 每日金額標籤
for i, (d, r) in enumerate(zip(dates, revenues)):
# 決定標籤位置(奇偶交錯避免重疊)
y_offset = 12 if i % 2 == 0 else -22
ha = 'center'
if i == 0:
label_color = NEUTRAL
elif r >= revenues[i - 1]:
label_color = UP_COLOR
else:
label_color = DOWN_COLOR
ax.annotate(f'NT${r:,.1f}',
(d, r),
textcoords='offset points', xytext=(0, y_offset),
ha=ha, fontsize=8.5, color=label_color, fontweight='bold',
bbox=dict(boxstyle='round,pad=0.2', facecolor='white',
edgecolor=label_color, alpha=0.85, linewidth=0.8))
# 全域最高/最低特別標記
if revenues:
max_i = revenues.index(max(revenues))
min_i = revenues.index(min(revenues))
ax.scatter([dates[max_i]], [revenues[max_i]], s=180, color=UP_COLOR,
zorder=6, marker='*', edgecolors='white', linewidths=0.8)
ax.scatter([dates[min_i]], [revenues[min_i]], s=180, color=DOWN_COLOR,
zorder=6, marker='v', edgecolors='white', linewidths=0.8)
# X 軸:日期 + 星期
WEEKDAYS_ZH = ['週一', '週二', '週三', '週四', '週五', '週六', '週日']
ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d'))
ax.xaxis.set_major_locator(mdates.DayLocator(interval=1))
# 替換 x tick labels 加上星期
tick_dates = dates
tick_labels = [f"{d.strftime('%m/%d')}\n{WEEKDAYS_ZH[d.weekday()]}" for d in tick_dates]
ax.set_xticks(tick_dates)
ax.set_xticklabels(tick_labels, fontsize=9)
ax.set_ylabel('業績(萬元)', fontsize=12)
today_str = datetime.now(TAIPEI_TZ).strftime('%Y/%m/%d')
chart_title = title or f'業績趨勢走勢圖 — 近 {days} 日 (截至 {today_str}'
ax.set_title(chart_title, fontsize=14, fontweight='bold', pad=14)
ax.grid(True, alpha=0.25, linestyle='--', color='#BDBDBD')
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'{x:,.0f}'))
# 圖例
up_patch = mpatches.Patch(color=UP_COLOR, label='▲ 較前日上升')
down_patch = mpatches.Patch(color=DOWN_COLOR, label='▼ 較前日下降')
ax.legend(handles=[up_patch, down_patch], loc='upper left', fontsize=10,
framealpha=0.85)
# 統計副標題
if len(revenues) >= 2:
total_rev = sum(revenues)
avg_rev = total_rev / len(revenues)
max_rev = max(revenues)
min_rev = min(revenues)
last_change_pct = (revenues[-1] - revenues[-2]) / revenues[-2] * 100 if revenues[-2] else 0
arrow = '' if last_change_pct >= 0 else ''
stats_text = (f"總計 NT${total_rev:,.1f}萬 | "
f"日均 NT${avg_rev:,.1f}萬 | "
f"最高 NT${max_rev:,.1f}萬 | 最低 NT${min_rev:,.1f}萬 | "
f"昨日 {arrow}{abs(last_change_pct):.1f}%")
fig.text(0.5, 0.01, stats_text, ha='center', fontsize=9,
color='#616161', style='italic')
plt.tight_layout(rect=[0, 0.04, 1, 1])
tmp = tempfile.NamedTemporaryFile(
suffix='.png', prefix='ocbot_trend_', delete=False, dir='/tmp')
plt.savefig(tmp.name, dpi=130, bbox_inches='tight')
plt.close()
return tmp.name
except ImportError:
sys_log.warning('[OpenClawBot] matplotlib not installed — chart disabled')
return ''
except Exception as e:
sys_log.error(f'[OpenClawBot] gen_trend_chart: {e}')
return ''
def gen_products_chart(date_str, n=10) -> str:
"""產生熱銷商品橫條圖 PNG — 上升紅/下降綠,標示業績金額"""
try:
import matplotlib
matplotlib.use('Agg')
_setup_mpl_chinese()
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from datetime import datetime as dt
import tempfile
products = query_top_products(date_str, n)
if not products:
return ''
# 取得上週同日資料做顏色比較
try:
d = dt.strptime(date_str.replace('/', '-'), '%Y-%m-%d').date()
lw_str = (d - timedelta(days=7)).strftime('%Y/%m/%d')
lw_products = query_top_products(lw_str, n * 2)
lw_map = {p.get('id'): p['revenue'] for p in lw_products if p.get('id')}
except Exception:
lw_map = {}
UP_COLOR = '#E53935' # 上升/優 紅
DOWN_COLOR = '#43A047' # 下降 綠
NEUTRAL = '#1976D2' # 無前期資料 藍
# 短名稱限14字+ ID
labels = [f"{p['name'][:13]}\n[{_short_id(p.get('id',''))}]"
if len(p['name']) > 13
else f"{p['name']}\n[{_short_id(p.get('id',''))}]"
for p in products]
revenues = [p['revenue'] / 10000 for p in products]
# 決定每條顏色
colors = []
for p in products:
pid = p.get('id')
lw_rev = lw_map.get(pid)
if lw_rev is None:
colors.append(NEUTRAL)
elif p['revenue'] >= lw_rev:
colors.append(UP_COLOR)
else:
colors.append(DOWN_COLOR)
# 翻轉matplotlib barh 從下往上)
labels.reverse(); revenues.reverse(); colors.reverse()
fig, ax = plt.subplots(figsize=(13, max(6, n * 0.75)))
fig.patch.set_facecolor('#FAFAFA')
ax.set_facecolor('#FAFAFA')
bars = ax.barh(labels, revenues, color=colors, alpha=0.88,
height=0.62, edgecolor='white', linewidth=0.8)
max_rev = max(revenues) if revenues else 1
for bar, rev, col in zip(bars, revenues, colors):
# 金額標籤(在 bar 右側)
ax.text(bar.get_width() + max_rev * 0.008,
bar.get_y() + bar.get_height() / 2,
f'NT${rev:,.2f}',
va='center', ha='left', fontsize=9.5,
color=col, fontweight='bold')
ax.set_xlabel('業績(萬元)', fontsize=11)
ax.set_title(f'🏆 熱銷商品 TOP{n}{date_str}',
fontsize=14, fontweight='bold', pad=12)
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'{x:,.0f}'))
ax.grid(True, alpha=0.25, linestyle='--', color='#BDBDBD', axis='x')
# 設定 xlim 留右側空間給標籤
ax.set_xlim(0, max_rev * 1.35)
# 圖例
up_p = mpatches.Patch(color=UP_COLOR, label='▲ 週環比上升')
dn_p = mpatches.Patch(color=DOWN_COLOR, label='▼ 週環比下降')
ne_p = mpatches.Patch(color=NEUTRAL, label='— 無前期資料')
ax.legend(handles=[up_p, dn_p, ne_p], loc='lower right', fontsize=9,
framealpha=0.85)
plt.tight_layout()
tmp = tempfile.NamedTemporaryFile(
suffix='.png', prefix='ocbot_products_', delete=False, dir='/tmp')
plt.savefig(tmp.name, dpi=130, bbox_inches='tight')
plt.close()
return tmp.name
except ImportError:
return ''
except Exception as e:
sys_log.error(f'[OpenClawBot] gen_products_chart: {e}')
return ''
# ── 商品策略矩陣 ───────────────────────────────────────────────
def analyze_product_strategy(date_str: str, top_n=10) -> list:
"""內部業績週環比 × 外部MCP趨勢 → 策略標籤"""
products = query_top_products(date_str, top_n * 2)
if not products:
return []
from datetime import datetime as dt
try:
d = dt.strptime(normalize_date(date_str).replace('/', '-'), '%Y-%m-%d').date()
lw_str = (d - timedelta(days=7)).strftime('%Y/%m/%d')
lw_products = query_top_products(lw_str, top_n * 2)
lw_map = {p.get('id'): p['revenue'] for p in lw_products if p.get('id')}
except Exception:
lw_map = {}
try:
from services.mcp_context_service import get_taiwan_trends
trend_kws = [t['keyword'] for t in get_taiwan_trends().get('trends', [])][:20]
except Exception:
trend_kws = []
result = []
for p in products[:top_n]:
pid, name, rev = p.get('id', ''), p['name'], p['revenue']
lw_rev = lw_map.get(pid, 0)
growth = (rev - lw_rev) / lw_rev if lw_rev > 0 else 0
ext_hot = any(kw in name for kw in trend_kws) if trend_kws else None
if growth > 0.1 and (ext_hot or ext_hot is None):
tag, strat, advice = '🔥', '加碼', '內外皆熱,立即加大廣告投放'
elif growth >= -0.1 and ext_hot:
tag, strat, advice = '💡', '機會', '外部熱搜但放量不足,提升曝光'
elif growth > 0.1 and not ext_hot:
tag, strat, advice = '', '收割', '內部增長強,外部降溫前先收割'
elif growth < -0.1 and not ext_hot:
tag, strat, advice = '⚠️', '觀察', '內外皆疲,考慮轉移資源'
else:
tag, strat, advice = '', '持穩', '表現平穩,維持現狀'
result.append({
'id': pid, 'name': name, 'revenue': rev,
'growth': growth, 'ext_hot': ext_hot,
'tag': tag, 'strategy': strat, 'advice': advice,
})
return result
def _analyze_strategy_range(start_str: str, end_str: str, products: list) -> list:
"""區間版策略分析(週/月/季/年用):比對前一等長區間的成長率"""
if not products:
return []
try:
s = datetime.strptime(start_str.replace('/', '-'), '%Y-%m-%d')
e = datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d')
delta = (e - s).days + 1
prev_end = (s - timedelta(days=1)).strftime('%Y/%m/%d')
prev_start = (s - timedelta(days=delta)).strftime('%Y/%m/%d')
prev_prods = query_top_products_range(prev_start, prev_end, len(products) * 2)
prev_map = {p.get('id'): p['revenue'] for p in prev_prods if p.get('id')}
except Exception:
prev_map = {}
try:
from services.mcp_context_service import get_taiwan_trends
trend_kws = [t['keyword'] for t in get_taiwan_trends().get('trends', [])][:20]
except Exception:
trend_kws = []
result = []
for p in products:
pid, name, rev = p.get('id', ''), p['name'], p['revenue']
prev_rev = prev_map.get(pid, 0)
growth = (rev - prev_rev) / prev_rev if prev_rev > 0 else 0
ext_hot = any(kw in name for kw in trend_kws) if trend_kws else None
if growth > 0.1 and (ext_hot or ext_hot is None):
tag, strat, advice = '🔥', '加碼', '內外皆熱,立即加大廣告投放'
elif growth >= -0.1 and ext_hot:
tag, strat, advice = '💡', '機會', '外部熱搜但放量不足,提升曝光'
elif growth > 0.1 and not ext_hot:
tag, strat, advice = '', '收割', '內部增長強,外部降溫前先收割'
elif growth < -0.1 and not ext_hot:
tag, strat, advice = '⚠️', '觀察', '內外皆疲,考慮轉移資源'
else:
tag, strat, advice = '', '持穩', '表現平穩,維持現狀'
result.append({
'id': pid, 'name': name, 'revenue': rev,
'growth': growth, 'ext_hot': ext_hot,
'tag': tag, 'strategy': strat, 'advice': advice,
})
return result
# ── 新增格式化函數 ─────────────────────────────────────────────
def fmt_category(cats, date_str):
if not cats:
return (f"⚠️ *分類資料不足*\n\n"
f"`{date_str}` 無分類業績資料\n"
f"_請確認資料是否包含「商品分類」欄位_")
total = sum(c['revenue'] for c in cats)
lines = [
f"🗂 *{date_str} 分類業績分析*",
f"合計 `NT$ {total:,.0f}` | 共 {len(cats)} 個分類",
f"{'' * 30}",
"",
]
for i, c in enumerate(cats):
rev = c['revenue']
pct = rev / total * 100 if total > 0 else 0
medal = MEDALS[i] if i < len(MEDALS) else f"`{i+1}.`"
bar_len = max(1, int(pct / 100 * 10))
mini_bar = '' * bar_len + '' * (10 - bar_len)
lines.append(f"{medal} *{c['cat']}*")
lines.append(f" 💰 `NT$ {rev:,.0f}` 🛍 {c['products']}")
lines.append(f" `{mini_bar}` 佔比 *{pct:.1f}%*")
lines.append("")
# 前3名佔比
top3_pct = sum(c['revenue'] for c in cats[:3]) / total * 100 if total else 0
lines.append(f"_📌 前3類合計佔比{top3_pct:.1f}%_")
return "\n".join(lines)
def fmt_comparison(data, date_str):
if not data:
return "⚠️ *無法取得比較資料*\n\n請確認歷史資料已匯入。"
def _delta_str(a, b):
if not b:
return '`—`'
pct = (a - b) / b * 100
arrow = '' if pct >= 0 else ''
emoji = '🟢' if pct >= 0 else '🔴'
return f"{emoji} `{arrow}{abs(pct):.1f}%` (`NT$ {a - b:+,.0f}`)"
today = data.get('today', {})
lw = data.get('last_week', {})
lm = data.get('last_month', {})
r_t = today.get('revenue', 0)
r_lw = lw.get('revenue', 0)
r_lm = lm.get('revenue', 0)
lines = [
f"🔄 *{date_str} 業績同期比較*",
f"{'' * 30}",
"",
f"📅 *本日業績* `NT$ {r_t:,.0f}`",
"",
]
if r_lw:
lines.append(f"📌 *vs 上週同日*{lw['date']}")
lines.append(f" 上週:`NT$ {r_lw:,.0f}`")
lines.append(f" 差異:{_delta_str(r_t, r_lw)}")
lines.append("")
if r_lm:
lines.append(f"📌 *vs 上月同日*{lm['date']}")
lines.append(f" 上月:`NT$ {r_lm:,.0f}`")
lines.append(f" 差異:{_delta_str(r_t, r_lm)}")
lines.append("")
# 趨勢判斷
if r_lw and r_lm:
both_up = r_t >= r_lw and r_t >= r_lm
both_down = r_t < r_lw and r_t < r_lm
if both_up:
lines.append("✅ *整體趨勢*:本日業績優於上週及上月同期,表現亮眼!")
elif both_down:
lines.append("⚠️ *整體趨勢*:本日業績低於上週及上月同期,需關注原因。")
else:
lines.append("📊 *整體趨勢*:表現不一,建議深入分析商品結構。")
return "\n".join(lines)
def fmt_goal_status(status):
if not status:
return "⚠️ *無法取得目標資料*"
rev = status.get('today_rev', 0) or 0
dg = status.get('daily_goal', 0) or 0
mg = status.get('monthly_goal', 0) or 0
lines = [
f"🎯 *目標達成率* _({status.get('date','')})_",
f"{'' * 30}",
"",
]
lines.append(f"💰 *今日業績* `NT$ {rev:,.0f}`")
lines.append("")
if dg:
pct = status.get('daily_pct', 0) or 0
gap = dg - rev
bar_len = min(10, max(0, int(pct // 10)))
bar = '' * bar_len + '' * (10 - bar_len)
# 顏色 emoji
if pct >= 100:
status_emoji = '🏆'
status_text = f"*超標達成!* 🎉 超出 `NT$ {abs(gap):,.0f}`"
elif pct >= 80:
status_emoji = '🟢'
status_text = f"接近達標 還差 `NT$ {gap:,.0f}`"
elif pct >= 50:
status_emoji = '🟡'
status_text = f"進度落後 還差 `NT$ {gap:,.0f}`"
else:
status_emoji = '🔴'
status_text = f"需要加速 還差 `NT$ {gap:,.0f}`"
lines.append(f"📌 *日目標* `NT$ {dg:,.0f}`")
lines.append(f" `[{bar}]` *{pct:.1f}%* {status_emoji}")
lines.append(f" {status_text}")
lines.append("")
else:
lines.append("_⚙ 尚未設定日目標_")
lines.append("_用 `/goal 200000` 設定每日目標_")
lines.append("")
def _goal_bar(pct, goal, actual, label, emoji):
if not goal:
return None
gap = goal - actual
bar_len = min(10, max(0, int(pct // 10)))
bar = '' * bar_len + '' * (10 - bar_len)
suffix = f" 🎉 超標 `NT$ {abs(gap):,.0f}`" if gap <= 0 else f" 還差 `NT$ {gap:,.0f}`"
return [
f"{emoji} *{label}* `NT$ {goal:,.0f}` → 已達 `NT$ {actual:,.0f}`",
f" `[{bar}]` *{pct:.1f}%*{suffix}",
]
# 週目標P9
wg = status.get('weekly_goal', 0) or 0
if wg:
week_rows = _goal_bar(status.get('weekly_pct') or 0, wg,
status.get('week_rev', 0) or 0, '週目標', '📅')
auto_tag = ' _(自動推算)_' if status.get('weekly_auto') else ''
if week_rows:
week_rows[0] += auto_tag
lines += week_rows
lines.append("")
for rows in [
_goal_bar(status.get('monthly_pct') or 0, mg,
status.get('month_rev', 0) or 0, '月目標', '📅'),
_goal_bar(status.get('quarterly_pct') or 0, status.get('quarterly_goal', 0) or 0,
(status.get('year_rev', 0) or 0) / 4, '季目標', '📆'),
_goal_bar(status.get('half_pct') or 0, status.get('half_goal', 0) or 0,
(status.get('year_rev', 0) or 0) / 2, '半年目標', '🗓'),
_goal_bar(status.get('yearly_pct') or 0, status.get('yearly_goal', 0) or 0,
status.get('year_rev', 0) or 0, '年度目標', '🏁'),
]:
if rows:
lines += rows
lines.append("")
# ── 月目標倒計時 ─────────────────────────────────────────
if mg:
month_rev = status.get('month_rev', 0) or 0
days_elapsed = status.get('days_elapsed', 0) or 0
days_total = status.get('days_in_month', 30) or 30
days_remain = days_total - days_elapsed
if days_remain > 0 and month_rev < mg:
needed_daily = (mg - month_rev) / days_remain
gap_pct = (mg - month_rev) / mg * 100
lines.append(f"{'' * 26}")
lines.append(f"📆 *月目標倒計時*")
lines.append(f" 已過 {days_elapsed} 天 / 剩 {days_remain}")
lines.append(f" 還差 `NT$ {mg - month_rev:,.0f}`{gap_pct:.0f}%")
lines.append(f" ✅ 每日至少需達 `NT$ {needed_daily:,.0f}`")
if days_elapsed > 0:
actual_daily = month_rev / days_elapsed
diff = actual_daily - needed_daily
if diff >= 0:
lines.append(f" 📈 日均 `NT$ {actual_daily:,.0f}` — *超標 +`NT$ {diff:,.0f}`*")
else:
lines.append(f" 📉 日均 `NT$ {actual_daily:,.0f}` — 落後 `NT$ {abs(diff):,.0f}`")
elif days_remain > 0 and month_rev >= mg:
lines.append(f"🎉 *月目標已達成!* 超出 `NT$ {month_rev - mg:,.0f}`,剩 {days_remain} 天繼續衝!")
if not any([mg, status.get('quarterly_goal'), status.get('half_goal'), status.get('yearly_goal')]):
lines.append("_⚙ 尚未設定長期目標_")
lines.append("_點選 🎯 目標管理 → 設定各週期目標_")
return "\n".join(lines)
def _short_id(pid: str) -> str:
"""DDABGC-A900H854P-000 → A900H854P取中段較易讀"""
parts = str(pid).split('-')
return parts[1] if len(parts) >= 3 else str(pid)[:12]
def fmt_restock_forecast(items: list) -> str:
"""補貨預測報告格式化"""
if not items:
return "⚠️ *補貨預測*\n\n暫無足夠銷售資料(需 3 天以上紀錄)"
high = [i for i in items if i['urgency'] == 'HIGH']
med = [i for i in items if i['urgency'] == 'MED']
low = [i for i in items if i['urgency'] == 'LOW']
lines = [
"📦 *補貨預測報告*",
f"{'' * 26}",
f"_基於近 7 日銷售速度分析_",
"",
]
if high:
lines.append("🔴 *緊急補貨(高銷速 + 加速中)*")
for i in high[:6]:
accel_s = f"{i['accel']:.0f}%" if i['accel'] > 0 else f"{abs(i['accel']):.0f}%"
lines.append(
f" 🔥 *{i['name'][:22]}*\n"
f" 日均 {i['daily_vel']:.1f} 件 趨勢 {accel_s} 週業績 `NT$ {i['rev7']:,.0f}`"
)
lines.append("")
if med:
lines.append("🟡 *建議補貨(中速 or 快速加速)*")
for i in med[:5]:
accel_s = f"{i['accel']:.0f}%" if i['accel'] > 0 else f"{abs(i['accel']):.0f}%"
lines.append(f" ⚡ *{i['name'][:22]}* 日均 {i['daily_vel']:.1f}{accel_s}")
lines.append("")
if low:
lines.append(f"🟢 *持續追蹤({len(low)} 件)* 銷售平穩,暫無急迫性")
lines.append("")
lines.append("_💡 高速商品建議備貨 ≥ 14 日量;中速備貨 ≥ 7 日_")
return "\n".join(lines)
def fmt_category_detail(category: str, items: list, date_label: str = '') -> str:
"""分類業績鑽取格式化"""
if not items:
return f"⚠️ *{category}* 分類無細項資料"
total_rev = sum(i['revenue'] for i in items)
period = f" _{date_label}_" if date_label else ''
lines = [
f"🗂 *{category} 分類業績*{period}",
f"{'' * 26}",
f"{len(items)} 個子類 合計 `NT$ {total_rev:,.0f}`",
"",
]
medals = ['🥇','🥈','🥉','','','','','','','']
for i, item in enumerate(items[:10]):
pct = item['revenue'] / total_rev * 100 if total_rev else 0
bar_len = min(8, max(1, int(pct / 12.5)))
bar = '' * bar_len + '' * (8 - bar_len)
medal = medals[i] if i < 10 else str(i + 1)
lines.append(
f" {medal} *{item['cat']}*\n"
f" `{bar}` {pct:.1f}% `NT$ {item['revenue']:,.0f}` {item['products']}商品 {item['qty']}"
)
return "\n".join(lines)
def fmt_promo_comparison(data: dict, label: str = '') -> str:
"""促銷效果比較格式化"""
if not data or not data.get('promo'):
return "⚠️ *促銷比較*\n\n查無資料,請確認日期範圍"
promo = data['promo']
pre = data['pre']
rev_lift = data.get('rev_lift', 0)
ord_lift = data.get('ord_lift', 0)
lift_emoji = '📈' if rev_lift >= 0 else '📉'
lines = [
f"🎉 *促銷活動效益分析*",
f"{'' * 26}",
f"活動期間:`{promo['start']}` ~ `{promo['end']}`{promo['days']}天)",
f"對比前期:`{pre['start']}` ~ `{pre['end']}`",
"",
f"{'' * 26}",
f"💰 *業績比較*",
f" 活動期:`NT$ {promo['revenue']:,.0f}` 訂單 {promo['orders']}",
f" 前同期:`NT$ {pre['revenue']:,.0f}` 訂單 {pre['orders']}",
f" {lift_emoji} 業績成長 *{rev_lift:+.1f}%* 訂單成長 *{ord_lift:+.1f}%*",
"",
]
if promo.get('top_products'):
lines.append("🏆 *活動期間熱銷 TOP5*")
for i, p in enumerate(promo['top_products'][:5]):
medals = ['🥇','🥈','🥉','','']
lines.append(f" {medals[i]} {p['name'][:22]} `NT$ {p['revenue']:,.0f}`")
lines.append("")
# 效益評估
if rev_lift >= 20:
lines.append("✅ *效益優良!* 建議複製此促銷模式")
elif rev_lift >= 5:
lines.append("🟡 *效益普通* 可調整折扣力度或品項")
else:
lines.append("🔴 *效益不佳* 建議檢視促銷定價與商品組合")
return "\n".join(lines)
def track_competitor_price_changes(results: list) -> list:
"""追蹤 momo 競品價格變動,回傳降價警報清單"""
if not results:
return []
try:
import redis as _redis
r = _redis.Redis(host='localhost', port=6379, db=12, socket_connect_timeout=2)
r.ping()
changes = []
for item in results:
momo_id = str(item.get('momo_icode') or item.get('momo_id') or item.get('id', ''))
if not momo_id:
continue
curr_price = float(item.get('momo_price') or 0)
if curr_price <= 0:
continue
key = f"price_hist:{momo_id}"
prev_raw = r.get(key)
if prev_raw:
prev_price = float(prev_raw)
if prev_price > 0:
pct = (curr_price - prev_price) / prev_price * 100
if pct <= -5: # momo 降價 ≥5% → PChome 需注意
changes.append({
'name': item.get('momo_name', '')[:28],
'prev_price': prev_price,
'curr_price': curr_price,
'pct': round(pct, 1),
'momo_id': momo_id,
})
# 更新快照(保留 8 天)
r.setex(key, 8 * 86400, str(curr_price))
return changes
except Exception as _e:
sys_log.warning(f"[price_track] Redis 不可用:{_e}")
return []
def fmt_monthly(ms: dict) -> str:
"""月份業績格式化"""
if not ms.get('found'):
return (f"⚠️ *{ms.get('month','?')} 月份無業績資料*\n\n"
f"此月份資料尚未匯入,或超出資料範圍。\n"
f"_使用 /history 查看所有可用月份_")
month = ms['month']
rev = ms['revenue']
orders = ms['orders']
days = ms['days_with_data']
avg_d = rev / days if days else 0
avg_o = ms['avg_order']
margin = ms['gross_margin']
# 月進度 bar以最高日為基準
daily = ms.get('daily', [])
max_d_rev = max((d['revenue'] for d in daily), default=1)
WEEKDAYS_ZH = ['週一', '週二', '週三', '週四', '週五', '週六', '週日']
lines = [
f"📅 *{month} 月份業績報告*",
f"{'' * 30}",
"",
f"💰 *月業績* `NT$ {rev:,.0f}`",
f"📦 月訂單 `{orders:,}` 筆 | 有資料 `{days}` 天",
f"🛒 日均業績 `NT$ {avg_d:,.0f}`",
f"🛒 客單均價 `NT$ {avg_o:,.0f}`",
f"📈 整月毛利率 `{margin:.1f}%`",
"",
]
if daily:
lines.append(f"📊 *逐日業績*")
for i, d in enumerate(daily):
bar_len = max(1, int(d['revenue'] / max_d_rev * 8))
bar = '' * bar_len + '·' * (8 - bar_len)
# 漲跌
if i > 0:
prev = daily[i - 1]['revenue']
chg = (d['revenue'] - prev) / prev * 100 if prev else 0
chg_str = f" {'' if chg >= 0 else ''}{abs(chg):.0f}%"
else:
chg_str = ''
try:
from datetime import datetime as dt
d_obj = dt.strptime(d['date'].replace('/', '-'), '%Y-%m-%d')
wday = WEEKDAYS_ZH[d_obj.weekday()]
except Exception:
wday = ''
lines.append(
f" `{d['date']}` {wday} `{bar}` `NT$ {d['revenue']:>10,.0f}`{chg_str}"
)
lines.append("")
if ms.get('top_products'):
lines.append(f"🏆 *月熱銷 TOP10*")
total_rev = rev
for i, p in enumerate(ms['top_products'][:10]):
medal = MEDALS[i] if i < len(MEDALS) else f"{i+1}."
pid = p.get('id', '') or ''
sid = _short_id(pid)
link = _pchome_link(pid, p['name'], 22)
pct = p['revenue'] / total_rev * 100 if total_rev else 0
lines.append(f" {medal} {link}")
lines.append(f" 🆔 `{sid}` 💰 `NT$ {p['revenue']:,.0f}` 佔 {pct:.1f}% 📦{p.get('qty',0)}")
lines.append("")
if ms.get('top_vendors'):
lines.append(f"🏭 *月熱銷廠商 TOP10*")
for i, v in enumerate(ms['top_vendors'][:10]):
medal = MEDALS[i] if i < len(MEDALS) else f"{i+1}."
pct = v['revenue'] / rev * 100 if rev else 0
lines.append(f" {medal} {_esc(v['name'][:22])} `NT$ {v['revenue']:,.0f}` {pct:.1f}%")
return "\n".join(lines)
def fmt_strategy(items, date_str=''):
if not items:
return "⚠️ *暫無策略資料*\n\n請確認商品業績資料已匯入。"
GROUP_META = {
'加碼': ('🔥', '加碼爆量', '內外皆熱 → 立刻加大廣告預算、補庫存', '#FF5722'),
'機會': ('💡', '把握機會', '外部熱搜 → 快速提升曝光,搶奪市佔', '#2196F3'),
'收割': ('', '趁勢收割', '內部增長 → 鎖定高毛利,衝刺轉換率', '#9C27B0'),
'觀察': ('⚠️', '謹慎觀察', '內外皆疲 → 停止加碼,評估庫存去化', '#FF9800'),
'持穩': ('', '穩定經營', '表現平穩 → 維持現狀,小幅優化素材', '#4CAF50'),
}
ORDER = ['加碼', '機會', '收割', '觀察', '持穩']
groups: dict = {k: [] for k in ORDER}
for s in items:
key = s.get('strategy', '持穩')
if key in groups:
groups[key].append(s)
else:
groups['持穩'].append(s)
title = f"🧬 *商品策略矩陣*"
if date_str:
title += f" _({date_str})_"
lines = [title, ""]
# 計算各組商品數 & 業績小計
summary_parts = []
for k in ORDER:
if groups[k]:
meta = GROUP_META[k]
summary_parts.append(f"{meta[0]}{k}×{len(groups[k])}")
if summary_parts:
lines.append("_分佈" + " ".join(summary_parts) + "_")
lines.append("")
for key in ORDER:
group_items = groups[key]
if not group_items:
continue
meta = GROUP_META[key]
emoji, label, advice, _ = meta
group_rev = sum(s['revenue'] for s in group_items)
lines.append(f"{emoji} *{label}* _{advice}_")
lines.append(f"{len(group_items)} 件 | 業績合計 `NT$ {group_rev:,.0f}`")
for s in group_items:
pid = s.get('id', '') or ''
sid = _short_id(pid)
link = _pchome_link(pid, s['name'], 20)
rev = s['revenue']
growth = s.get('growth', 0)
if growth and growth != 0:
g_val = abs(growth) * 100
g_str = f"{'' if growth > 0 else ''}{g_val:.0f}%"
g_emoji = '📈' if growth > 0 else '📉'
else:
g_str = ''
g_emoji = ''
lines.append(f" {link}")
lines.append(f" 🆔 `{sid}` 💰 `NT$ {rev:,.0f}`{g_str} {g_emoji}")
lines.append("")
lines.append("_💡 策略:內部週環比 × 外部 Google 熱搜雙維度分析_")
return "\n".join(lines).rstrip()
# ── 簡報生成 ──────────────────────────────────────────────────
def _clean_ai_text(text: str) -> str:
"""清理 AI 輸出:移除 Markdown 語法、去除開場白"""
import re
# 去除 **bold**、*italic*、`code`
text = re.sub(r'\*{1,3}([^*\n]+)\*{1,3}', r'\1', text)
text = re.sub(r'`([^`\n]+)`', r'\1', text)
# 去除 ## 標題符號
text = re.sub(r'^#{1,4}\s*', '', text, flags=re.MULTILINE)
# 去除 AI 慣用開場白
text = re.sub(
r'^(好的[,。]?|以下是.*?[:]|針對.*?分析[如下如下]?[:]?|'
r'根據.*?資料[,]?|以下為.*?[:])\s*\n?',
'', text, flags=re.IGNORECASE
)
return text.strip()
def _scheduled_ppt_fast_fallback_enabled() -> bool:
return os.getenv("PPT_SCHEDULED_FAST_FALLBACK", "").strip().lower() in {"1", "true", "yes", "on"}
def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str:
"""
用 NIM DeepSeek 生成簡報 AI 分析文字
(批次任務用 NIM節省 Gemini 即時對話額度)
"""
is_monthly = '月報' in report_type
is_strategy = '策略' in report_type
is_competitor = '競品' in report_type
is_promo = '促銷' in report_type
is_vendor = '廠商' in report_type
is_period = any(k in report_type for k in ('quarterly', 'half_yearly', 'annual', 'ttm', '季報', '半年報', '年報'))
is_category = '品類' in report_type or 'category' in report_type
is_customer = '客戶' in report_type or 'customer' in report_type
is_forecast = '檔期前瞻' in report_type or 'forecast' in report_type
is_promo_cmp = '多活動' in report_type or 'promo_compare' in report_type
is_new_prod = '新品' in report_type or 'new_product' in report_type
is_market_intel = '市場情報' in report_type or 'market_intel' in report_type
is_price_elast = '價格彈性' in report_type or 'price_elasticity' in report_type
is_5forces = '五力' in report_type or 'competitor_v4' in report_type or '競業五力' in report_type
if _scheduled_ppt_fast_fallback_enabled() and (is_market_intel or is_price_elast):
return _ppt_fallback_insight(report_type or '簡報', prompt_data, '')
# ── 格式鐵律(所有 prompt 共用後綴)────────────────────────
FORMAT_RULES = (
"\n\n【輸出格式鐵律 — 絕對遵守】\n"
"1. 禁止使用任何 Markdown 語法:禁止 **粗體**、*斜體*、`程式碼`、## 標題\n"
"2. 段落標題用【】全形括號,例如:【整體競爭態勢】\n"
"3. 開頭直接進入分析內容,禁止「好的」「以下是」「針對您的資料」等 AI 慣用語\n"
"4. 每個建議條目以 ✅ 開頭\n"
"5. 繁體中文,語氣專業、精準、業績導向"
)
# ── 2026 台灣電商市場趨勢脈絡(所有 prompt 共用前綴知識)──────
# 任何 AI 分析必須以此知識為背景,不可違反實際市場趨勢。
MARKET_TREND_2026 = (
"\n\n【2026 年台灣電商市場知識基底(必須以此為分析背景)】\n"
"── 關鍵檔期與品類拉動幅度 ──\n"
" • 母親節5 月第 2 週):年度大促,美妝保養 +30~50%、母嬰 +20~35%\n"
" • 520 情人節:禮品/香氛/輕珠寶 +25~40%\n"
" • 618 購物節(年中最大):全品類 +30~50%,主打囤貨型商品(美妝禮盒、母嬰用品)\n"
" • 端午節:應景食品 +40~60%、家庭清潔/紙品 +15~25%\n"
" • 雙11年度最強全品類 +50~80%\n"
" • 雙12 / 年末:保健食品 +30~50%、年節禮品 +40~60%\n"
"── 2026 熱門賽道 ──\n"
" • 永續美妝(無毒/敏弱肌/天然成分)\n"
" • 母嬰高端化NT$2000+ 客單帶、日韓品牌、安全認證)\n"
" • 機能性食品(益生菌、葉黃素、銀髮保健)\n"
" • IP 聯名(角色聯名增加客單與話題)\n"
" • 男性保養(從工具型轉精緻型)\n"
" • 寵物經濟(鮮食、保健、玩具高端化)\n"
"── 平台競爭態勢 ──\n"
" • 蝦皮免運門檻低NT$99、直播帶貨強、年輕族群\n"
" • PChome3C/家電優勢、24h 到貨、會員忠誠度高\n"
" • 酷澎:火箭快送、選品精緻、高端客群\n"
" • momo生活百貨/美妝強、電視購物頻道整合、會員訂閱推力\n"
)
if is_monthly:
sys_instruction = (
"你身兼三職:(1) 資深電商策略顧問10 年 BCG / 麥肯錫零售諮詢經驗)"
"(2) momo 平台行銷總監(熟悉台灣電商平台流量分配、廣告投放 ROI、檔期節奏"
"(3) 品類採購負責人(精通美妝保養、母嬰、個人清潔等品類的供應鏈與選品邏輯)。\n"
"你的客戶是 momo 平台的 BU 主管與行銷團隊,他們會用你這份報告做下一步的"
"庫存決策、廣告預算分配、檔期商品規劃,因此必須給出可直接執行的決策建議,"
"而非空泛分析。所有判斷必須符合 2026 年台灣電商市場的實際趨勢與消費行為。\n\n"
"請根據以下月報業績數據與外部市場情報,輸出一份完整月度營運報告,結構嚴格如下:\n\n"
"【整體業績解讀】4-5句\n"
"引用月業績、訂單、毛利率、客單價四項指標,評估本月整體表現等級(優/良/普/弱),"
"明確點出最顯著亮點(如品類爆發、客單拉升)與最大警訊(如毛利壓縮、成長放緩);"
"與台灣電商市場月均表現比較定位(月成長 5~15% 為健康區間,<5% 偏弱、>20% 強勁);"
"若毛利率 <10% 必須點明「毛利偏低、需改善 mix 或議價」。\n\n"
"【市場趨勢脈絡】3-4句本段為新增的關鍵段\n"
"結合 2026 年台灣電商當下趨勢,至少觸及以下三項中兩項:\n"
" (a) 節慶檔期:當月與下月的關鍵檔期(母親節 5/2 週、520、618、雙11、雙12"
" 及其對品類拉動的歷史幅度(例如:母親節美妝品類業績通常拉抬 30~50%\n"
" (b) 消費行為趨勢永續美妝、母嬰高端化、IP 聯名、無毒/敏弱肌、機能性食品、"
" 銀髮保健、寵物經濟、男性保養等熱門賽道;點出哪個趨勢與本月業績共振或背離\n"
" (c) 平台競爭態勢與蝦皮、PChome、酷澎的相對位置特別是「免運門檻」「直播帶貨」"
" 「會員訂閱」的策略影響\n"
"本段要讓讀者看完知道「當下市場在發生什麼,我們站在哪裡」。\n\n"
"【品類結構深度解析】4-5句\n"
"TOP1~2 主力品類:成因(季節 / 檔期 / 商品力 / 廣告投放)+ 該品類在台灣電商整體中的"
"市場份額位階(例如:美妝在 momo 通常佔 18~22%,本月 X% 屬偏高/偏低);"
"成長最快或最具潛力的新興品類,建議是否加碼資源;"
"若 TOP1 品類佔比 >60% 必須點明「集中度過高、需分散風險」並具體建議下一個應扶植的品類;"
"若毛利率高的品類佔比偏低,建議調整品類 mix 提升結構毛利。\n\n"
"【熱銷商品洞察】3-4句\n"
"TOP3 熱銷商品的高業績成因(定價優勢 / 品牌信任 / 廣告推力 / 檔期效應 / 季節剛需);"
"說明這些商品對整體客單價與毛利的貢獻或壓力(高業績不等於高毛利);"
"識別「新進榜」商品的潛力(值得加碼)與「跌出榜」商品的衰退原因;"
"建議哪 1 款商品適合做次月主推 hero SKU。\n\n"
"【MCP 市場情報整合】3-4句\n"
"結合外部市場情報(節日日曆 / 季節情境 / 競品動態),說明當前電商環境對本月業績的"
"正面/負面影響指出下月必須卡位的外部機會具體到檔期與品類組合例如「618 主打"
"美妝禮盒套組,瞄準 NT$1500~3000 客單帶」)。\n\n"
"【行銷與銷售行動建議 — SMART 框架】\n"
"每段必須符合 SMARTSpecific具體商品/品類、Measurable量化目標 %/NT$)、"
"Achievable可行動、不空泛、Relevant與業績痛點直接相關、Time-bound時程\n\n"
"■ 本週立即執行3 條,以 ✅ 開頭):庫存補貨 / 廣告投放優化 / 定價或滿額門檻調整 / "
"下架低毛利長尾。每條須含「商品名/品類 + 量化目標 + 完成期限」。\n"
"■ 本月優化重點3 條,以 ✅ 開頭):品類 mix 調整 / 客單拉升組合 / 毛利改善議價 / "
"新進榜商品扶植。每條須含「方法 + 預期效益(毛利率 +X% / 客單 +NT$Y / 轉換率 +Z%)」。\n"
"■ 下月預備部署3 條,以 ✅ 開頭):檔期商品規劃 / 預售活動設計 / 廣告預算分配 / "
"競品阻擊。每條須結合下月關鍵檔期與市場趨勢脈絡,給「卡位時機 + 預估收益」。\n\n"
"【競爭定位與風險預警】3-4句\n"
"本月在 momo 平台同類賣家中的相對位置(領先 / 並列 / 落後);"
"最大三項潛在風險(品類過度集中 / 毛利持續下滑 / 競品價格戰 / 檔期商品庫存不足等);"
"對應的「立即啟動」防禦動作(例如:建立次月安全庫存閾值、與 TOP 3 廠商重新議價)。\n\n"
"要求:每段必須引用至少 2 個具體數字(業績/百分比/排名/客單),全文 900~1200 字,"
"語氣為資深顧問遞交給 BU 主管的決策報告,不要學術化,要落地。"
"禁止使用「可能」「也許」「建議考慮」等模糊用詞要明確「必須做X、目標Y、期限Z」。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 2400
elif is_promo:
sys_instruction = (
"你是資深電商活動行銷策略分析師,擁有 10 年以上台灣電商促銷活動策劃與效益評估實戰經驗,"
"精通促銷活動 ROI 分析、商品選品策略、消費者行為洞察,深度熟悉美妝、保健、母嬰等品類的"
"活動規劃模式。\n\n"
f"請針對以下{report_type}資料,輸出一份完整專業的促銷效益分析報告,結構如下:\n\n"
"【整體活動評估】3-4句\n"
"以業績成長率、訂單成長率、客單價變化、毛利率表現為核心,綜合評估本次活動效益等級(優良/良好/普通/偏弱/不佳),"
"點出最顯著的亮點或警訊並與業界平均促銷效益10~30%業績提升為正常區間)作比較。\n\n"
"【關鍵發現】4-5句\n"
"分析活動期間「新晉商品」的表現意義、客單價變化背後的消費行為訊號、"
"毛利率壓縮或提升的結構性原因,以及訂單與業績成長率的背離(若有)代表什麼。\n\n"
"【市場信號解讀】3-4句本段須結合 2026 當下市場趨勢)\n"
"(a) 檔期對位本次活動相對於台灣電商促銷節奏雙11/母親節/520/618/雙12/年慶)"
"的時機優劣,以及該檔期歷史拉抬幅度作為對標基準。\n"
"(b) 品類熱度當前消費者熱搜賽道永續美妝、敏弱肌、機能性食品、銀髮保健、IP 聯名、"
"男性保養、寵物經濟)與本次活動商品的契合度。\n"
"(c) 價格敏感度:本次客單帶相對該品類的市場常態(如美妝禮盒 NT$1500~3000、母嬰高端"
"NT$2000+以及與蝦皮、PChome 的相對位置判斷。\n\n"
"【方案A短期優化活動後1週3條每條以 ✅ 開頭)\n"
"針對本次活動結果的立即行動:庫存管理、廣告預算調整、回購引導、售後溝通。\n"
"每條含具體商品/品類名稱或量化目標。\n\n"
"【方案B中期強化下次活動3條每條以 ✅ 開頭)\n"
"針對下次促銷活動的優化方向:商品組合、滿額門檻設計、新晉商品扶植、"
"跨品類搭配促銷策略,含預期效益(轉換率↑/客單↑/毛利改善)。\n\n"
"【方案C長期結構改善季度層級3條每條以 ✅ 開頭)\n"
"從商業模式角度提升促銷健康度RFM 分群精準投放、忠誠訂閱制降低促銷依賴、"
"促銷效益基準線 KPI 建立、供應鏈協同提升毛利空間。\n\n"
"【風險預警】1-2句\n"
"指出最大潛在風險(毛利侵蝕/庫存積壓/價格形象破壞),提出防禦建議。\n\n"
"要求:每段必須引用至少一個具體數字,全文不超過 600 字,語氣如資深顧問報告。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 1400
elif is_strategy:
sys_instruction = (
"你身兼 (1) 資深電商策略分析師10 年 BCG / 麥肯錫零售諮詢經驗)"
"(2) 行銷主管(精通台灣電商 RFM 分群、廣告 ROI、檔期商品規劃\n"
"你的客戶是 momo BU 主管,會用本報告做下週的庫存與行銷預算決策。\n\n"
f"請針對以下{report_type}資料,輸出一份繁體中文專業策略報告,結構如下:\n\n"
"【業績解讀】3-4句總結核心業績表現量化點出最關鍵亮點與警訊"
"與台灣電商市場月均5~15% 為健康)作比較定位。\n\n"
"【市場趨勢脈絡】3-4句新增段\n"
"結合 2026 年台灣電商當下趨勢,至少觸及兩項:\n"
" (a) 當期關鍵檔期(母親節 / 520 / 618 / 雙11 / 雙12對品類拉動幅度\n"
" (b) 消費行為熱門賽道永續美妝、母嬰高端化、IP 聯名、敏弱肌、銀髮保健、男性保養\n"
" (c) 平台競爭:與蝦皮 / PChome / 酷澎的免運門檻、直播帶貨、會員訂閱策略對位\n\n"
"【策略矩陣分析】4-5句解讀加碼/機會/收割/觀察分佈,"
"點出哪類商品最值得加碼資源(廣告預算、首頁版位、滿額門檻),"
"成長動因(檔期 / 品牌力 / 廣告 / 搜尋詞排名)為何。\n\n"
"【行銷與銷售行動建議 — SMART 框架】\n"
"每條符合 Specific / Measurable / Achievable / Relevant / Time-bound\n"
"■ 立即執行2 條,✅ 開頭):庫存補貨 / 廣告投放 / 定價或滿額門檻\n"
"■ 本期強化2 條,✅ 開頭):品類 mix / 客單拉升組合 / 新進榜扶植\n"
"每條須含「商品名/品類 + 量化目標(毛利+X% / 客單+NT$Y / 轉換率+Z%+ 期限」。\n\n"
"【風險預警】2-3句指出 2~3 項潛在風險(集中度 / 毛利下滑 / 競品價格戰),"
"對應「立即啟動」防禦動作。\n\n"
"要求:每段引用至少 2 個具體數字,全文 600~800 字,禁用「可能/也許/建議考慮」。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 1400
elif is_5forces:
sys_instruction = (
"你是資深企業戰略顧問BCG / 麥肯錫零售諮詢經驗,精通 Porter 五力與競爭定位)。"
"已有 6 維度(商品力/價格力/行銷力/服務力/品牌力/財務力momo vs 競品的 0-10 分評分。\n\n"
f"請輸出{report_type}戰略整合報告,結構:\n\n"
"【整體競爭態勢】4-5 句)\n"
"解讀 momo 6 維度綜合評分 vs 競品;點出最大優勢力與最大劣勢力;"
"與業界整體格局比較定位(蝦皮/酷澎/PChome 多方夾擊下 momo 的位置)。\n\n"
"【優勢加碼策略】3-4 句)\n"
"針對最大優勢力(如行銷力 / 品牌力),建議如何持續加碼擴大領先;"
"識別第二、第三優勢力是否值得整合形成「組合拳」(如行銷力 + 服務力 = "
"「直播帶貨即時下單免運」)。\n\n"
"【劣勢補強或避戰】3-4 句)\n"
"針對最大劣勢力,二選一:\n"
" (a) 補強:投入資源補上短板(如服務力落後則升級物流)\n"
" (b) 避戰:放棄該戰場、強化其他差異化武器(如商品力落後則放棄全品類比拼,聚焦核心品類)\n"
"給出明確選擇與理由。\n\n"
"【六力整合 SMART 行動】\n"
"■ 立即執行3 條,✅ 開頭):每條對應 1 個力的具體投放/優化\n"
"■ 中期強化3 條,✅ 開頭):跨力組合戰術\n"
"■ 長期戰略2 條,✅ 開頭):護城河建立 / 生態系整合\n"
"每條須含「具體武器 + 量化目標 + 期限」。\n\n"
"【最大三大競爭風險】2-3 句)\n"
"(a) 競品大檔期入侵(蝦皮直播 / 酷澎補貼)\n"
"(b) 平台流量分配變化Google Search 演算法 / 社群推薦)\n"
"(c) 法規/政策變動(電商營業稅 / 跨境法規)\n\n"
"要求:每段引用具體數字(評分、業界基準),全文 1000~1300 字,"
"禁用模糊用詞,要明確戰術與時程。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 2400
elif is_price_elast:
sys_instruction = (
"你身兼 (1) 採購主管(精通選品、議價、定價策略)"
"(2) 商品 PM精通商品生命週期、客單價拉升策略\n"
"你的客戶是 momo 採購與 PM 團隊,會用本報告做新品定價、選品決策、"
"高/低毛利商品配置。\n\n"
f"請針對以下{report_type}(價位甜蜜點 + 各價位區間銷量分布)"
"輸出採購策略建議:\n\n"
"【價位甜蜜點解讀】3-4 句)\n"
"點明甜蜜點價位區間、佔總訂單百分比;判斷集中度健康度(>50% 過度集中 / "
"30-50% 主流明確 / <30% 分布健康);說明此甜蜜點對應的消費層級"
"(學生 / 上班族 / 家庭主婦 / 高端客群)。\n\n"
"【高/低價帶結構評估】3-4 句)\n"
"高價帶(>NT$2K業績佔比與業界基準健康 30-50%)比較;"
"若 <25% 點明「需引進高客單 SKU 提升結構毛利」;"
"若 >50% 點明「需注意中低價市場佈局」;"
"識別「斷層」價位區間SKU 數明顯偏少者,可能是補貨機會)。\n\n"
"【選品與定價策略 — SMART 框架】\n"
"■ 立即執行3 條,✅ 開頭):\n"
" ✅ 新品定價:對齊甜蜜點區間 ±10%(具體價位範圍)\n"
" ✅ 補強斷層:[斷層價位] 開發 N 款新 SKU\n"
" ✅ 高價試水:在甜蜜點上一級價位帶試銷 3 款高端品\n"
"■ 中期強化2 條,✅ 開頭):價格分層組合 / 滿額門檻設計\n"
"■ 長期佈局1 條,✅ 開頭):自有品牌進入高毛利價位帶\n"
"每條須含「商品名/品類 + 量化目標 + 期限」。\n\n"
"【最大風險】2-3 句)\n"
"(a) 過度依賴單一價位 → 競品價格戰時整體業績被打\n"
"(b) 高價帶不足 → 結構毛利偏低、無法承擔行銷成本\n"
"(c) 低價帶過多 → 拉低品牌形象\n\n"
"要求每段引用具體數字價位區間、SKU 數、業績佔比),"
"全文 700~900 字,禁用模糊用詞。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 1700
elif is_market_intel:
sys_instruction = (
"你身兼 (1) 行銷情報分析師(精通競品監控、消費趨勢、社群口碑分析)"
"(2) BU 主管(策略決策層級的市場敏感度)。\n"
"你的客戶是 momo CEO/BU 主管/行銷主管,會用本報告了解外部市場大局,"
"做下週的廣告投放、檔期備戰、競品阻擊決策。\n\n"
f"請針對以下{report_type}(外部資料彙整:節慶日曆、季節情境、電商新聞、"
"Google Trends、Dcard、YouTube、天氣、匯率輸出戰略洞察\n\n"
"【本週市場大事】3-4 句)\n"
"(1) 即將到來的關鍵檔期與其對 momo 業績的影響預估\n"
"(2) 當前最熱門的消費賽道(依 Google Trends + Dcard + YouTube 信號)\n"
"(3) 本週外部最大風險(如競品大檔期、不利天氣、匯率波動)。\n\n"
"【消費者情緒與口碑解讀】3-4 句)\n"
"Dcard 熱門討論主題反映什麼消費焦慮(價格 / 安全 / 認證 / 永續);"
"YouTube 爆紅商品的特徵(顏值 / 解決痛點 / 名人推薦 / IP 聯名);"
"建議 momo 在哪些品類加碼選品或行銷投入。\n\n"
"【競爭態勢與差異化】3-4 句)\n"
"蝦皮、PChome、酷澎、博客來等競品本週動態依電商新聞"
"momo 應該強化哪些差異化武器(會員訂閱 / 直播帶貨 / 富邦銀行折扣);"
"若競品有大檔期,給出阻擊策略(價格戰避戰 / 服務力差異 / 限時加碼)。\n\n"
"【行動建議 — SMART 框架】\n"
"■ 本週立即執行3 條,✅ 開頭):廣告投放調整 / 商品上架 / 競品比價\n"
"■ 下週預備3 條,✅ 開頭):檔期商品規劃 / 行銷檔期協作 / 庫存備援\n"
"■ 本月戰略2 條,✅ 開頭):消費賽道選品 / 行銷主題定位\n"
"每條必須 SMART具體商品/品類 + 量化目標 + 期限。\n\n"
"【最大三大外部風險】2-3 句)\n"
"(a) 政策法規變動(如電商營業稅、進口商品法規)\n"
"(b) 匯率波動(影響進口商品成本)\n"
"(c) 競品大檔期或新平台進入(如 Temu、酷澎激進補貼\n\n"
"要求每段引用具體外部信號Trend 關鍵字、Dcard 主題、新聞標題等),"
"全文 800~1100 字,禁用模糊用詞。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 2000
elif is_new_prod:
sys_instruction = (
"你身兼 (1) PM 商品經理(精通新品上架 / 商品生命週期 / SKU 健康度)"
"(2) 採購主管(精通選品、新廠商引進、新品扶植)。\n"
"你的客戶是 momo PM 與採購團隊,會用本報告做新品扶植加碼、"
"曇花一現新品下架、明星新品行銷加碼的決策。\n\n"
f"請針對以下{report_type}資料,輸出新品戰術洞察,結構嚴格如下:\n\n"
"【新品力評估】3-4 句)\n"
"引用新品數、新品業績、業績佔比,評估新品力等級(強勁 >8% / 穩健 3-8% / "
"偏弱 1-3% / 疲弱 <1%);與業界平均(健康電商 5-10%)比較定位;"
"若 <3% 必須點明「新品引進不足,將失去成長動能」並建議下季加碼新品開發。\n\n"
"【明星新品識別】3-4 句)\n"
"點名 TOP3 明星新品,分析高業績成因(檔期推力 / 品牌力 / 行銷投放 / "
"獨家代理);建議哪 1-2 款適合做次月主推 hero SKU"
"若 TOP1 新品業績 >NT$10 萬則建議升格為「常銷主力」加碼資源。\n\n"
"【品類分佈與機會】3-4 句)\n"
"新品依品類分佈是否健康(過度集中於單一品類?);"
"建議下季應加碼新品的品類(依 2026 趨勢:永續美妝 / 母嬰高端 / "
"機能性食品 / 男性保養 / 銀髮保健等);"
"識別「新品荒漠」品類(無新品進駐者),建議優先填補。\n\n"
"【新品扶植與淘汰建議 — SMART 框架】\n"
"■ 立即執行3 條,✅ 開頭):\n"
" ✅ 加碼:對 TOP3 新品(具體商品名)增加首頁版位/廣告預算 +X%"
"預期業績 +Y%期限YYYY/MM/DD\n"
" ✅ 觀察:對排名 11-30 名新品具體商品名2 週後回看,"
"若週業績 < NT$Z 則啟動下架評估\n"
" ✅ 數據追蹤:建立新品 KPI 儀表板(爬榜速度 / 客單 / 復購率),"
"每週自動更新\n"
"■ 中期強化2 條,✅ 開頭):開發新廠商 / 跨品類聯名 / 自有品牌 OEM\n"
"■ 長期佈局1 條,✅ 開頭):建立新品引進 SOP試銷 30 天 → "
"達標升常銷 / 不達標下架)\n\n"
"【最大風險與防禦】2-3 句)\n"
"(a) 新品試銷失敗率高 → 建議單一品類下架率 >50% 觸發採購復盤\n"
"(b) 新品搶食常銷市場 → 觀察常銷商品銷量是否被新品稀釋\n"
"(c) 過度依賴單品爆款 → 建議新品 TOP1 佔新品業績 <30% 為健康\n\n"
"要求:每段引用至少 2 個具體數字,全文 800~1000 字,禁用模糊用詞。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 1800
elif is_promo_cmp:
sys_instruction = (
"你是資深行銷主管10 年促銷活動策劃實戰經驗)。"
f"以下是多場促銷活動的 ROI 對比數據。請輸出{report_type}跨活動洞察:\n\n"
"【整體比較解讀】3-4 句)\n"
"點出 N 場活動中業績拉抬最高/最低、毛利最佳/最差、訂單拉抬最強的活動;"
"評估整體促銷組合健康度(是否過度依賴單一檔期)。\n\n"
"【勝出活動成功要素】3-4 句)\n"
"分析最高拉抬活動的成功因素(檔期 / 商品力 / 行銷投放 / 滿額設計);"
"判斷哪些要素可複製到下一場。\n\n"
"【失敗活動診斷】3-4 句)\n"
"點出拉抬偏低或負成長活動的問題(時機不對 / 對比期過旺 / 商品選錯 / "
"毛利侵蝕過深);給出具體改善方向。\n\n"
"【行動建議 — SMART 框架】\n"
"■ 立即執行3 條,✅ 開頭):複製成功要素 / 立即停損失敗格式\n"
"■ 中期強化2 條,✅ 開頭):建立活動 KPI 基準線 / RFM 精準投放\n"
"■ 長期佈局1 條,✅ 開頭):建立年度活動行事曆 + 自動化 ROI 追蹤\n\n"
"要求:每段引用具體活動名與數字,全文 700~900 字。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 1600
elif is_forecast:
sys_instruction = (
"你身兼 (1) BU 主管(決策檔期備戰策略)"
"(2) 行銷投放主管(廣告預算分配)(3) 採購(庫存補貨)。\n"
"你的客戶是 momo BU會用本報告做檔期前 14 天的庫存補貨、廣告投放、"
"競品阻擊、滿額門檻等戰術決策。所有判斷必須有量化依據與明確期限。\n\n"
f"請針對以下{report_type}資料,輸出檔期戰術前瞻報告,結構嚴格如下:\n\n"
"【檔期定位與機會評估】4-5 句)\n"
"引用本檔期歷史拉抬倍數lift_factor、預期業績、去年同檔期實績"
"評估本期成長 vs 衰退趨勢;點明:(a) 本檔期主推品類(依 2026 趨勢)"
"(b) 客單帶(如母親節美妝禮盒 NT$1500-3000(c) 競品檔期動態。\n\n"
"【準備窗口進度評估】3-4 句)\n"
"已過 X / Y 天的累積業績達成預期 N%;判斷是否達標、需加碼或減碼;"
"若進度落後 > 20% 點明「需立即啟動加速方案」並給出具體手段。\n\n"
"【庫存戰術建議】3-4 句)\n"
"基於 baseline 期 TOP 商品銷量 × lift_factor 計算預期銷量;"
"點名 3 款必補貨商品(含具體數量目標);"
"識別 2 款「滯銷風險」baseline 期低銷量但被列入檔期主推的);"
"建議安全庫存閾值(檔期 + 7 天緩衝)。\n\n"
"【廣告投放與滿額門檻】3-4 句)\n"
"建議廣告預算baseline 業績的 X%、目標 ROAS Y"
"鎖定族群(依 2026 賽道:永續美妝/母嬰高端/銀髮保健等);"
"滿額門檻設計(依預期客單 × 1.2~1.5 倍)。\n\n"
"【行動清單 — SMART 框架】\n"
"■ 檔期前 7 天3 條,✅ 開頭):補貨 / 廣告投放 / 競品價格巡檢\n"
"■ 檔期當日 + 3 天3 條,✅ 開頭):滿額活動 / 直播帶貨 / 即時補刀\n"
"■ 檔期後 7 天2 條,✅ 開頭):回購引導 / 庫存清貨 / 復盤學習\n"
"每條須含「商品/品類 + 量化目標(業績 +X% / 庫存 N 組 / 廣告 NT$Y+ 期限」。\n\n"
"【最大三大風險與防禦】2-3 句)\n"
"(a) 缺貨斷鏈 — 啟動次廠商備援\n"
"(b) 競品低價 — 滿額贈/品牌力差異化\n"
"(c) 廣告 ROAS 失控 — 中途調整素材或暫停 underperformer\n\n"
"要求:每段引用至少 2 個具體數字,全文 800~1100 字,禁用模糊用詞。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 2000
elif is_customer:
sys_instruction = (
"你是資深行銷主管10 年電商 RFM/CRM 實戰經驗)。"
"因本資料層無 user_idPII 法規限制),分析以訂單級為主:"
"訂單規模分群、消費星期分佈、商品復購率。\n\n"
f"請輸出{report_type}行銷洞察,結構:\n\n"
"【訂單規模解讀】3-4 句)\n"
"引用總訂單、總業績、平均客單,判斷市場定位(高/中/低客單);"
"高客單訂單佔比是否健康(業界 NT$5K+ 佔 5~15% 為合理);"
"若高客單 <5% 點明「客群偏低端,需推高客單組合」。\n\n"
"【消費熱點與時段】2-3 句)\n"
"識別最熱星期 vs 最冷星期業績差異,建議集中廣告/活動到熱門時段;"
"若消費過度集中在週末,建議週間推送提醒;反之亦然。\n\n"
"【商品復購信號】3-4 句)\n"
"TOP 復購商品的特徵(消耗品 / 季節剛需 / 訂閱型);"
"建議哪些商品適合做「自動訂閱」或「週期回購提醒」;"
"點名適合做組合銷售(搭配低客單商品提升 AOV\n\n"
"【行動建議 — SMART 框架】\n"
"■ 立即執行3 條,✅ 開頭):高客單組合 / 熱門時段廣告 / 復購提醒\n"
"■ 中期強化2 條,✅ 開頭):訂閱制設計 / 跨品類捆綁\n"
"■ 長期佈局1 條,✅ 開頭):建立會員系統取得 user_id 升級完整 RFM\n"
"每條須含「具體商品/品類 + 量化目標 + 期限」。\n\n"
"要求:每段引用具體數字,全文 600~800 字,禁用模糊用詞。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 1500
elif is_category:
sys_instruction = (
"你身兼 (1) 採購主管(精通選品、廠商議價、品類組合)"
"(2) PM 商品經理精通商品生命週期、新品爬榜、SKU 健康度)。\n"
"你的客戶是 momo 採購與 PM 團隊,會用本報告做品類選品、新廠商引進、"
"下架低毛利長尾、扶植新進榜商品的決策。\n\n"
f"請針對以下{report_type}資料,輸出品類深度分析報告,結構嚴格如下:\n\n"
"【品類整體解讀】4-5 句)\n"
"引用本品類 90 天業績、訂單、毛利率、SKU 數、廠商數,評估品類定位"
"(主力 / 成長 / 長尾);點出最關鍵亮點(高毛利商品爆發、新進榜潛力)"
"與最大警訊毛利下滑、SKU 過度集中、廠商斷供)。\n\n"
"【90 天趨勢分析】3-4 句)\n"
"解讀日業績曲線:高低點對應的檔期/季節因素;判斷品類處於上升 / 持平 / "
"下降趨勢;對比品類季節性(如美妝在母親節前 30 天通常 +30%)。\n\n"
"【子品類結構與機會】3-4 句)\n"
"前 3 大子品類佔比、是否健康分散;子品類間的 mix 健康度;"
"建議哪個子品類是下季度應加碼資源的(高毛利 + 成長中)。\n\n"
"【SKU 與廠商組合健康度】4-5 句)\n"
"TOP3 商品集中度(前 3 商品佔本品類業績 X%,是否過於依賴);"
"新進榜商品(🆕)的潛力評估:誰值得加碼資源、誰只是曇花一現;"
"TOP3 廠商議價空間:毛利偏低者、可爭取獨家代理者、可下架者各列名 1-2 家。\n\n"
"【行動建議 — SMART 框架】\n"
"■ 立即執行3 條,✅ 開頭):補貨 / 廣告投放 / 下架低毛利長尾\n"
"■ 中期強化3 條,✅ 開頭):新品扶植 / 廠商議價 / 子品類擴張\n"
"■ 長期佈局2 條,✅ 開頭):自有品牌 / 跨品類聯名\n"
"每條須含「具體商品名 + 量化目標 + 期限」。\n\n"
"【最大風險與防禦】2-3 句)\n"
"點出本品類 2~3 項風險(集中度過高 / 季節性過強 / 競品價格戰),對應防禦動作。\n\n"
"要求:每段引用至少 2 個具體數字(商品名/業績/排名),"
"全文 800~1000 字,禁用模糊用詞。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 1800
elif is_period:
sys_instruction = (
"你身兼三職:(1) 資深電商策略顧問10 年 BCG / 麥肯錫零售諮詢經驗)"
"(2) momo BU 主管(決策季度/半年/年度資源分配)"
"(3) CFO 觀點(看 P&L 結構、毛利歸因、預算 vs 實際)。\n"
"你的客戶是 momo 高層管理層,會用本報告做下季度/半年度/年度的"
"戰略校正、資源重分配、OKR 設定。所有判斷必須有量化依據。\n\n"
f"請針對以下{report_type}資料,輸出戰略級期間回顧報告,結構嚴格如下:\n\n"
"【期間整體解讀】4-5句\n"
"引用期間業績、訂單、毛利率、客單價,評估等級(卓越/穩健/普通/警訊);"
"與上期 / 去年同期分別作 △% 比較;指出最關鍵亮點與最大警訊;"
"與台灣電商市場同期表現比較定位(健康成長 5~15%、強勁 >20%、警訊 <0%)。\n\n"
"【市場趨勢與本期對位】3-4句\n"
"結合本期所跨檔期(依期間落點:母親節/520/618/雙11/雙12回顧檔期效益"
"對比歷史拉動幅度如雙11 +50~80%、618 +30~50%"
"點出本期是否充分捕捉市場紅利或錯失機會。\n\n"
"【月度走勢分析】3-4句\n"
"解讀月度業績曲線:高點月成因(檔期/活動/季節)、低點月成因;"
"識別連續 2 個月以上的趨勢(持續上升/下降/震盪);"
"QoQ / HoH / YoY 的成長動能差異。\n\n"
"【品類與商品結構洞察】3-4句\n"
"TOP3 主力品類佔比與健康度(前一品類 >60% 為集中度過高);"
"新進榜商品 vs 跌出榜商品的比例與業績規模;"
"毛利結構(高毛利品類 vs 低毛利品類)的 mix 健康度。\n\n"
"【行動建議 — 戰略級 SMART】\n"
"■ 下期立即啟動3 條,✅ 開頭,含期限):\n"
" 針對庫存補貨、廣告投放、定價調整、品類 mix 調整等 30 天內可見效的決策。\n"
"■ 下期戰略重點3 條,✅ 開頭,含 60-90 天目標):\n"
" 針對品類 mix、商品組合、廠商議價、會員活動等 1 季可改善的結構性議題。\n"
"■ 下下期預備佈局2 條,✅ 開頭,含 6-12 個月目標):\n"
" 針對年度大檔雙11/雙12/618、新品類進入、自有品牌、平台戰略等長期議題。\n"
"每條必須含「具體商品/品類 + 量化目標(業績 +X% / 毛利 +Y pp / 客單 +NT$Z+ 期限」。\n\n"
"【最大三大風險與防禦】2-3句\n"
"點出 3 項最大潛在風險(集中度 / 毛利下滑 / 競品價格戰 / 庫存積壓 / 廠商斷供 等),"
"對應「立即啟動」防禦動作(具體至:建立 N 天安全庫存 / 與 TOP3 簽 N 年協議)。\n\n"
"要求:每段引用至少 2 個具體數字,全文 1000~1300 字,"
"語氣為資深顧問遞交給 BU 主管/CEO 的戰略決策報告,禁用模糊用詞,要明確期限與量化。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 2600
elif is_vendor:
sys_instruction = (
"你身兼 (1) 資深採購主管10 年零售/電商採購實戰經驗,精通議價、選品、"
"獨家代理談判)(2) 供應鏈管理顧問(精通 Pareto 集中度風險、安全庫存、"
"雙源頭策略)。\n"
"你的客戶是 momo BU 採購主管,會用本報告做下季度的議價對象、新廠商扶植、"
"備援名單、毛利改善決策。\n\n"
f"請針對以下{report_type}資料,輸出採購策略視角的分析報告,結構嚴格如下:\n\n"
"【整體廠商結構解讀】4-5 句)\n"
"引用廠商總數、合計業績、合計毛利、平均毛利率,評估廠商組合健康度(健康/警訊);"
"點出最關鍵亮點TOP1 廠商業績/毛利、新進榜潛力廠商)與最大警訊"
"(集中度過高、毛利持續下滑、長尾過多);"
"與業界平均(健康電商前 20% 廠商佔 70~80% 業績為合理區間)作比較定位。\n\n"
"【集中度與供應風險評估】4-5 句)\n"
"(a) Pareto 80/20 分析:前 N 家廠商佔 80% 業績的具體比例,是否健康分散\n"
"(b) TOP3 廠商斷供風險:若 TOP1 廠商斷供,影響業績幾 %?是否有備援?\n"
"(c) 長尾廠商價值:後 50% 廠商是新晉/補充/淘汰候選?毛利是否優於 TOP\n"
"(d) 與上期比較廠商總數變化、新進榜🆕vs 跌出榜的數量\n\n"
"【議價優先順序與毛利改善】4-5 句)\n"
"(a) 第一線議價對象TOP3 中毛利最低的,明確點名 + 議價方向(量價折扣 / "
"獨家代理 / 行銷費用協同)\n"
"(b) 高毛利廠商扶植:毛利率 >15% 但業績佔比偏低的,建議加碼資源(首頁版位、"
"廣告預算)\n"
"(c) 低毛利長尾汰換:毛利 <5% 且業績後段,建議下架或重新議價\n\n"
"【行動建議 — SMART 框架(必須 SMART\n"
"■ 立即執行3 條,✅ 開頭):\n"
" ✅ 議價:對 [具體廠商名] 啟動 Q+1 議價,目標毛利 +X pp期限YYYY/MM/DD\n"
" ✅ 備援:為 TOP3 廠商建立備援名單(同品類至少 1 家替代廠商)\n"
" ✅ 汰換:[具體廠商名] 毛利 X%、業績 Y 萬,建議下季度下架或重新議約\n"
"■ 中期強化3 條,✅ 開頭):\n"
" ✅ 新廠商開發:針對 [具體品類] 開發 N 家新廠商,下季度預期業績貢獻 NT$X 萬\n"
" ✅ 獨家代理談判:[具體廠商] 爭取台灣電商獨家權,預期市佔 +X%\n"
" ✅ 廠商分級制度:建立 A/B/C 分級(依業績+毛利+穩定度),每季調整資源分配\n"
"■ 長期結構2 條,✅ 開頭):\n"
" ✅ 集中度目標12 個月內把前 5 家佔比從 X% 降至 Y%(風險分散)\n"
" ✅ 自有品牌OEM/ODM規劃 [具體品類] 自有品牌,毛利目標 30%+\n\n"
"【最大風險與防禦動作】2-3 句)\n"
"指出 2~3 項最大風險(單點供應斷鏈 / 競品挖角獨家廠商 / 進口匯率波動),"
"對應「立即啟動」防禦動作(具體至:建立 N 天安全庫存、與 TOP3 簽 N 年協議)。\n\n"
"要求:每段引用至少 2 個具體數字(廠商名 / 業績 / 毛利率 / 排名變化),"
"全文 800~1000 字,禁用「可能/也許/建議考慮」模糊用詞。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 1800
elif is_competitor:
sys_instruction = (
"你是資深電商競品策略分析師,專精美妝(開架/專櫃)、保健食品、母嬰用品,"
"具備 10 年以上台灣電商市場實戰經驗,深度熟悉 PChome、momo 競爭生態、"
"台灣消費者行為與美妝/保健品類購買決策模式。\n\n"
"═══════════════════════════════\n"
"角色設定(絕對不可違反)\n"
" 視角 = PChome 業績成長決策,不預設單一平台永遠正確\n"
" price_diff = MOMO售價 PChome售價\n"
" 正值 → PChome較便宜 / PChome價格優勢 → 可放大曝光、主推位置與商品頁賣點\n"
" 負值 → MOMO較便宜 / MOMO低價壓力 → 需檢查 PChome 售價、券、組合或毛利\n"
" 待補資料不可當成成功配對;必須明確列為資料品質風險\n"
"═══════════════════════════════\n\n"
f"請以 EwoooC 商品營運視角,針對以下{report_type}輸出一份專業競品分析報告:\n\n"
"【整體競爭態勢】3-4句\n"
"引用平均價差、比對成功件數、待補資料件數,指出 PChome 價格優勢、MOMO 低價壓力與資料覆蓋風險。\n\n"
"【PChome 價格優勢商品放大策略】4-5句\n"
"點名 PChome 較便宜的具體商品與品類,判斷可能原因(活動定價、組合包、會員回饋或清庫存),"
"提出 PChome 可放大的搜尋關鍵字、站內陳列、檔期素材與推薦理由。\n\n"
"【MOMO 低價壓力商品解決方案】4-5句\n"
"點名 MOMO 較便宜的商品,提出 PChome 可採取的售價檢查、折扣券、組合包、內容曝光或服務差異化做法,避免只用降價犧牲毛利。\n\n"
"【美妝/保健/母嬰品類專項洞察】3-4句\n"
"針對本期出現的具體商品,結合台灣市場趨勢深度分析:\n"
"美妝:成分透明化趨勢、敏感肌/無添加需求、社群口碑行銷;\n"
"保健:機能性訴求、族群細分(銀髮/運動/女性)、定期訂購轉換;\n"
"母嬰:安全認證標章、日韓品牌偏好、媽媽社群影響力。\n\n"
"【本期 TOP3 業績導向行動建議】3條每條以 ✅ 開頭)\n"
"每條包含:具體商品或品類 + 行動方向 + 預期業績效益(轉換率↑/客單價↑/市佔↑)。\n\n"
"要求:每段必須引用至少一個具體數字或商品名,不超過 500 字,語氣如資深顧問報告。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 1200
else:
# daily / weekly 走這條:精煉版顧問報告(時間範圍小,不需深度市場分析,但仍含趨勢脈絡)
sys_instruction = (
"你是資深電商營運顧問10 年台灣電商實戰經驗),擅長從短期業績抓出可執行的"
"戰術建議。客戶是 momo BU 主管,會用本報告做今日/本週的庫存與廣告調整。\n\n"
f"請針對以下{report_type}資料,輸出簡潔但專業的分析與行動建議,結構如下:\n\n"
"【整體業績解讀】2-3句\n"
"引用業績、訂單、毛利率、客單價,評估等級(優/良/普/弱),點出最關鍵的亮點與警訊。\n\n"
"【市場機會與風險】2-3句\n"
"結合當前檔期(母親節/520/618/雙11/雙12 等),說明本期業績所受影響"
"與下一個應卡位的時機;指出潛在風險(庫存/競品/毛利)。\n\n"
"【TOP3 立即行動建議】3 條,✅ 開頭)\n"
"每條符合 SMART具體商品/品類 + 量化目標(補貨 N 組/廣告 +X%/客單 +NT$Y+ 期限。\n\n"
"要求:每段引用具體數字,全文 350~450 字,禁用「可能/也許」模糊用詞。"
+ MARKET_TREND_2026
+ FORMAT_RULES
)
max_tokens = 900
def _call_gemini(prompt: str, tokens: int) -> str:
gemini_api_key = _gemini_fallback_api_key('openclaw_ppt_analysis')
if not gemini_api_key:
return ''
r = requests.post(
f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={gemini_api_key}",
headers={'Content-Type': 'application/json'},
json={
'contents': [{'parts': [{'text': prompt}]}],
'generationConfig': {'maxOutputTokens': tokens, 'temperature': 0.35},
},
timeout=40,
)
r.raise_for_status()
return (r.json().get('candidates', [{}])[0]
.get('content', {}).get('parts', [{}])[0]
.get('text', '').strip())
def _call_ollama(prompt: str, tokens: int) -> str:
if not _OLLAMA_AVAILABLE:
return ""
try:
# 簡報分析使用 qwen2.5-coder:7b (已升級 GCP) 或 hermes3
model = os.getenv('OPENCLAW_OLLAMA_MODEL', 'qwen2.5-coder:7b')
resp = OllamaService(model=model).generate(
prompt=prompt,
model=model,
temperature=0.3,
timeout=90,
options={'num_predict': tokens},
)
if not resp.success:
sys_log.warning(f"[PPT] Ollama cascade failed: {resp.error}")
return ""
return (resp.content or '').strip()
except Exception as e:
sys_log.warning(f"[PPT] Ollama error: {e}")
return ""
# ── Ollama firstGCP-A → GCP-B → 111 三主機級聯 ────────────
if _OLLAMA_AVAILABLE:
try:
raw = _call_ollama(f"{sys_instruction}\n\n--- 資料 ---\n{prompt_data}", max_tokens)
result_text = _clean_ai_text(raw)
if result_text and len(result_text) > 100:
if _LEARNING_ENABLED:
import threading as _thr
_thr.Thread(
target=store_insight,
kwargs={
'insight_type': report_type or 'analysis',
'content': result_text,
'period': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'),
},
daemon=True
).start()
return result_text
except Exception as e0:
sys_log.warning(f"[PPT] Ollama first path error: {e0}")
if not NVIDIA_API_KEY and not _gemini_fallback_allowed('openclaw_ppt_analysis'):
return 'AI 分析暫不可用,請確認 Ollama / API Key 設定)'
# ── NIM fallbackOllama 失敗後) ────────────
if NVIDIA_API_KEY:
try:
r = requests.post(
f'{NVIDIA_BASE_URL}/chat/completions',
headers={'Authorization': f'Bearer {NVIDIA_API_KEY}',
'Content-Type': 'application/json'},
json={
'model': CHAT_MODEL,
'messages': [
{'role': 'system', 'content': sys_instruction},
{'role': 'user', 'content': f'--- 資料 ---\n{prompt_data}'},
],
'max_tokens': max_tokens,
'temperature': 0.35,
},
timeout=25,
)
r.raise_for_status()
result_text = _clean_ai_text(r.json()['choices'][0]['message']['content'])
# ── PPT 分析自動入知識庫 ──────────────────────────────
if _LEARNING_ENABLED and result_text:
import threading as _thr
_thr.Thread(
target=store_insight,
kwargs={
'insight_type': report_type or 'analysis',
'content': result_text,
'period': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'),
},
daemon=True
).start()
return result_text
except Exception as e:
sys_log.warning(f"[PPT] NIM unavailable after Ollama ({type(e).__name__}), fallback Gemini")
# ── Gemini final fallback ─────────────────────────────────
if _gemini_fallback_allowed('openclaw_ppt_analysis'):
try:
raw = _call_gemini(f"{sys_instruction}\n\n--- 資料 ---\n{prompt_data}", max_tokens)
result_text = _clean_ai_text(raw)
if _LEARNING_ENABLED and result_text:
import threading as _thr
_thr.Thread(
target=store_insight,
kwargs={
'insight_type': report_type or 'analysis',
'content': result_text,
'period': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'),
},
daemon=True
).start()
return result_text
except Exception as e2:
sys_log.error(f"[PPT] Gemini fallback error: {e2}")
# ── Ollama (GCP/111) Final Fallback ───────────────────────
if _OLLAMA_AVAILABLE:
try:
sys_log.info("[PPT] Trying local/GCP Ollama as final fallback")
raw = _call_ollama(f"{sys_instruction}\n\n--- 資料 ---\n{prompt_data}", max_tokens)
result_text = _clean_ai_text(raw)
if result_text and len(result_text) > 100:
if _LEARNING_ENABLED:
import threading as _thr
_thr.Thread(
target=store_insight,
kwargs={
'insight_type': report_type or 'analysis',
'content': result_text,
'period': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'),
},
daemon=True
).start()
return result_text
except Exception as e3:
sys_log.error(f"[PPT] Ollama final fallback error: {e3}")
return 'AI 分析暫時無法使用,請稍後重試)'
def _ppt_needs_fallback(ai_text: str) -> bool:
if not ai_text:
return True
weak_markers = ('AI 分析暫', '暫無 AI 分析', '生成中', '請稍後重試')
return any(marker in ai_text for marker in weak_markers) or len(ai_text.strip()) < 40
def _ppt_fallback_insight(report_type: str, data_summary: str, mcp_text: str = '') -> str:
"""模型額度或外部 API 失敗時,仍用既有 DB 摘要補足簡報內容。"""
lines = [
f"{report_type}重點摘要】",
data_summary[:1200],
]
if mcp_text:
lines.extend(["", "【外部情報補充】", mcp_text[:600]])
lines.extend([
"",
"【建議動作】",
"1. 先檢查高業績商品與高毛利商品是否重疊,優先補強可放大的品項。",
"2. 針對低毛利或異常波動商品,回看價格、促銷與庫存狀態。",
"3. 若本頁顯示資料不足,請先確認該日期/月份業績資料是否已完成匯入。",
])
return "\n".join(lines)
class PPTDataInsufficientError(Exception):
"""ADR-019 Phase 1請求的 PPT 期間缺資料。raise 前已主動 inline-keyboard 詢問用戶。"""
pass
def _ppt_check_data_freshness(report_type: str, chat_id: int, reply_to: int,
requested_yr: int = None, requested_mo: int = None,
requested_date: str = None) -> None:
"""ADR-019 Phase 1PPT 生成前 probe 資料新鮮度。資料缺口時主動 inline keyboard
詢問用戶(自訂日期 / 改看最新 / 取消),並 raise PPTDataInsufficientError 中止流程。
daily / strategy(單日):傳 requested_date='YYYY/MM/DD'
monthly傳 requested_yr, requested_mo
"""
latest = latest_date() # 'YYYY-MM-DD' 或 None
if not latest:
return # DB 連不到,原樣繼續,由 caller 處理
try:
latest_dt = datetime.strptime(latest.replace('/', '-'), '%Y-%m-%d')
except (ValueError, AttributeError):
return
if report_type in ('monthly', '月報') and requested_yr and requested_mo:
has_data = (latest_dt.year > requested_yr or
(latest_dt.year == requested_yr and latest_dt.month >= requested_mo))
if not has_data:
prev_yr, prev_mo = latest_dt.year, latest_dt.month
kb = [
_row((f'📊 改看 {prev_yr}/{prev_mo:02d} 月報',
f'cmd:ppt:monthly {prev_yr}/{prev_mo:02d}')),
_row(('📅 自訂月份', 'await:date_ppt_monthly')),
_row(('❌ 取消', 'menu:reports')),
]
send_message(
chat_id,
f"⚠️ *{requested_yr}/{requested_mo:02d}* 尚無業績資料\n"
f"目前最新資料截至:`{latest}`\n\n請選擇:",
reply_to,
keyboard=kb,
parse_mode='Markdown',
)
raise PPTDataInsufficientError(f'monthly {requested_yr}/{requested_mo:02d}')
elif report_type in ('daily', '日報', 'strategy', '策略', 'weekly', '週報') and requested_date:
sales = query_sales(requested_date)
if not sales.get('found'):
kb = [
_row((f'📊 改看 {latest} 日報', f'cmd:ppt:{report_type or "daily"} {latest}')),
_row(('📅 自訂日期', 'await:date_ppt_daily')),
_row(('❌ 取消', 'menu:reports')),
]
send_message(
chat_id,
f"⚠️ *{requested_date}* 尚無業績資料\n"
f"目前最新資料:`{latest}`\n\n請選擇:",
reply_to,
keyboard=kb,
parse_mode='Markdown',
)
raise PPTDataInsufficientError(f'{report_type} {requested_date}')
def _normalize_ppt_parameters(parameters: dict) -> str:
"""將快取參數轉成穩定字串。
自動把當前 ppt_generator 的模板版本字串 (tpl_ver) 併入 parameters
任何 report_type 模板升級bump TEMPLATE_VERSIONS→ 舊快取全部 miss → 重新生成。
"""
try:
from services.ppt_generator import get_template_version
report_type = parameters.get('report_type') if isinstance(parameters, dict) else None
if report_type:
params_with_ver = dict(parameters)
params_with_ver['tpl_ver'] = get_template_version(report_type)
return json.dumps(params_with_ver, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
return json.dumps(parameters, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
except Exception:
try:
return json.dumps(parameters, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
except Exception:
return json.dumps({}, ensure_ascii=False)
def _invalidate_ppt_cache(report_type: str = None) -> int:
"""強制失效指定 report_type或全部的 PPT 快取。
將 expires_at 設為 NOW() 1 分鐘,下次查詢即 miss → 用最新模板重生。
回傳被影響的列數。
用法(管理員):
_invalidate_ppt_cache('monthly') # 只清月報
_invalidate_ppt_cache() # 清全部
"""
from database.manager import DatabaseManager
from database.ppt_reports import PPTReport
now = datetime.now(TAIPEI_TZ).replace(tzinfo=None)
expired_at = now - timedelta(minutes=1)
session = DatabaseManager().get_session()
try:
q = session.query(PPTReport).filter(
or_(PPTReport.expires_at.is_(None), PPTReport.expires_at > now)
)
if report_type:
q = q.filter(PPTReport.report_type == report_type)
affected = q.update({PPTReport.expires_at: expired_at}, synchronize_session=False)
session.commit()
sys_log.info(f"[PPT] 強制失效快取 type={report_type or 'ALL'} affected={affected}")
return int(affected or 0)
except Exception as e:
session.rollback()
sys_log.error(f"[PPT] 失效快取失敗: {e}")
return 0
finally:
session.close()
def cleanup_expired_ppt_cache(days_old: int = 7, dry_run: bool = True) -> dict:
"""清理已過期且超過 days_old 天的 PPT 檔案 + DB 紀錄。
執行條件expires_at < NOW() days_old 天 → 刪檔 + 刪 row。
保留 days_old 天緩衝避免誤刪剛失效的紀錄。
安全預設:**dry_run=True**critic 修正 HIGH-2避免靜默實刪
呼叫方必須明確傳 dry_run=False 才會真正刪除。
launchd / cron 排程務必顯式傳:
cleanup_expired_ppt_cache(days_old=7, dry_run=False)
回傳統計欄位語意critic Info-1
dry_run=True 時deleted_files / deleted_rows / freed_bytes 為「將刪除」預估值
dry_run=False 時:上述為「已實刪」實際值
errors 為過程中發生例外的列表id + 錯誤訊息)
"""
from database.manager import DatabaseManager
from database.ppt_reports import PPTReport
cutoff = datetime.now(TAIPEI_TZ).replace(tzinfo=None) - timedelta(days=days_old)
stat = {'deleted_files': 0, 'deleted_rows': 0,
'freed_bytes': 0, 'errors': [], 'dry_run': dry_run}
session = DatabaseManager().get_session()
try:
rows = (session.query(PPTReport)
.filter(PPTReport.expires_at.isnot(None),
PPTReport.expires_at < cutoff)
.all())
for r in rows:
try:
if r.file_path and os.path.exists(r.file_path):
size = os.path.getsize(r.file_path)
if not dry_run:
os.unlink(r.file_path)
stat['deleted_files'] += 1
stat['freed_bytes'] += size
if not dry_run:
session.delete(r)
stat['deleted_rows'] += 1
except Exception as e:
stat['errors'].append(f"id={r.id}: {e}")
if not dry_run:
session.commit()
sys_log.info(f"[PPT cleanup] {stat}")
return stat
except Exception as e:
session.rollback()
sys_log.error(f"[PPT cleanup] 失敗: {e}")
stat['errors'].append(str(e))
return stat
finally:
session.close()
def _load_cached_ppt_entry(report_type: str, parameters: dict):
"""回傳尚未過期的快取紀錄與解析後 payload若無則回傳 (None, {})."""
from database.manager import DatabaseManager
from database.ppt_reports import PPTReport
now = datetime.now(TAIPEI_TZ).replace(tzinfo=None)
params = _normalize_ppt_parameters(parameters)
session = DatabaseManager().get_session()
try:
cached = (
session.query(PPTReport)
.filter(
PPTReport.report_type == report_type,
PPTReport.parameters == params,
or_(PPTReport.expires_at.is_(None), PPTReport.expires_at > now),
)
.order_by(PPTReport.generated_at.desc())
.first()
)
if not cached:
return None, {}
cached_payload = {}
if cached.cached_data:
try:
cached_payload = json.loads(cached.cached_data)
if not isinstance(cached_payload, dict):
cached_payload = {}
except Exception as e:
sys_log.warning(f"[PPT] cached_data 解析失敗: {e}")
cached_payload = {}
return cached, cached_payload
except Exception as e:
session.rollback()
sys_log.warning(f"[PPT] 讀取快取失敗:{e}")
finally:
session.close()
return None, {}
def _load_cached_ppt_path(report_type: str, parameters: dict) -> str | None:
"""嘗試回傳尚未過期且仍存在的快取檔案。"""
path, _ = _load_cached_ppt_path_and_analysis(report_type, parameters)
if path:
return path
return None
def _load_cached_ppt_analysis(report_type: str, parameters: dict) -> str | None:
"""回傳快取中的 AI 分析文字(可直接寫入簡報)。"""
_, cached_analysis = _load_cached_ppt_path_and_analysis(report_type, parameters)
if not cached_analysis or not isinstance(cached_analysis, str):
return None
return cached_analysis.strip() if cached_analysis else None
def _load_cached_ppt_path_and_analysis(report_type: str, parameters: dict):
"""回傳快取檔案路徑與 AI 分析文字。"""
cached, payload = _load_cached_ppt_entry(report_type, parameters)
cached_path = (
cached.file_path if cached and cached.file_path and os.path.exists(cached.file_path) else None
)
cached_analysis = payload.get('analysis') if isinstance(payload, dict) else None
return cached_path, cached_analysis
def _store_ppt_cache(report_type: str, parameters: dict, file_path: str, cached_payload: dict) -> str | None:
"""儲存 PPT 快取資料,回傳 file_path。"""
from database.manager import DatabaseManager
from database.ppt_reports import PPTReport
from services.ppt_generator import get_template_version
now = datetime.now(TAIPEI_TZ)
params = _normalize_ppt_parameters(parameters)
expire_at = now + timedelta(hours=PPT_CACHE_TTL_HOURS)
payload = dict(cached_payload or {})
payload.setdefault('report_type', report_type)
payload.setdefault('parameters', parameters)
payload.setdefault('template_version', get_template_version(report_type))
payload.setdefault('stored_at', now.strftime('%Y-%m-%d %H:%M:%S'))
payload.setdefault('file_path', file_path)
try:
file_size = os.path.getsize(file_path)
payload.setdefault('file_size', file_size)
except OSError:
file_size = None
payload.setdefault('file_size', None)
try:
cached_data = json.dumps(payload, ensure_ascii=False, default=str)
except Exception:
cached_data = json.dumps({'error': 'cached_data serialization failed'}, ensure_ascii=False)
session = DatabaseManager().get_session()
try:
cached = (
session.query(PPTReport)
.filter(PPTReport.report_type == report_type, PPTReport.parameters == params)
.order_by(PPTReport.generated_at.desc())
.first()
)
if cached:
cached.file_path = file_path
cached.file_size = file_size
cached.generated_at = now.replace(tzinfo=None)
cached.expires_at = expire_at.replace(tzinfo=None)
cached.cached_data = cached_data
else:
session.add(PPTReport(
report_type=report_type,
parameters=params,
file_path=file_path,
file_size=file_size,
generated_at=now.replace(tzinfo=None),
expires_at=expire_at.replace(tzinfo=None),
cached_data=cached_data,
))
session.commit()
return file_path
except Exception as e:
session.rollback()
sys_log.warning(f"[PPT] 快取寫入失敗:{e}")
return None
finally:
session.close()
def _is_cached_ppt_file(file_path: str) -> bool:
"""判斷傳入檔案是否已被快取,避免傳送後刪除。"""
if not file_path:
return False
from database.manager import DatabaseManager
from database.ppt_reports import PPTReport
now = datetime.now(TAIPEI_TZ)
now = now.replace(tzinfo=None)
session = DatabaseManager().get_session()
try:
cached = (
session.query(PPTReport)
.filter(PPTReport.file_path == file_path)
.filter(or_(PPTReport.expires_at.is_(None), PPTReport.expires_at > now))
.first()
)
return cached is not None
except Exception:
return False
finally:
session.close()
def _fetch_mcp_context() -> str:
"""MCP 失敗時回空字串,避免阻塞。"""
try:
return build_mcp_context('', [])
except Exception:
return ''
def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str,
_reply_to: int = None) -> str:
"""依 sub_type 生成對應 pptx回傳檔案路徑。
ADR-019 Phase 1在生成前 probe 資料新鮮度,缺資料時 raise
PPTDataInsufficientError已主動詢問用戶由 _ppt_background 靜默吞掉。
"""
try:
from services.ppt_generator import (
generate_daily_ppt, generate_weekly_ppt,
generate_monthly_ppt, generate_strategy_ppt,
generate_competitor_ppt, generate_promo_ppt,
generate_vendor_ppt, generate_period_review_ppt,
generate_category_deep_ppt, generate_customer_analytics_ppt,
generate_forecast_pre_event_ppt, generate_promo_compare_ppt,
generate_new_product_ppt, generate_market_intel_weekly_ppt,
generate_price_elasticity_ppt, generate_competitor_v4_ppt,
check_pptx_available
)
except ImportError:
raise RuntimeError("ppt_generator 模組不可用,請確認 python-pptx 已安裝")
if not check_pptx_available():
raise RuntimeError("python-pptx 未安裝請執行pip install python-pptx")
now = datetime.now(TAIPEI_TZ)
if sub_type in ('daily', '日報'):
if sub_arg and re.fullmatch(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', sub_arg):
date_str = normalize_date(sub_arg)
else:
date_str = latest_date() or now.strftime('%Y/%m/%d')
_ppt_check_data_freshness('daily', _chat_id, _reply_to, requested_date=date_str)
params = {'report_type': 'daily', 'date': date_str}
cached, cached_ai = _load_cached_ppt_path_and_analysis('daily', params)
if cached:
return cached
mcp_text = ''
if not cached_ai:
mcp_text = _fetch_mcp_context()
sales = query_sales(date_str)
top_products = query_top_products(date_str, 10)
top_vendors = query_top_vendors(date_str, 5)
weekly = query_weekly_trend()
data_summary = (
f"日期:{date_str}\n"
f"業績NT$ {float(sales.get('revenue', 0)):,.0f} | "
f"訂單:{sales.get('orders', '-')}筆 | 毛利率:{sales.get('gross_margin', 0):.1f}%\n"
f"熱銷商品:" + " / ".join(
f"{p['name']}(NT${p['revenue']:,.0f})" for p in top_products[:5]) + "\n"
f"外部情報:{mcp_text[:500]}"
)
ai_text = cached_ai or _ppt_ai_analysis(data_summary, '日報')
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight('日報', data_summary, mcp_text)
db_data = {
'sales': sales, 'top_products': top_products,
'top_vendors': top_vendors, 'weekly': weekly, 'mcp': mcp_text,
}
ppt_path = generate_daily_ppt(date_str, db_data, ai_text)
_store_ppt_cache('daily', params, ppt_path, {
'report_type': 'daily',
'parameters': params,
'data_summary': data_summary,
'analysis': ai_text,
'source_data': db_data,
'mcp': mcp_text,
})
return ppt_path
elif sub_type in ('weekly', '週報'):
end_str = latest_date() or now.strftime('%Y/%m/%d')
_ppt_check_data_freshness('weekly', _chat_id, _reply_to, requested_date=end_str)
params = {'report_type': 'weekly'}
cached, cached_ai = _load_cached_ppt_path_and_analysis('weekly', params)
if cached:
return cached
mcp_text = ''
if not cached_ai:
mcp_text = _fetch_mcp_context()
weekly = query_weekly_trend()
top_products = query_top_products(target, 10)
top_vendors = query_top_vendors(target, 10)
strat = analyze_product_strategy(target, 10)
data_summary = (
f"週期:{now.strftime('%Y/%m/%d')} 週報\n"
f"週業績NT$ {sum(w['revenue'] for w in weekly):,.0f}\n"
f"逐日:" + " | ".join(f"{w['date']}(NT${w['revenue']:,.0f})" for w in weekly) + "\n"
f"熱銷:" + " / ".join(f"{p['name']}(NT${p['revenue']:,.0f})" for p in top_products[:5]) + "\n"
f"外部情報:{mcp_text[:500]}"
)
ai_text = cached_ai or _ppt_ai_analysis(data_summary, '週報')
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight('週報', data_summary, mcp_text)
db_data = {
'weekly': weekly, 'top_products': top_products,
'top_vendors': top_vendors, 'strategy': strat, 'mcp': mcp_text,
}
ppt_path = generate_weekly_ppt(db_data, ai_text)
_store_ppt_cache('weekly', params, ppt_path, {
'report_type': 'weekly',
'parameters': params,
'data_summary': data_summary,
'analysis': ai_text,
'source_data': db_data,
'mcp': mcp_text,
})
return ppt_path
elif sub_type in ('monthly', '月報'):
if sub_arg:
parts = sub_arg.replace('-', '/').split('/')
yr, mo = int(parts[0]), int(parts[1]) if len(parts) >= 2 else now.month
else:
yr, mo = now.year, now.month
_ppt_check_data_freshness('monthly', _chat_id, _reply_to,
requested_yr=yr, requested_mo=mo)
params = {'report_type': 'monthly', 'month': f'{yr}/{mo:02d}'}
cached, cached_ai = _load_cached_ppt_path_and_analysis('monthly', params)
if cached:
return cached
mcp_text = ''
if not cached_ai:
mcp_text = _fetch_mcp_context()
ms = query_monthly_summary(yr, mo)
top_cats = query_category_monthly(yr, mo, lim=8)
ms['top_categories'] = top_cats
# 月對月 / 年對年 比較資料(市場專業標準必備)
prev_yr_mo = (yr if mo > 1 else yr - 1)
prev_mo_no = (mo - 1 if mo > 1 else 12)
try:
prev_mo_data = query_monthly_summary(prev_yr_mo, prev_mo_no)
except Exception:
prev_mo_data = {'found': False}
try:
prev_yr_data = query_monthly_summary(yr - 1, mo)
except Exception:
prev_yr_data = {'found': False}
ms['prev_month'] = prev_mo_data if prev_mo_data.get('found') else None
ms['prev_year'] = prev_yr_data if prev_yr_data.get('found') else None
aov = ms.get('avg_order', ms.get('revenue', 0) / ms.get('orders', 1) if ms.get('orders') else 0)
top5_products = ms.get('top_products', [])[:5]
top5_cats = top_cats[:5]
cat_total = sum(float(c.get('revenue', 0)) for c in top5_cats)
cat_breakdown = '\n'.join(
f" - {c.get('cat','')}: NT${float(c.get('revenue',0)):,.0f}"
f"({float(c.get('revenue',0))/cat_total*100:.0f}%)" if cat_total else f" - {c.get('cat','')}:NT${float(c.get('revenue',0)):,.0f}"
for c in top5_cats
)
prod_breakdown = '\n'.join(
f" {i+1}. {p.get('name','')[:30]} — NT${float(p.get('revenue',0)):,.0f}"
for i, p in enumerate(top5_products)
)
data_summary = (
f"【月份】{yr}/{mo:02d}\n"
f"【月業績】NT${ms.get('revenue', 0):,.0f}\n"
f"【月訂單】{ms.get('orders', 0):,}\n"
f"【毛利率】{ms.get('gross_margin', 0):.1f}%\n"
f"【平均客單價】NT${aov:,.0f}\n\n"
f"【品類業績分佈TOP5\n{cat_breakdown}\n\n"
f"【熱銷商品 TOP5】\n{prod_breakdown}\n\n"
f"【MCP 外部市場情報】\n{mcp_text[:600] if mcp_text else '(無外部情報)'}"
)
ai_text = cached_ai or _ppt_ai_analysis(data_summary, '月報')
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight('月報', data_summary, mcp_text)
db_data = {'monthly': ms, 'mcp': mcp_text}
ppt_path = generate_monthly_ppt(yr, mo, db_data, ai_text)
_store_ppt_cache('monthly', params, ppt_path, {
'report_type': 'monthly',
'parameters': params,
'data_summary': data_summary,
'analysis': ai_text,
'source_data': db_data,
'mcp': mcp_text,
})
return ppt_path
elif sub_type in ('strategy', '策略'):
period_label = '日報'
range_dates = re.findall(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', sub_arg or '')
if len(range_dates) >= 2:
start_str = normalize_date(range_dates[0])
end_str = normalize_date(range_dates[1])
date_str = f'{start_str}~{end_str}'
try:
start_dt = datetime.strptime(start_str.replace('/', '-'), '%Y-%m-%d')
end_dt = datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d')
if start_dt.year == end_dt.year and start_dt.month == end_dt.month and start_dt.day == 1:
period_label = f'{end_dt.year}/{end_dt.month:02d} 月策略(截至 {end_dt.month:02d}/{end_dt.day:02d}'
else:
period_label = f'{start_str}~{end_str} 策略'
except Exception:
period_label = f'{start_str}~{end_str} 策略'
elif sub_arg and re.fullmatch(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', sub_arg):
date_str = normalize_date(sub_arg)
start_str, end_str = date_str, date_str
period_label = f'{date_str} 日策略'
elif sub_arg in ('weekly', 'week', '', '週報'):
end_str = latest_date() or now.strftime('%Y/%m/%d')
start_str = (datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d')
- timedelta(days=6)).strftime('%Y/%m/%d')
date_str = f'{start_str}~{end_str}'
period_label = '週策略近7日'
elif sub_arg in ('monthly', 'month', '', '月報') or (
sub_arg and re.match(r'\d{4}[/-]\d{1,2}$', sub_arg)):
m_parts = sub_arg.replace('-', '/').split('/') if sub_arg and '/' in sub_arg else []
if len(m_parts) == 2:
yr_s, mo_s = int(m_parts[0]), int(m_parts[1])
else:
yr_s, mo_s = now.year, now.month
import calendar
last_day = calendar.monthrange(yr_s, mo_s)[1]
start_str = f'{yr_s}/{mo_s:02d}/01'
end_str = f'{yr_s}/{mo_s:02d}/{last_day:02d}'
date_str = f'{yr_s}/{mo_s:02d}'
period_label = f'{yr_s}/{mo_s:02d} 月策略'
elif sub_arg in ('quarterly', 'quarter', 'q', '', '季報'):
end_str = latest_date() or now.strftime('%Y/%m/%d')
start_str = (datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d')
- timedelta(days=89)).strftime('%Y/%m/%d')
date_str = f'{start_str}~{end_str}'
period_label = '季策略近90日'
elif sub_arg in ('half', 'h1', 'h2', '半年', '半年報'):
end_str = latest_date() or now.strftime('%Y/%m/%d')
start_str = (datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d')
- timedelta(days=179)).strftime('%Y/%m/%d')
date_str = f'{start_str}~{end_str}'
period_label = '半年策略近180日'
elif sub_arg in ('yearly', 'year', 'annual', '', '年報'):
end_str = latest_date() or now.strftime('%Y/%m/%d')
start_str = (datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d')
- timedelta(days=364)).strftime('%Y/%m/%d')
date_str = f'{start_str}~{end_str}'
period_label = '年度策略近365日'
else:
date_str = latest_date() or now.strftime('%Y/%m/%d')
start_str, end_str = date_str, date_str
period_label = f'{date_str} 日策略'
_ppt_check_data_freshness('strategy', _chat_id, _reply_to, requested_date=end_str)
params = {'report_type': 'strategy', 'start': start_str, 'end': end_str, 'label': period_label}
cached, cached_ai = _load_cached_ppt_path_and_analysis('strategy', params)
if cached:
return cached
mcp_text = ''
if not cached_ai:
mcp_text = _fetch_mcp_context()
if start_str == end_str:
sales = query_sales(date_str)
top_products = query_top_products(date_str, 15)
strat = analyze_product_strategy(date_str, 20)
else:
rng = query_date_range(start_str, end_str)
sales = {'revenue': rng.get('revenue', 0),
'orders': rng.get('orders', 0),
'gross_margin': rng.get('gross_margin', 0),
'avg_order': rng.get('avg_order', 0)}
top_products = query_top_products_range(start_str, end_str, 15)
strat = _analyze_strategy_range(start_str, end_str, top_products)
from collections import Counter
strat_cnt = Counter(s['strategy'] for s in strat)
strat_detail = "\n".join(
f" [{k}×{v}件] " + " / ".join(
f"{s['name']}(NT${s['revenue']:,.0f})"
for s in strat if s['strategy'] == k)[:3]
for k, v in strat_cnt.most_common()
)
data_summary = (
f"分析週期:{period_label}\n"
f"業績NT$ {float(sales.get('revenue', 0)):,.0f} | "
f"訂單:{sales.get('orders', 0)}筆 | "
f"毛利率:{sales.get('gross_margin', 0):.1f}% | "
f"客單價NT$ {float(sales.get('avg_order', 0)):,.0f}\n\n"
f"策略矩陣分佈:\n{strat_detail}\n\n"
f"TOP5商品" + " / ".join(
f"{p['name']}(NT${p['revenue']:,.0f})" for p in top_products[:5]) + "\n\n"
f"外部市場信號:{mcp_text[:600]}"
)
ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'策略簡報({period_label}')
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight(f'策略簡報({period_label}', data_summary, mcp_text)
db_data = {
'sales': sales, 'top_products': top_products,
'strategy': strat, 'mcp': mcp_text,
'period_label': period_label,
}
ppt_path = generate_strategy_ppt(date_str, db_data, ai_text)
_store_ppt_cache('strategy', params, ppt_path, {
'report_type': 'strategy',
'parameters': params,
'data_summary': data_summary,
'analysis': ai_text,
'source_data': db_data,
'mcp': mcp_text,
})
return ppt_path
elif sub_type in ('competitor', '競品', 'compare'):
if not _PCHOME_AVAILABLE:
raise RuntimeError("PChome 比價模組不可用")
if sub_arg in ('weekly', 'week', ''):
end_d = datetime.strptime(
(latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d')
start_d = end_d - timedelta(days=6)
period_label = '週比較近7日'
date_str_for_query = start_d.strftime('%Y/%m/%d')
elif sub_arg in ('monthly', 'month', ''):
end_d = datetime.strptime(
(latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d')
start_d = end_d.replace(day=1)
period_label = f'{end_d.year}/{end_d.month:02d} 月比較'
date_str_for_query = start_d.strftime('%Y/%m/%d')
elif sub_arg in ('quarterly', 'quarter', 'q', ''):
end_d = datetime.strptime(
(latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d')
start_d = end_d - timedelta(days=89)
period_label = '季比較近90日'
date_str_for_query = start_d.strftime('%Y/%m/%d')
elif sub_arg in ('half', '半年'):
end_d = datetime.strptime(
(latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d')
start_d = end_d - timedelta(days=179)
period_label = '半年比較近180日'
date_str_for_query = start_d.strftime('%Y/%m/%d')
elif sub_arg in ('yearly', 'year', ''):
end_d = datetime.strptime(
(latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d')
start_d = end_d - timedelta(days=364)
period_label = '年度比較近365日'
date_str_for_query = start_d.strftime('%Y/%m/%d')
elif sub_arg and re.match(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', sub_arg):
d_str = normalize_date(sub_arg)
start_d = end_d = datetime.strptime(d_str.replace('/', '-'), '%Y-%m-%d')
period_label = f'{d_str} 日比較'
date_str_for_query = d_str
else:
yd = (now - timedelta(days=1)).strftime('%Y/%m/%d')
start_d = end_d = datetime.strptime(yd.replace('/', '-'), '%Y-%m-%d')
period_label = f'{yd} 日比較'
date_str_for_query = yd
params = {
'report_type': 'competitor',
'start': start_d.strftime('%Y/%m/%d'),
'end': end_d.strftime('%Y/%m/%d'),
'label': period_label,
}
cached, cached_ai = _load_cached_ppt_path_and_analysis('competitor', params)
if cached:
return cached
mcp_text_c = ''
if not cached_ai:
mcp_text_c = _fetch_mcp_context()
from services.competitor_intel_repository import (
fetch_competitor_comparison_results,
fetch_competitor_review_queue,
summarize_review_decision_envelopes,
)
competitor_engine = _db()
results = fetch_competitor_comparison_results(
competitor_engine,
start_date=start_d.strftime('%Y-%m-%d'),
end_date=end_d.strftime('%Y-%m-%d'),
limit=30,
)
review_queue = fetch_competitor_review_queue(competitor_engine, limit=5)
review_decision_brief = summarize_review_decision_envelopes(review_queue, limit=5)
found_c = [r for r in results if r.get('found')]
pchome_low_price_c = [r for r in found_c if r.get('price_diff', 0) > 10]
momo_low_price_c = [r for r in found_c if r.get('price_diff', 0) < -10]
unit_comparable_c = [
r for r in results
if not r.get('found') and r.get('match_status') in ('unit_comparable', 'refresh_unit_comparable')
]
not_found_c = [r for r in results if not r.get('found') and r not in unit_comparable_c]
avg_diff_c = (sum(r.get('price_diff_pct', 0) for r in found_c) / len(found_c)
if found_c else 0)
data_summary = (
f"【可信資料源=指定期間 competitor_price_history即時報表才用 competitor_pricesMOMO vs PChome】\n"
f"分析週期:{period_label}\n"
f"掃描商品:{len(results)} 件 | 高信心比對:{len(found_c)} 件 | 需單位價比較:{len(unit_comparable_c)} 件 | 待補身份/價格:{len(not_found_c)}\n"
f"PChome 價格優勢PChome 比 MOMO 便宜):{len(pchome_low_price_c)} 件 | MOMO 低價壓力MOMO 比 PChome 便宜):{len(momo_low_price_c)}\n"
f"平均價差:{avg_diff_c:+.1f}%(正值=PChome價格優勢負值=MOMO低價壓力\n\n"
f"PChome 價格優勢 TOP3可放大曝光" + " / ".join(
f"{r['momo_name'][:15]}PChome便宜NT${abs(r['price_diff']):,.0f}"
for r in pchome_low_price_c[:3]) + "\n"
f"MOMO 低價壓力 TOP3需檢查售價與組合" + " / ".join(
f"{r['momo_name'][:15]}MOMO便宜NT${abs(r['price_diff']):,.0f}"
for r in momo_low_price_c[:3]) + "\n"
f"單位價覆核樣本:" + " / ".join(
f"{r['momo_name'][:12]}{(r.get('unit_comparison') or {}).get('summary') or '候選價需換算'}"
for r in unit_comparable_c[:3]) + "\n"
f"待補資料樣本:" + " / ".join(
f"{r['momo_name'][:12]}{r.get('match_status', 'no_valid_match')}"
for r in not_found_c[:3]) + "\n\n"
f"覆核決策信封HITL不可自動寫正式價差\n{review_decision_brief.get('text')}\n\n"
f"外部情報:{mcp_text_c[:400]}"
)
ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'競品比較簡報({period_label}')
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight(f'競品比較簡報({period_label}', data_summary, mcp_text_c)
db_data = {
'results': results,
'period_label': period_label,
'review_queue': review_queue,
'review_decision_brief': review_decision_brief,
'mcp': mcp_text_c,
}
ppt_path = generate_competitor_ppt(period_label, db_data, ai_text)
_store_ppt_cache('competitor', params, ppt_path, {
'report_type': 'competitor',
'parameters': params,
'data_summary': data_summary,
'analysis': ai_text,
'source_data': db_data,
'mcp': mcp_text_c,
})
return ppt_path
elif sub_type in ('promo', '促銷'):
m_p = re.findall(r'\d{4}[/\-]\d{1,2}[/\-]\d{1,2}', sub_arg)
if len(m_p) < 2:
raise ValueError(f"促銷簡報需要日期範圍例如promo 2026/04/01-2026/04/07")
start_s_p = normalize_date(m_p[0])
end_s_p = normalize_date(m_p[1])
promo_label_p = f'{start_s_p}~{end_s_p}'
params = {'report_type': 'promo', 'start': start_s_p, 'end': end_s_p, 'label': promo_label_p}
cached, cached_ai = _load_cached_ppt_path_and_analysis('promo', params)
if cached:
return cached
mcp_text = ''
if not cached_ai:
mcp_text = _fetch_mcp_context()
data_p = query_promo_comparison(start_s_p, end_s_p)
if not data_p:
raise ValueError("查無促銷期間資料")
pd = data_p.get('promo', {})
prev = data_p.get('pre', {})
tops_str = ' / '.join(
f"{p['name'][:12]}NT${p['revenue']:,.0f}"
for p in (pd.get('top_products') or [])[:3]
)
data_summary_p = (
f"促銷活動期間:{start_s_p} ~ {end_s_p}{pd.get('days',0)}天)\n"
f"對比前期:{prev.get('start','')} ~ {prev.get('end','')}\n\n"
f"【活動期業績】NT$ {pd.get('revenue',0):,.0f} 訂單 {pd.get('orders',0):,}"
f"毛利率 {pd.get('margin',0):.1f}%\n"
f"【對比期業績】NT$ {prev.get('revenue',0):,.0f} 訂單 {prev.get('orders',0):,}"
f"毛利率 {prev.get('margin',0):.1f}%\n"
f"業績成長:{data_p.get('rev_lift',0):+.1f}% 訂單成長:{data_p.get('ord_lift',0):+.1f}%\n\n"
f"活動期熱銷 TOP3{tops_str or '(無資料)'}\n"
f"外部市場情報:{mcp_text[:300]}"
)
ai_text_p = cached_ai or _ppt_ai_analysis(data_summary_p, f'促銷效益分析({promo_label_p}')
if not cached_ai and _ppt_needs_fallback(ai_text_p):
ai_text_p = _ppt_fallback_insight(f'促銷效益分析({promo_label_p}', data_summary_p, mcp_text)
ppt_path = generate_promo_ppt(promo_label_p, data_p, ai_text_p)
_store_ppt_cache('promo', params, ppt_path, {
'report_type': 'promo',
'parameters': params,
'data_summary': data_summary_p,
'analysis': ai_text_p,
'source_data': data_p,
'mcp': mcp_text,
})
return ppt_path
elif sub_type in ('vendor', '廠商'):
# /ppt vendor [YYYY/MM] 指定月份廠商報告
# /ppt vendor [YYYY/MM/DD-YYYY/MM/DD] 自訂期間
if sub_arg and re.match(r'\d{4}[/-]\d{1,2}$', sub_arg):
yr, mo = [int(x) for x in sub_arg.replace('-', '/').split('/')]
import calendar as _cal
last_day = _cal.monthrange(yr, mo)[1]
start_str = f"{yr}/{mo:02d}/01"
end_str = f"{yr}/{mo:02d}/{last_day:02d}"
period_lbl = f"{yr}/{mo:02d}"
# 上期
prev_yr = yr if mo > 1 else yr - 1
prev_mo = mo - 1 if mo > 1 else 12
prev_last = _cal.monthrange(prev_yr, prev_mo)[1]
prev_start = f"{prev_yr}/{prev_mo:02d}/01"
prev_end = f"{prev_yr}/{prev_mo:02d}/{prev_last:02d}"
elif sub_arg and '-' in sub_arg and len(sub_arg) > 15:
parts = sub_arg.split('-')
start_str = normalize_date(parts[0])
end_str = normalize_date(parts[1])
period_lbl = f"{start_str} ~ {end_str}"
yr, mo = (int(start_str.split('/')[0]), int(start_str.split('/')[1]))
# 上期 = 同等天數往前推
from datetime import datetime as _dt
from datetime import timedelta as _td
s = _dt.strptime(start_str.replace('/', '-'), '%Y-%m-%d').date()
e = _dt.strptime(end_str.replace('/', '-'), '%Y-%m-%d').date()
days = (e - s).days + 1
prev_end_d = s - _td(days=1)
prev_start_d = prev_end_d - _td(days=days - 1)
prev_start = prev_start_d.strftime('%Y/%m/%d')
prev_end = prev_end_d.strftime('%Y/%m/%d')
else:
# 預設:當月
yr = now.year
mo = now.month
import calendar as _cal
last_day = _cal.monthrange(yr, mo)[1]
start_str = f"{yr}/{mo:02d}/01"
end_str = f"{yr}/{mo:02d}/{last_day:02d}"
period_lbl = f"{yr}/{mo:02d}"
prev_yr = yr if mo > 1 else yr - 1
prev_mo = mo - 1 if mo > 1 else 12
prev_last = _cal.monthrange(prev_yr, prev_mo)[1]
prev_start = f"{prev_yr}/{prev_mo:02d}/01"
prev_end = f"{prev_yr}/{prev_mo:02d}/{prev_last:02d}"
params = {'report_type': 'vendor', 'period': period_lbl}
cached, cached_ai = _load_cached_ppt_path_and_analysis('vendor', params)
if cached:
return cached
mcp_text = ''
if not cached_ai:
mcp_text = _fetch_mcp_context()
curr = query_vendor_summary(start_str, end_str, lim=30)
prev = query_vendor_summary(prev_start, prev_end, lim=30)
curr['prev_period'] = prev.get('vendor_ranking', [])
curr['period_label'] = period_lbl
# data summary 給 AI
top5 = curr.get('vendor_ranking', [])[:5]
top5_str = '\n'.join(
f" {i+1}. {v.get('name','')[:30]} — NT${v.get('sales',0):,.0f}"
f" | 毛利 {v.get('margin',0):.1f}%"
for i, v in enumerate(top5)
)
kpis = curr.get('kpis', {})
data_summary = (
f"【期間】{period_lbl}\n"
f"【廠商總數】{kpis.get('vendor_count', 0)}\n"
f"【合計業績】NT${kpis.get('total_sales', 0):,.0f}\n"
f"【合計毛利】NT${kpis.get('total_profit', 0):,.0f}\n"
f"【平均毛利率】{kpis.get('avg_margin', 0):.1f}%\n\n"
f"【TOP 5 廠商】\n{top5_str}\n\n"
f"【上期同等期間業績】NT${prev.get('kpis', {}).get('total_sales', 0):,.0f}"
f"{prev.get('kpis', {}).get('vendor_count', 0)} 家)\n\n"
f"【MCP 外部市場情報】\n{mcp_text[:500] if mcp_text else '(無外部情報)'}"
)
ai_text = cached_ai or _ppt_ai_analysis(data_summary, '廠商業績報告')
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight('廠商業績報告', data_summary, mcp_text)
ppt_path = generate_vendor_ppt(yr, mo, curr, ai_text)
_store_ppt_cache('vendor', params, ppt_path, {
'report_type': 'vendor',
'parameters': params,
'data_summary': data_summary,
'analysis': ai_text,
'source_data': curr,
'mcp': mcp_text,
})
return ppt_path
elif sub_type in ('competitor_v4', 'competitor5', '五力', '競業五力'):
# /ppt competitor_v4 [PChome|蝦皮|酷澎]
comp_name = sub_arg.strip() if sub_arg else 'PChome'
period_label = '近 30 天'
params = {'report_type': 'competitor_v4', 'competitor': comp_name}
cached, cached_ai = _load_cached_ppt_path_and_analysis('competitor_v4', params)
if cached:
return cached
c5_data = query_competitor_5forces(competitor=comp_name, period=period_label)
# 組 data summary
forces = c5_data.get('forces', {})
score_lines = []
for k, label in [('product_power', '商品力'), ('price_power', '價格力'),
('marketing_power', '行銷力'), ('service_power', '服務力'),
('brand_power', '品牌力'), ('financial_power', '財務力')]:
f = forces.get(k, {})
score_lines.append(
f" {label}: momo {f.get('momo', 0):.1f} / "
f"{comp_name} {f.get('competitor', 0):.1f} "
f"(差 {f.get('momo', 0) - f.get('competitor', 0):+.1f})"
)
data_summary = (
f"【競品】{comp_name}\n"
f"【期間】{period_label}\n\n"
f"【六力評分】\n" + '\n'.join(score_lines) + "\n\n"
f"【綜合評分】\n"
f" momo {sum(forces[k].get('momo', 0) for k in forces) / 6:.2f}/10 vs "
f"{comp_name} {sum(forces[k].get('competitor', 0) for k in forces) / 6:.2f}/10"
)
ai_text = cached_ai or _ppt_ai_analysis(data_summary, '競業五力分析')
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight('競業五力', data_summary, '')
ppt_path = generate_competitor_v4_ppt(period_label, c5_data, ai_text)
_store_ppt_cache('competitor_v4', params, ppt_path, {
'report_type': 'competitor_v4', 'parameters': params,
'data_summary': data_summary, 'analysis': ai_text,
'source_data': c5_data, 'mcp': '',
})
return ppt_path
elif sub_type in ('price_elasticity', 'price', '價格彈性', '甜蜜點'):
# /ppt price_elasticity 全平台 90 天
# /ppt price_elasticity 美妝保養 單品類
# /ppt price_elasticity 美妝保養 30 自訂天數
cat = None
days = 90
if sub_arg:
parts = sub_arg.strip().split()
if parts:
# 第一個參數可能是品類,最後一個若為純數字當天數
if parts[-1].isdigit():
days = int(parts[-1])
parts = parts[:-1]
if parts:
cat = ' '.join(parts)
params = {'report_type': 'price_elasticity',
'category': cat or 'all', 'days': days}
cached, cached_ai = _load_cached_ppt_path_and_analysis('price_elasticity', params)
if cached:
return cached
pe_data = query_price_elasticity(category=cat, days=days)
if not pe_data.get('found'):
raise RuntimeError(f'品類 "{cat or "全平台"}"{days} 天無資料')
sweet = pe_data.get('sweet_spot', {})
bucket_str = '\n'.join(
f" {b.get('range', '')}: {b.get('sku_count', 0)} SKU / "
f"{b.get('total_orders', 0):,} 訂單 / NT${b.get('total_revenue', 0):,.0f}"
for b in pe_data.get('buckets', [])
)
data_summary = (
f"【品類】{pe_data.get('category', '全平台')}\n"
f"【期間】近 {days}\n"
f"【SKU 數】{pe_data.get('sku_count', 0)}\n"
f"【總訂單】{pe_data.get('total_orders', 0):,}\n\n"
f"【各價位桶分布】\n{bucket_str}\n\n"
f"【價格甜蜜點】{sweet.get('range', '')} "
f"(佔 {sweet.get('ratio', 0):.1f}% 訂單,"
f"平均售價 NT${sweet.get('avg_price', 0):,.0f}"
)
ai_text = cached_ai or _ppt_ai_analysis(data_summary, '價格彈性報告')
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight('價格彈性', data_summary, '')
ppt_path = generate_price_elasticity_ppt(pe_data, ai_text)
_store_ppt_cache('price_elasticity', params, ppt_path, {
'report_type': 'price_elasticity', 'parameters': params,
'data_summary': data_summary, 'analysis': ai_text,
'source_data': pe_data, 'mcp': '',
})
return ppt_path
elif sub_type in ('market_intel', 'intel', '市場情報', '情報週報'):
# /ppt market_intel 本週市場情報
from datetime import datetime as _dt, timedelta as _td
if sub_arg and '起一週' in sub_arg:
week_label = sub_arg.strip()
else:
today = now.date() if hasattr(now, 'date') else now
# 對齊週一作為週起點
week_start = today - _td(days=today.weekday())
week_label = f"{week_start.strftime('%Y/%m/%d')} 起一週"
params = {'report_type': 'market_intel', 'week': week_label}
cached, cached_ai = _load_cached_ppt_path_and_analysis('market_intel', params)
if cached:
return cached
# 抓 mcp_collector 各 API容錯失敗段落填預設文字
from services.mcp_collector_service import mcp_collector
from services.mcp_context_service import (
get_ecommerce_news, get_taiwan_trends, get_dcard_trends,
get_youtube_trending, get_taiwan_weather, get_twbank_exchange_rates,
)
def _safe(fn, default='(本次擷取失敗或無資料)'):
try:
r = fn()
return r.strip() if r and r.strip() else default
except Exception as e:
sys_log.warning(f"[market_intel] {fn.__name__} fail: {e}")
return default
sections = {
'holiday': mcp_collector.get_holiday_context(),
'seasonal': mcp_collector.get_seasonal_context(),
'ecommerce_news': _safe(get_ecommerce_news),
'google_trends': _safe(get_taiwan_trends),
'dcard': _safe(get_dcard_trends),
'youtube': _safe(get_youtube_trending),
'weather': _safe(get_taiwan_weather),
'exchange': _safe(get_twbank_exchange_rates),
}
# 組 data summary 給 AI
data_summary_parts = [f"【本週】{week_label}"]
for k, v in sections.items():
data_summary_parts.append(f"\n{k}\n{v[:600]}")
data_summary = '\n'.join(data_summary_parts)
ai_text = cached_ai or _ppt_ai_analysis(data_summary, '市場情報週報')
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight('市場情報', data_summary, '')
ppt_path = generate_market_intel_weekly_ppt(week_label, sections, ai_text)
_store_ppt_cache('market_intel', params, ppt_path, {
'report_type': 'market_intel', 'parameters': params,
'data_summary': data_summary, 'analysis': ai_text,
'source_data': sections, 'mcp': '',
})
return ppt_path
elif sub_type in ('new_product', 'newproduct', '新品', '新品追蹤'):
# /ppt new_product 預設 30 天追蹤
# /ppt new_product 14 自訂追蹤天數
days = 30
if sub_arg and sub_arg.isdigit():
days = int(sub_arg)
baseline_days = 60
params = {'report_type': 'new_product', 'days': days}
cached, cached_ai = _load_cached_ppt_path_and_analysis('new_product', params)
if cached:
return cached
mcp_text = ''
if not cached_ai:
mcp_text = _fetch_mcp_context()
np_data = query_new_products(days_recent=days, days_baseline=baseline_days)
if not np_data.get('found'):
raise RuntimeError(f'{days} 天無新品(前 {baseline_days} 天無交易但近期有銷售的商品)')
kpis = np_data.get('kpis', {})
top5_str = '\n'.join(
f" {i+1}. {p.get('name','')[:30]} ({p.get('category','')}) — "
f"NT${p.get('revenue', 0):,.0f}"
for i, p in enumerate(np_data.get('new_products', [])[:5])
)
sub_str = '\n'.join(
f" - {c.get('name','')}: {c.get('sku_count', 0)} 款 / "
f"NT${c.get('revenue', 0):,.0f}"
for c in np_data.get('sub_categories', [])[:5]
)
data_summary = (
f"【追蹤期間】{np_data.get('period', '')}\n"
f"【新品總數】{kpis.get('new_count', 0)}\n"
f"【新品業績】NT${kpis.get('new_revenue', 0):,.0f}\n"
f"【業績佔比】{kpis.get('new_pct', 0):.1f}%vs 整體 NT${kpis.get('total_revenue', 0):,.0f}\n\n"
f"【新品 TOP 5】\n{top5_str}\n\n"
f"【新品依品類分佈】\n{sub_str}\n\n"
f"【MCP 外部市場情報】\n{mcp_text[:500] if mcp_text else '(無)'}"
)
ai_text = cached_ai or _ppt_ai_analysis(data_summary, '新品追蹤報告')
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight('新品追蹤', data_summary, mcp_text)
ppt_path = generate_new_product_ppt(np_data, ai_text)
_store_ppt_cache('new_product', params, ppt_path, {
'report_type': 'new_product', 'parameters': params,
'data_summary': data_summary, 'analysis': ai_text,
'source_data': np_data, 'mcp': mcp_text,
})
return ppt_path
elif sub_type in ('promo_compare', 'promocompare', '促銷比較', '多活動'):
# /ppt promo_compare 母親節:2026/05/05-2026/05/14|520:2026/05/18-2026/05/22|618:2026/06/14-2026/06/22
# 用 | 分隔多場活動,每場用 : 分 label/dates
if not sub_arg or '|' not in sub_arg:
raise RuntimeError(
'格式:/ppt promo_compare 活動1:YYYY/MM/DD-YYYY/MM/DD|活動2:...'
)
promos_input = []
for chunk in sub_arg.split('|'):
if ':' not in chunk:
continue
lbl, dates = chunk.split(':', 1)
if '-' not in dates:
continue
s_d, e_d = dates.split('-', 1)
try:
s_d = normalize_date(s_d.strip())
e_d = normalize_date(e_d.strip())
except Exception:
continue
promos_input.append({'label': lbl.strip(), 'start': s_d, 'end': e_d})
if len(promos_input) < 2:
raise RuntimeError('至少需要 2 場活動才能比較')
params = {'report_type': 'promo_compare',
'promos': '|'.join(f"{p['label']}:{p['start']}-{p['end']}" for p in promos_input)}
cached, cached_ai = _load_cached_ppt_path_and_analysis('promo_compare', params)
if cached:
return cached
# 用 query_promo_comparison 跑每場
all_promos = []
for pi in promos_input:
try:
cmp = query_promo_comparison(pi['start'], pi['end'])
if cmp and cmp.get('promo'):
promo_kpi = cmp['promo']
all_promos.append({
'label': pi['label'],
'start': pi['start'], 'end': pi['end'],
'days': int(promo_kpi.get('days', 1)),
'revenue': float(promo_kpi.get('revenue', 0)),
'orders': int(promo_kpi.get('orders', 0)),
'margin': float(promo_kpi.get('margin', 0)),
'rev_lift': float(cmp.get('rev_lift', 0)),
'ord_lift': float(cmp.get('ord_lift', 0)),
})
except Exception as e:
sys_log.warning(f"[promo_compare] {pi['label']} fetch fail: {e}")
if not all_promos:
raise RuntimeError('無法獲取任何活動資料')
rankings = {
'best_revenue': max(all_promos, key=lambda x: x['revenue']),
'best_lift': max(all_promos, key=lambda x: x['rev_lift']),
'worst_lift': min(all_promos, key=lambda x: x['rev_lift']),
'best_margin': max(all_promos, key=lambda x: x['margin']),
}
promo_summary = '\n'.join(
f" {i+1}. {p['label']} ({p['start']}~{p['end']}): "
f"NT${p['revenue']:,.0f} / 訂單 {p['orders']} / 毛利 {p['margin']:.1f}% / "
f"業績拉抬 {p['rev_lift']:+.1f}%"
for i, p in enumerate(all_promos)
)
data_summary = (
f"【比較活動數】{len(all_promos)}\n\n"
f"【各活動明細】\n{promo_summary}\n\n"
f"【最高業績】{rankings['best_revenue']['label']} "
f"NT${rankings['best_revenue']['revenue']:,.0f}\n"
f"【最高拉抬】{rankings['best_lift']['label']} "
f"+{rankings['best_lift']['rev_lift']:.1f}%\n"
f"【最低拉抬】{rankings['worst_lift']['label']} "
f"{rankings['worst_lift']['rev_lift']:+.1f}%\n"
f"【最佳毛利】{rankings['best_margin']['label']} "
f"{rankings['best_margin']['margin']:.1f}%"
)
ai_text = cached_ai or _ppt_ai_analysis(data_summary, '多活動 ROI 比較')
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight('多活動比較', data_summary, '')
label = f"{len(all_promos)} 場活動比較"
ppt_path = generate_promo_compare_ppt(
label, {'promos': all_promos, 'rankings': rankings}, ai_text
)
_store_ppt_cache('promo_compare', params, ppt_path, {
'report_type': 'promo_compare', 'parameters': params,
'data_summary': data_summary, 'analysis': ai_text,
'source_data': {'promos': all_promos, 'rankings': rankings}, 'mcp': '',
})
return ppt_path
elif sub_type in ('forecast', 'forecast_pre_event', '檔期前瞻'):
# /ppt forecast 母親節 2026/05/12
# /ppt forecast 618 2026/06/18
if not sub_arg:
raise RuntimeError('檔期前瞻需指定:/ppt forecast 母親節 2026/05/12')
parts = sub_arg.strip().split()
if len(parts) < 2:
raise RuntimeError('格式:/ppt forecast 檔期名 YYYY/MM/DD')
event_name = parts[0]
event_date = parts[1]
params = {'report_type': 'forecast_pre_event',
'event': event_name, 'date': event_date}
cached, cached_ai = _load_cached_ppt_path_and_analysis('forecast_pre_event', params)
if cached:
return cached
mcp_text = ''
if not cached_ai:
mcp_text = _fetch_mcp_context()
fc_data = query_forecast_pre_event(event_name, event_date)
if not fc_data.get('found'):
raise RuntimeError(f'檔期 {event_name} {event_date} 預測失敗:{fc_data.get("error", "未知")}')
baseline = fc_data.get('baseline', {})
ly = fc_data.get('last_year', {})
prep = fc_data.get('prep_window', {})
forecast = fc_data.get('forecast', {})
top5_str = '\n'.join(
f" {i+1}. {p.get('name','')[:30]} — baseline 業績 NT${p.get('revenue', 0):,.0f}"
for i, p in enumerate(fc_data.get('top_products', [])[:5])
)
data_summary = (
f"【檔期】{event_name}{event_date}\n"
f"【準備窗口】{fc_data.get('window_start', '')} ~ {fc_data.get('window_end', '')}\n\n"
f"【Baseline 期(檔期前 60-30 天)】\n"
f" 業績 NT${baseline.get('revenue', 0):,.0f} / "
f"日均 NT${baseline.get('avg_daily_revenue', 0):,.0f} / "
f"{baseline.get('days', 0)}\n\n"
f"【去年同檔期 ± 7 天】\n"
f" 業績 NT${ly.get('revenue', 0):,.0f} / 訂單 {ly.get('orders', 0):,}\n\n"
f"【本期準備窗口已執行】\n"
f" 已過 {prep.get('days_passed', 0)}/{prep.get('days_total', 0)} 天 / "
f"業績 NT${prep.get('revenue', 0):,.0f}\n\n"
f"【預期業績baseline × lift_factor\n"
f" NT${forecast.get('expected_revenue', 0):,.0f} (lift {forecast.get('lift_factor', 1):.2f}x)\n\n"
f"【Baseline 期 TOP 5 商品(庫存盤點對象)】\n{top5_str}\n\n"
f"【MCP 外部市場情報】\n{mcp_text[:500] if mcp_text else '(無)'}"
)
ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'檔期前瞻({event_name}')
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight('檔期前瞻', data_summary, mcp_text)
ppt_path = generate_forecast_pre_event_ppt(event_name, event_date, fc_data, ai_text)
_store_ppt_cache('forecast_pre_event', params, ppt_path, {
'report_type': 'forecast_pre_event', 'parameters': params,
'data_summary': data_summary, 'analysis': ai_text,
'source_data': fc_data, 'mcp': mcp_text,
})
return ppt_path
elif sub_type in ('customer', 'customer_analytics', '客戶'):
# /ppt customer [YYYY/MM] 指定月客戶分析
# /ppt customer 預設近 30 天
from datetime import datetime as _dt, timedelta as _td
if sub_arg and re.match(r'\d{4}[/-]\d{1,2}$', sub_arg):
yr_c, mo_c = [int(x) for x in sub_arg.replace('-', '/').split('/')]
import calendar as _cal
last_d = _cal.monthrange(yr_c, mo_c)[1]
start_str = f"{yr_c}/{mo_c:02d}/01"
end_str = f"{yr_c}/{mo_c:02d}/{last_d:02d}"
period_label = f"{yr_c}/{mo_c:02d}"
else:
today_d = now.date() if hasattr(now, 'date') else now
start_d = today_d - _td(days=30)
start_str = start_d.strftime('%Y/%m/%d')
end_str = today_d.strftime('%Y/%m/%d')
period_label = f"近 30 天 ({start_str} ~ {end_str})"
params = {'report_type': 'customer', 'period': period_label}
cached, cached_ai = _load_cached_ppt_path_and_analysis('customer', params)
if cached:
return cached
mcp_text = ''
if not cached_ai:
mcp_text = _fetch_mcp_context()
cust_data = query_customer_analytics(start_str, end_str)
if not cust_data.get('found'):
raise RuntimeError(f'期間 {period_label} 無客戶資料')
kpis = cust_data.get('kpis', {})
bucket_str = '\n'.join(
f" - {b.get('range','')}: {b.get('count', 0):,} 筆訂單"
for b in cust_data.get('aov_buckets', [])
)
wd_str = '\n'.join(
f" - {w.get('weekday','')}: {w.get('count', 0):,} 訂單 / NT${w.get('revenue', 0):,.0f}"
for w in cust_data.get('weekday_dist', [])
)
repeat_str = '\n'.join(
f" - {p.get('name','')[:25]}: 復購 {p.get('repeat_count', 0)}"
for p in cust_data.get('repeat_products', [])[:5]
)
data_summary = (
f"【期間】{period_label}\n"
f"【總訂單】{kpis.get('total_orders', 0):,}\n"
f"【總業績】NT${kpis.get('total_revenue', 0):,.0f}\n"
f"【平均客單】NT${kpis.get('aov', 0):,.0f}\n\n"
f"【客單價分佈】\n{bucket_str}\n\n"
f"【星期分佈】\n{wd_str}\n\n"
f"【商品復購 TOP 5】\n{repeat_str if repeat_str else '(無)'}\n"
)
ai_text = cached_ai or _ppt_ai_analysis(data_summary, '客戶/訂單分析')
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight('客戶分析', data_summary, mcp_text)
ppt_path = generate_customer_analytics_ppt(period_label, cust_data, ai_text)
_store_ppt_cache('customer', params, ppt_path, {
'report_type': 'customer', 'parameters': params,
'data_summary': data_summary, 'analysis': ai_text,
'source_data': cust_data, 'mcp': mcp_text,
})
return ppt_path
elif sub_type in ('category', '品類'):
# /ppt category 美妝保養 [days]
if not sub_arg:
raise RuntimeError('品類深度報告需指定品類名稱:/ppt category 美妝保養')
parts = sub_arg.strip().split()
cat = parts[0]
days = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 90
params = {'report_type': 'category', 'category': cat, 'days': days}
cached, cached_ai = _load_cached_ppt_path_and_analysis('category', params)
if cached:
return cached
mcp_text = ''
if not cached_ai:
mcp_text = _fetch_mcp_context()
cat_data = query_category_deep(cat, days=days)
if not cat_data.get('found'):
raise RuntimeError(f'品類 "{cat}" 最近 {days} 天無資料')
kpis = cat_data.get('kpis', {})
top5_str = '\n'.join(
f" {i+1}. {p.get('name','')[:30]} — NT${p.get('revenue', 0):,.0f}"
for i, p in enumerate(cat_data.get('top_products', [])[:5])
)
sub_str = '\n'.join(
f" - {c.get('name','')[:20]}: NT${c.get('revenue', 0):,.0f}"
for c in cat_data.get('sub_categories', [])[:5]
)
new_str = '\n'.join(
f" - {p.get('name','')[:30]} — NT${p.get('revenue', 0):,.0f}"
for p in cat_data.get('new_products', [])[:5]
)
data_summary = (
f"【品類】{cat}\n"
f"【期間】{cat_data.get('period', '')}(最近 {days} 天)\n"
f"【業績】NT${kpis.get('revenue', 0):,.0f}\n"
f"【訂單】{kpis.get('orders', 0):,}\n"
f"【毛利率】{kpis.get('gross_margin', 0):.1f}%\n"
f"【SKU 總數】{kpis.get('sku_count', 0)}\n"
f"【廠商數】{kpis.get('vendor_count', 0)}\n\n"
f"【子品類 TOP 5】\n{sub_str}\n\n"
f"【熱銷商品 TOP 5】\n{top5_str}\n\n"
f"【近 30 天新進榜】\n{new_str if new_str else '(無)'}\n\n"
f"【MCP 外部市場情報】\n{mcp_text[:500] if mcp_text else '(無)'}"
)
ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'品類深度報告({cat}')
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight('品類深度', data_summary, mcp_text)
cat_data['mcp'] = mcp_text
ppt_path = generate_category_deep_ppt(cat, cat_data, ai_text)
_store_ppt_cache('category', params, ppt_path, {
'report_type': 'category', 'parameters': params,
'data_summary': data_summary, 'analysis': ai_text,
'source_data': cat_data, 'mcp': mcp_text,
})
return ppt_path
elif sub_type in ('quarterly', '季報', 'half_yearly', '半年報', 'annual', '年報', 'ttm'):
# 期間回顧報告 — period_review 共用 generator
# /ppt quarterly [YYYY/Q1-4] 季報
# /ppt half_yearly [YYYY/H1-2] 半年報
# /ppt annual [YYYY] 年報
# /ppt ttm 最近 12 個月(滾動)
from datetime import datetime as _dt, timedelta as _td
import calendar as _cal
# ── 解析期間 ────────────────────────────────────────
if sub_type in ('quarterly', '季報'):
period_type = 'quarterly'
yr = int(sub_arg.split('/')[0]) if sub_arg else now.year
q = int(sub_arg.split('/Q')[1]) if sub_arg and 'Q' in sub_arg else ((now.month - 1) // 3 + 1)
start_mo, end_mo = (q - 1) * 3 + 1, q * 3
start_str = f"{yr}/{start_mo:02d}/01"
end_last = _cal.monthrange(yr, end_mo)[1]
end_str = f"{yr}/{end_mo:02d}/{end_last:02d}"
period_label = f"{yr} Q{q}"
# 上期 = 上一季
prev_q = q - 1 if q > 1 else 4
prev_yr = yr if q > 1 else yr - 1
prev_start_mo, prev_end_mo = (prev_q - 1) * 3 + 1, prev_q * 3
prev_start = f"{prev_yr}/{prev_start_mo:02d}/01"
prev_end_last = _cal.monthrange(prev_yr, prev_end_mo)[1]
prev_end = f"{prev_yr}/{prev_end_mo:02d}/{prev_end_last:02d}"
yoy_start = f"{yr-1}/{start_mo:02d}/01"
yoy_end_last = _cal.monthrange(yr-1, end_mo)[1]
yoy_end = f"{yr-1}/{end_mo:02d}/{yoy_end_last:02d}"
elif sub_type in ('half_yearly', '半年報'):
period_type = 'half_yearly'
yr = int(sub_arg.split('/')[0]) if sub_arg else now.year
h = int(sub_arg.split('/H')[1]) if sub_arg and 'H' in sub_arg else (1 if now.month <= 6 else 2)
start_mo, end_mo = (1, 6) if h == 1 else (7, 12)
start_str = f"{yr}/{start_mo:02d}/01"
end_last = _cal.monthrange(yr, end_mo)[1]
end_str = f"{yr}/{end_mo:02d}/{end_last:02d}"
period_label = f"{yr} H{h}"
prev_h = h - 1 if h > 1 else 2
prev_yr = yr if h > 1 else yr - 1
prev_start_mo, prev_end_mo = (1, 6) if prev_h == 1 else (7, 12)
prev_start = f"{prev_yr}/{prev_start_mo:02d}/01"
prev_end_last = _cal.monthrange(prev_yr, prev_end_mo)[1]
prev_end = f"{prev_yr}/{prev_end_mo:02d}/{prev_end_last:02d}"
yoy_start = f"{yr-1}/{start_mo:02d}/01"
yoy_end_last = _cal.monthrange(yr-1, end_mo)[1]
yoy_end = f"{yr-1}/{end_mo:02d}/{yoy_end_last:02d}"
elif sub_type in ('annual', '年報'):
period_type = 'annual'
yr = int(sub_arg) if sub_arg and sub_arg.isdigit() else now.year
start_str = f"{yr}/01/01"
end_str = f"{yr}/12/31"
period_label = f"{yr}"
prev_start = f"{yr-1}/01/01"
prev_end = f"{yr-1}/12/31"
yoy_start = f"{yr-2}/01/01"
yoy_end = f"{yr-2}/12/31"
else: # ttm 滾動 12 月
period_type = 'ttm'
today = now.date() if hasattr(now, 'date') else now
ttm_end = today
ttm_start = today.replace(day=1) - _td(days=365)
ttm_start = ttm_start.replace(day=1)
start_str = ttm_start.strftime('%Y/%m/%d')
end_str = ttm_end.strftime('%Y/%m/%d')
period_label = f"TTM {start_str[:7]}~{end_str[:7]}"
# 上期 TTM = 再往前 12 個月
prev_end_d = ttm_start - _td(days=1)
prev_start_d = prev_end_d.replace(day=1) - _td(days=365)
prev_start_d = prev_start_d.replace(day=1)
prev_start = prev_start_d.strftime('%Y/%m/%d')
prev_end = prev_end_d.strftime('%Y/%m/%d')
yoy_start = prev_start
yoy_end = prev_end
params = {'report_type': period_type, 'period': period_label}
cached, cached_ai = _load_cached_ppt_path_and_analysis(period_type, params)
if cached:
return cached
mcp_text = ''
if not cached_ai:
mcp_text = _fetch_mcp_context()
# 抓三段資料:本期、上期、去年同期
curr = query_period_summary(start_str, end_str)
if not curr.get('found'):
raise RuntimeError(f'{period_type} 期間 {period_label} 無資料,請確認 DB')
try:
prev = query_period_summary(prev_start, prev_end)
except Exception:
prev = {'found': False}
try:
yoy = query_period_summary(yoy_start, yoy_end)
if yoy.get('found'):
yoy['period_label'] = f"{yoy_start} ~ {yoy_end}"
except Exception:
yoy = {'found': False}
# 組 db_data
kpis = curr.get('kpis', {})
prod_breakdown = '\n'.join(
f" {i+1}. {p.get('name','')[:30]} — NT${p.get('revenue', 0):,.0f}"
for i, p in enumerate(curr.get('top_products', [])[:5])
)
cat_breakdown = '\n'.join(
f" - {c.get('cat','')}: NT${c.get('revenue', 0):,.0f}"
for c in curr.get('top_categories', [])[:5]
)
data_summary = (
f"【期間】{period_label}{period_type}\n"
f"【業績】NT${kpis.get('revenue', 0):,.0f}{kpis.get('revenue', 0)/10000:.1f}萬)\n"
f"【訂單】{kpis.get('orders', 0):,}\n"
f"【毛利率】{kpis.get('gross_margin', 0):.1f}%\n"
f"【平均客單】NT${kpis.get('avg_order', 0):,.0f}\n"
f"【商品數】{kpis.get('product_count', 0)}\n"
f"【廠商數】{kpis.get('vendor_count', 0)}\n\n"
f"【品類 TOP 5】\n{cat_breakdown}\n\n"
f"【熱銷商品 TOP 5】\n{prod_breakdown}\n\n"
f"【上期業績】NT${prev.get('kpis', {}).get('revenue', 0):,.0f}\n"
f"【去年同期業績】NT${yoy.get('kpis', {}).get('revenue', 0):,.0f}\n\n"
f"【MCP 外部市場情報】\n{mcp_text[:600] if mcp_text else '(無)'}"
)
ai_text = cached_ai or _ppt_ai_analysis(
data_summary,
f'{period_type}{period_label}'
)
if not cached_ai and _ppt_needs_fallback(ai_text):
ai_text = _ppt_fallback_insight(period_type, data_summary, mcp_text)
db_data_pr = dict(curr)
db_data_pr['prev_period'] = prev if prev.get('found') else None
db_data_pr['yoy_period'] = yoy if yoy.get('found') else None
db_data_pr['mcp'] = mcp_text
ppt_path = generate_period_review_ppt(period_type, period_label, db_data_pr, ai_text)
_store_ppt_cache(period_type, params, ppt_path, {
'report_type': period_type,
'parameters': params,
'data_summary': data_summary,
'analysis': ai_text,
'source_data': db_data_pr,
'mcp': mcp_text,
})
return ppt_path
else:
raise RuntimeError(
f'不支援的簡報類型:{sub_type}'
f'支援daily / weekly / monthly / quarterly / half_yearly / annual / ttm / strategy / competitor / promo / vendor'
)
# ── Telegram Excel 匯入 ──────────────────────────────────────────
# 必要欄位定義
_EXCEL_REQUIRED_COLS = ['日期', '商品ID', '商品名稱', '總業績', '數量']
_EXCEL_OPTIONAL_COLS = ['廠商名稱', '總成本', '商品分類L1', '%', '折扣活動名稱', '折價券活動名稱']
def _validate_excel_format(filepath: str, filename: str) -> dict:
"""
讀取 Excel回傳格式驗證報告 dict
ok, row_count, col_count, missing_required, present_optional,
dates, date_count, rev_total, report_type, warnings
"""
import pandas as pd
import os
result = {
'ok': False, 'row_count': 0, 'col_count': 0,
'missing_required': [], 'present_optional': [],
'dates': [], 'date_count': 0, 'rev_total': 0.0,
'report_type': '未知格式', 'warnings': [],
}
try:
df = pd.read_excel(filepath, engine='openpyxl', dtype=str)
if df.empty:
result['warnings'].append('Excel 檔案為空')
return result
result['row_count'] = len(df)
result['col_count'] = len(df.columns)
cols = df.columns.tolist()
# 判斷報表類型
if '即時業績' in filename and '當日' in filename:
result['report_type'] = '當日業績報表'
elif '即時業績' in filename and '全月' in filename:
result['report_type'] = '全月業績報表'
else:
result['report_type'] = '業績報表(自動偵測)'
result['warnings'].append('檔名不符合標準格式建議即時業績_當日_YYYYMMDD.xlsx')
# 必要欄位檢查
result['missing_required'] = [c for c in _EXCEL_REQUIRED_COLS if c not in cols]
result['present_optional'] = [c for c in _EXCEL_OPTIONAL_COLS if c in cols]
# 日期分析(找日期欄)
date_col = next((c for c in ['日期', '訂單日期', '交易日期'] if c in cols), None)
if date_col:
import pandas as _pd2
dates_s = _pd2.to_datetime(df[date_col], errors='coerce').dt.strftime('%Y/%m/%d')
result['dates'] = sorted(dates_s.dropna().unique().tolist())
result['date_count'] = len(result['dates'])
# 業績預覽
if '總業績' in cols:
try:
result['rev_total'] = df['總業績'].apply(
lambda x: float(str(x).replace(',', '').strip() or '0')
).sum()
except Exception as _e:
result['warnings'].append(f'業績預覽解析失敗:{str(_e)[:80]}')
result['ok'] = len(result['missing_required']) == 0
except Exception as _e:
result['warnings'].append(f'讀取失敗:{str(_e)[:80]}')
return result
def _fmt_excel_validation_report(filename: str, v: dict) -> str:
"""格式化 Excel 驗證報告為 Telegram 訊息"""
ok_mark = '' if v['ok'] else ''
lines = [
f"📊 *Excel 格式驗證報告*",
f"{'' * 24}",
f"",
f"📄 *檔案*`{filename}`",
f"📋 *類型*{v['report_type']}",
f"📦 *資料量*{v['row_count']:,}× {v['col_count']}",
f"",
]
# 必要欄位
if v['missing_required']:
missing_str = ' '.join(f'{c}' for c in v['missing_required'])
lines.append(f"❌ *必要欄位缺失*{missing_str}")
lines.append(f" _缺少以上欄位無法匯入_")
else:
present_str = ' '.join(f'{c}' for c in _EXCEL_REQUIRED_COLS)
lines.append(f"✅ *必要欄位*(全部通過)")
lines.append(f" {present_str}")
if v['present_optional']:
opt_str = ' '.join(f'{c}' for c in v['present_optional'])
lines.append(f"📌 *附加欄位*{opt_str}")
lines.append("")
# 日期範圍
if v['dates']:
if v['date_count'] == 1:
lines.append(f"📅 *涵蓋日期*`{v['dates'][0]}`1 天)")
elif v['date_count'] <= 5:
date_str = ' / '.join(v['dates'])
lines.append(f"📅 *涵蓋日期*{v['date_count']} 天({date_str}")
else:
lines.append(f"📅 *涵蓋日期*{v['dates'][0]} ~ {v['dates'][-1]}(共 {v['date_count']} 天)")
if v['rev_total']:
lines.append(f"💰 *業績預覽*`NT$ {v['rev_total']:,.0f}`")
if v['warnings']:
lines.append("")
for w in v['warnings']:
lines.append(f"⚠️ {w}")
lines.append("")
lines.append(f"{'' * 24}")
if v['ok']:
lines.append(f"✅ *格式驗證通過,可以匯入!*")
lines.append(f"_寫入後將覆蓋相同日期的現有資料_")
else:
lines.append(f"❌ *格式驗證失敗,無法匯入*")
lines.append(f"_請修正上述缺失欄位後重新上傳_")
return "\n".join(lines)
def _download_telegram_file(file_id: str, suffix: str = '.xlsx') -> str:
"""從 Telegram 下載檔案到 /tmp回傳本地路徑失敗回傳 ''"""
try:
r = requests.get(f"{BOT_API_URL}/getFile", params={'file_id': file_id}, timeout=10)
file_path_tg = r.json().get('result', {}).get('file_path', '')
if not file_path_tg:
return ''
file_url = f"https://api.telegram.org/file/bot{BOT_TOKEN}/{file_path_tg}"
file_data = requests.get(file_url, timeout=60).content
import tempfile as _tf
tmp = _tf.NamedTemporaryFile(suffix=suffix, prefix='ocbot_xl_', delete=False, dir='/tmp')
tmp.write(file_data)
tmp.close()
return tmp.name
except Exception as _e:
sys_log.error(f"[ExcelImport] download failed: {_e}")
return ''
def _handle_excel_import(doc: dict, chat_id: int, reply_to: int):
"""背景執行緒:下載 Excel → 驗證 → 發送驗證報告 + 確認按鈕"""
filename = doc.get('file_name', 'upload.xlsx')
file_id = doc.get('file_id', '')
file_size = doc.get('file_size', 0)
# 大小限制 50MB
if file_size > 50 * 1024 * 1024:
send_message(chat_id, "⚠️ 檔案太大(上限 50MB請壓縮後重新上傳", reply_to)
return
send_message(chat_id,
f"⏳ 正在下載 Excel 檔案...\n`{filename}`",
reply_to, parse_mode='Markdown')
suffix = '.xlsx' if filename.lower().endswith('.xlsx') else '.xls'
local_path = _download_telegram_file(file_id, suffix)
if not local_path:
send_message(chat_id, "❌ 檔案下載失敗,請重試", reply_to)
return
# 驗證格式
v = _validate_excel_format(local_path, filename)
report_text = _fmt_excel_validation_report(filename, v)
if v['ok']:
# 儲存待確認資訊
_excel_pending[chat_id] = {
'file_path': local_path,
'filename': filename,
}
kb = [
_row(('✅ 確認匯入資料庫', 'cmd:import_confirm'), ('❌ 取消', 'cmd:import_cancel')),
]
send_message(chat_id, report_text, reply_to, kb)
else:
# 驗證失敗 → 直接顯示報告,不提供匯入選項
try:
import os
os.unlink(local_path)
except Exception:
pass
send_message(chat_id, report_text, reply_to)
# ── 排程自動報告 ───────────────────────────────────────────────
def send_morning_report():
"""每日 08:30 早報 — P10 升級版TOP15 + PPT導引按鈕"""
try:
now = datetime.now(TAIPEI_TZ)
td = now.strftime('%Y/%m/%d')
yd = (now - timedelta(days=1)).strftime('%Y/%m/%d')
wdays = ['週一', '週二', '週三', '週四', '週五', '週六', '週日']
sales = query_sales(yd)
top15 = query_top_products(yd, 15)
weekly = query_weekly_trend()
# 計算7日均值做基準
avg7 = 0
if weekly and len(weekly) >= 3:
avg7 = sum(w['revenue'] for w in weekly[-7:]) / min(7, len(weekly))
lines = [
f"☀️ *早安!今天是 {now.strftime('%m/%d')}{wdays[now.weekday()]}*",
f"{'' * 30}",
"",
]
if sales.get('found'):
rev = float(sales['revenue'])
orders = sales.get('orders', 0) or 0
margin = float(sales.get('gross_margin', 0))
lines.append(f"📅 *昨日業績回顧* _({yd})_")
lines.append(f" 💰 業績:`NT$ {rev:,.0f}` 📦 訂單:`{orders:,}` 筆")
lines.append(f" 🛒 客單:`NT$ {float(sales.get('avg_order',0)):,.0f}` 📈 毛利率:`{margin:.1f}%`")
if avg7 > 0:
diff = rev - avg7
pct = diff / avg7 * 100
arrow = '' if diff >= 0 else ''
vs_avg = f"{arrow}{abs(pct):.1f}% vs 7日均 (`NT$ {diff:+,.0f}`)"
lines.append(f" 📊 {vs_avg}")
else:
lines.append(f"📅 _昨日資料尚未匯入{yd}_")
lines.append("")
if top15:
lines.append(f"🏆 *昨日熱銷 TOP15*")
lines.append(f"{'' * 26}")
for i, p in enumerate(top15):
pid = p.get('id', '') or ''
sid = _short_id(pid)
link = _pchome_link(pid, p['name'], 20)
rank = MEDALS[i] if i < len(MEDALS) else f"`{i+1}.`"
lines.append(f" {rank} {link}")
lines.append(f" 🆔 `{sid}` `NT$ {p['revenue']:,.0f}` 📦 {p.get('qty','-')}")
lines.append("")
gs = get_goal_status(td)
if gs.get('daily_goal'):
dg = gs['daily_goal']
lines.append(f"🎯 *今日目標* `NT$ {dg:,.0f}`")
lines.append(f" 💪 加油衝刺!祝業績長紅!")
elif gs.get('monthly_goal'):
mg = gs['monthly_goal']
lines.append(f"🎯 *月目標* `NT$ {mg:,.0f}`")
lines.append(f" 月累計:`NT$ {gs.get('month_rev',0):,.0f}` (`{gs.get('monthly_pct',0) or 0:.1f}%`)")
else:
lines.append("💪 *今日加油!* 祝業績長紅!")
lines.append("")
try:
from services.mcp_context_service import get_taiwan_weather
taipei = get_taiwan_weather().get('weather', {}).get('臺北市', {})
if taipei:
wx = taipei.get('Wx', '')
pop = taipei.get('PoP', '')
temp = taipei.get('temp', '') or f"{taipei.get('MinT','')}-{taipei.get('MaxT','')}°C"
weather_line = f"🌤 *台北天氣* {wx}"
if temp.strip('-'):
weather_line += f" {temp}"
if pop:
weather_line += f" 💧降雨 {pop}%"
lines.append(weather_line)
if int(pop or 0) >= 60:
lines.append(" ☂️ _建議做室內促銷活動_")
except Exception:
pass
# P10 — 導引按鈕產出日報PPT + 查看業績數據
morning_kb = [
_row((f'📊 產出 {yd} 日報PPT', f'cmd:ppt:daily {yd}'),
('📈 業績數據', f'cmd:sales:{yd}')),
_row(('🏆 完整熱銷排行', f'cmd:top:{yd}'),
('📋 下載 Excel', f'cmd:report:{yd}')),
]
send_message(ALLOWED_GROUP, "\n".join(lines), keyboard=morning_kb, parse_mode='Markdown')
sys_log.info("[OpenClawBot] 早報已發送")
except Exception as e:
sys_log.error(f"[OpenClawBot] 早報失敗: {e}")
def send_evening_report():
"""每日 21:00 晚報"""
try:
now = datetime.now(TAIPEI_TZ)
td = now.strftime('%Y/%m/%d')
sales = query_sales(td)
weekly = query_weekly_trend()
lines = [
f"🌙 *今日業績收盤 {now.strftime('%m/%d')} 21:00*",
f"{'' * 30}",
"",
]
if sales.get('found'):
rev = float(sales['revenue'])
orders = sales.get('orders', 0) or 0
prods = sales.get('products', 0) or 0
avg_o = float(sales.get('avg_order', 0))
margin = float(sales.get('gross_margin', 0))
lines.append(f"💰 *今日業績* `NT$ {rev:,.0f}`")
lines.append(f"📦 訂單 `{orders:,}` 筆 🛍 商品 `{prods:,}` 件 🛒 客單 `NT$ {avg_o:,.0f}`")
lines.append(f"📈 毛利率 `{margin:.1f}%`")
lines.append("")
gs = get_goal_status(td)
if gs.get('daily_goal'):
pct = gs.get('daily_pct', 0) or 0
dg = gs['daily_goal']
gap = dg - rev
bar_len = min(10, max(0, int(pct // 10)))
bar = '' * bar_len + '' * (10 - bar_len)
result = "🏆 超標達成!" if gap <= 0 else f"差 `NT$ {gap:,.0f}` 達標"
lines.append(f"🎯 *日目標達成* `[{bar}]` *{pct:.1f}%* {result}")
lines.append("")
# vs 昨日比較
if weekly and len(weekly) >= 2:
yd_rev = weekly[-2]['revenue'] if len(weekly) >= 2 else 0
if yd_rev:
diff = rev - yd_rev
pct_d = diff / yd_rev * 100
arrow = '' if diff >= 0 else ''
emoji = '📈' if diff >= 0 else '📉'
lines.append(f"{emoji} *vs 昨日* {arrow}`{abs(pct_d):.1f}%` (`NT$ {diff:+,.0f}`)")
lines.append("")
else:
lines.append("⚠️ *今日業績資料尚未匯入*")
lines.append("")
top15_ev = query_top_products(td, 15)
if top15_ev:
lines.append(f"🏆 *今日熱銷 TOP15*")
lines.append(f"{'' * 26}")
for i, p in enumerate(top15_ev):
pid = p.get('id', '') or ''
sid = _short_id(pid)
link = _pchome_link(pid, p['name'], 20)
rank = MEDALS[i] if i < len(MEDALS) else f"`{i+1}.`"
lines.append(f" {rank} {link}")
lines.append(f" 🆔 `{sid}` `NT$ {p['revenue']:,.0f}` 📦 {p.get('qty','-')}")
lines.append("")
try:
strat = analyze_product_strategy(td, 8)
hot_list = [s for s in strat if s['strategy'] == '加碼'][:2]
opp_list = [s for s in strat if s['strategy'] == '機會'][:2]
if hot_list or opp_list:
lines.append(f"🧬 *明日行動建議*")
for s in hot_list:
pid_s = s.get('id', '') or ''
link_s = _pchome_link(pid_s, s['name'], 18)
lines.append(f" 🔥 加碼:{link_s} 週▲{abs(s.get('growth',0))*100:.0f}%")
for s in opp_list:
pid_s = s.get('id', '') or ''
link_s = _pchome_link(pid_s, s['name'], 18)
lines.append(f" 💡 機會:{link_s}")
lines.append("")
except Exception:
pass
# P10 — 晚報導引按鈕
evening_kb = [
_row((f'📊 產出 {td} 日報PPT', f'cmd:ppt:daily {td}'),
('📈 完整業績數據', f'cmd:sales:{td}')),
_row(('📋 下載 Excel 報表', f'cmd:report:{td}'),
('🧬 策略矩陣分析', f'cmd:strategy:{td}')),
]
send_message(ALLOWED_GROUP, "\n".join(lines), keyboard=evening_kb, parse_mode='Markdown')
sys_log.info("[OpenClawBot] 晚報已發送")
except Exception as e:
sys_log.error(f"[OpenClawBot] 晚報失敗: {e}")
def send_weekly_report():
"""每週一 09:00 週報"""
try:
now = datetime.now(TAIPEI_TZ)
td = now.strftime('%Y/%m/%d')
weekly = query_weekly_trend()
WEEKDAYS_ZH = ['週一', '週二', '週三', '週四', '週五', '週六', '週日']
lines = [
f"📊 *週報 {now.strftime('%m/%d')}(週一)*",
f"{'' * 30}",
"",
]
if weekly:
total = sum(d.get('revenue', 0) for d in weekly)
avg = total / len(weekly)
max_r = max(d['revenue'] for d in weekly)
min_r = min(d['revenue'] for d in weekly)
max_rev = max_r or 1
lines.append(f"📅 *上週業績總覽*")
lines.append(f" 合計:`NT$ {total:,.0f}` 日均:`NT$ {avg:,.0f}`")
lines.append(f" 最高:`NT$ {max_r:,.0f}` 最低:`NT$ {min_r:,.0f}`")
lines.append("")
lines.append("*逐日明細*")
for i, d in enumerate(weekly[-7:]):
rev = d.get('revenue', 0)
bar_len = max(1, int(rev / max_rev * 8))
bar = '' * bar_len + '·' * (8 - bar_len)
try:
from datetime import datetime as dt
d_obj = dt.strptime(d['date'].replace('/', '-'), '%Y-%m-%d')
wday = WEEKDAYS_ZH[d_obj.weekday()]
except Exception:
wday = ''
# vs 前日漲跌
if i > 0:
prev = weekly[i - 1]['revenue']
pct_chg = (rev - prev) / prev * 100 if prev else 0
chg_str = f" {'' if pct_chg >= 0 else ''}{abs(pct_chg):.1f}%"
else:
chg_str = ''
lines.append(f" `{d['date']}` {wday} `{bar}` `NT$ {rev:>10,.0f}`{chg_str}")
lines.append("")
top5 = query_top_products(td, 5)
if top5:
lines.append(f"🏆 *本週熱銷 TOP5*")
for i, p in enumerate(top5[:5]):
sid = _short_id(p.get('id', ''))
lines.append(f" {MEDALS[i]} {p['name'][:18]} `NT$ {p['revenue']:,.0f}`")
lines.append(f" 🆔 `{sid}`")
lines.append("")
lines.append(f"📋 [報表中心]({MOMO_BASE_URL}/reports)")
# 附趨勢圖
chart = gen_trend_chart(14)
if chart:
send_photo(ALLOWED_GROUP, chart, caption="\n".join(lines))
os.unlink(chart)
else:
send_message(ALLOWED_GROUP, "\n".join(lines), parse_mode='Markdown')
sys_log.info("[OpenClawBot] 週報已發送")
except Exception as e:
sys_log.error(f"[OpenClawBot] 週報失敗: {e}")
def check_anomalies():
"""每日 9/12/15/18 點異常偵測(整體業績 + 商品級)"""
try:
now = datetime.now(TAIPEI_TZ)
td = now.strftime('%Y/%m/%d')
ts = now.strftime('%H:%M')
alerts = []
# ── ① 整體業績異常(今日 vs 7日均 ──────────────────
try:
weekly = query_weekly_trend()
today_data = next((w for w in weekly if w['date'] == td), None)
past_data = [w for w in weekly if w['date'] != td]
if today_data and len(past_data) >= 3:
avg7 = sum(w['revenue'] for w in past_data[-7:]) / len(past_data[-7:])
if avg7 > 0:
pct = (today_data['revenue'] - avg7) / avg7 * 100
if abs(pct) >= 25:
direction = '📈 急升' if pct > 0 else '📉 急降'
tip = '建議加強曝光推廣' if pct > 0 else '⚠️ 請立即確認原因(活動下架?資料延遲?)'
alerts.insert(0,
f"🔔 *整體業績異常* {direction} {abs(pct):.0f}%\n"
f" 今日:`NT$ {today_data['revenue']:,.0f}` "
f"7日均`NT$ {avg7:,.0f}`\n"
f" 💡 {tip}"
)
except Exception as _e:
sys_log.warning(f"[anomaly] 整體業績檢查失敗: {_e}")
# ── ② 商品級異常(偏差>30% ─────────────────────────
anomalies = query_anomalies(td)
for a in anomalies:
pct = a.get('pct', 0) or 0
if abs(pct) < 30:
continue
direction = '📈 *急升*' if pct > 0 else '📉 *急降*'
today_rev = a.get('today', 0)
avg7_rev = a.get('avg7', 0)
sid = _short_id(a.get('id', ''))
if avg7_rev:
ratio = today_rev / avg7_rev
bar_len = min(10, max(0, int(ratio * 5)))
ratio_bar = '' * bar_len + '' * (10 - bar_len)
alerts.append(
f"{direction} {abs(pct):.0f}%\n"
f" 商品:{a['name'][:20]}\n"
f" 🆔 `{sid}`\n"
f" 今日:`NT$ {today_rev:,.0f}` 7日均`NT$ {avg7_rev:,.0f}`\n"
f" 比率:`{ratio_bar}` {ratio:.1f}x"
)
else:
alerts.append(
f"{direction} {abs(pct):.0f}%\n"
f" 商品:{a['name'][:20]}\n"
f" 🆔 `{sid}`\n"
f" 今日:`NT$ {today_rev:,.0f}` 7日均`NT$ {avg7_rev:,.0f}`"
)
if alerts:
header = (
f"🚨 *業績異常偵測 {td} {ts}*\n"
f"{'' * 28}\n"
f"共發現 {len(alerts)} 件異常\n\n"
)
msg = header + "\n\n".join(alerts)
ack_kb = [
_row(('✅ 已知悉', 'cmd:ack:anomaly'),
('🔄 追蹤中', 'cmd:ack:tracking')),
_row(('📊 查看今日業績', f'cmd:sales:{td}'),
('🏆 熱銷商品', f'cmd:top:{td}')),
]
send_message(ALLOWED_GROUP, msg, keyboard=ack_kb, parse_mode='Markdown')
sys_log.info(f"[OpenClawBot] 異常告警 {len(alerts)}")
else:
sys_log.info(f"[OpenClawBot] 異常偵測完成,無告警 ({ts})")
except Exception as e:
sys_log.error(f"[OpenClawBot] check_anomalies: {e}")
def send_competitor_report():
"""每日 08:00 推播競品比價日報 + 降價警報"""
if not _PCHOME_AVAILABLE:
return
try:
yesterday = (datetime.now(TAIPEI_TZ).date() - timedelta(days=1)).strftime('%Y/%m/%d')
sys_log.info(f'[PChome] 每日競品日報開始 {yesterday}')
results = pchome_batch(_db(), top_n=30, date_str=yesterday)
pchome_save(_db(), results)
msg = pchome_fmt_report(results, yesterday)
kb = [_row(('🔍 搜尋比價', 'await:search_compare'),
('📄 比價簡報', 'menu:competitor_ppt'))]
send_message(ALLOWED_GROUP, msg, None, kb)
sys_log.info(f'[PChome] 競品日報已推播 {len(results)} 件商品')
# ── 追蹤價格變動,有降價則額外發送警報 ──────────────
try:
changes = track_competitor_price_changes(results)
if changes:
alert_lines = [
f"⚡ *momo 降價警報!* 共 {len(changes)}",
f"{'' * 26}",
f"_momo 降價 ≥5% → PChome 需評估因應策略_",
"",
]
for c in changes[:8]:
alert_lines.append(
f"📉 *{c['name']}*\n"
f" `NT$ {c['prev_price']:,.0f}` → `NT$ {c['curr_price']:,.0f}` "
f"*{c['pct']:+.1f}%*"
)
send_message(ALLOWED_GROUP, "\n".join(alert_lines), parse_mode='Markdown')
sys_log.info(f"[price_track] 降價警報 {len(changes)}")
except Exception as _pe:
sys_log.warning(f"[price_track] {_pe}")
except Exception as e:
sys_log.error(f'[PChome] send_competitor_report: {e}', exc_info=True)
def send_daily_excel():
"""每日 08:45 自動發送昨日 Excel 業績報表"""
try:
yesterday = (datetime.now(TAIPEI_TZ).date() - timedelta(days=1)).strftime('%Y/%m/%d')
sys_log.info(f"[AutoExcel] 開始產生 {yesterday} Excel 報表")
path = generate_daily_pdf(yesterday)
if not path:
sys_log.warning("[AutoExcel] 報表產生失敗")
return
ext = 'Excel' if path.endswith('.xlsx') else 'CSV'
caption = (
f"📊 *{yesterday} 完整業績報表({ext}*\n"
f"_自動定時發送 — 每日 08:45_"
)
send_document(ALLOWED_GROUP, path, caption=caption)
try:
import os as _os; _os.unlink(path)
except Exception:
pass
sys_log.info(f"[AutoExcel] {yesterday} 報表已發送")
except Exception as e:
sys_log.error(f"[AutoExcel] {e}")
def start_scheduler():
"""啟動排程Flask app 啟動後呼叫)"""
global _scheduler
try:
if _scheduler is not None and _scheduler.running:
sys_log.info("[OpenClawBot] Scheduler 已在執行中,跳過重複啟動")
return
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
_scheduler = BackgroundScheduler(timezone='Asia/Taipei')
_scheduler.add_job(
send_morning_report,
CronTrigger(hour=8, minute=30),
id="openclaw_send_morning_report",
replace_existing=True,
)
_scheduler.add_job(
send_competitor_report,
CronTrigger(hour=8, minute=0),
id="openclaw_send_competitor_report",
replace_existing=True,
)
_scheduler.add_job(
send_daily_excel,
CronTrigger(hour=8, minute=45),
id="openclaw_send_daily_excel",
replace_existing=True,
)
_scheduler.add_job(
send_evening_report,
CronTrigger(hour=21, minute=0),
id="openclaw_send_evening_report",
replace_existing=True,
)
_scheduler.add_job(
send_weekly_report,
CronTrigger(day_of_week='mon', hour=9, minute=0),
id="openclaw_send_weekly_report",
replace_existing=True,
)
_scheduler.add_job(
check_anomalies,
CronTrigger(hour='9,12,15,18', minute=0),
id="openclaw_check_anomalies",
replace_existing=True,
)
# ADR-019 Phase 6: 每日 09:00 主動巡檢資料新鮮度,缺資料時透過 EventRouter 發警告
try:
from services.data_freshness_probe import run_data_freshness_probe
_scheduler.add_job(
run_data_freshness_probe,
CronTrigger(hour=9, minute=5),
id="openclaw_data_freshness_probe",
replace_existing=True,
)
except ImportError as _e:
sys_log.warning(f"[OpenClawBot] data_freshness_probe 未安裝,跳過:{_e}")
_scheduler.start()
sys_log.info("[OpenClawBot] Scheduler started ✓ (competitor/morning/excel/evening/weekly/anomaly/freshness)")
except ImportError:
sys_log.warning("[OpenClawBot] APScheduler 未安裝 — 排程功能停用")
except Exception as e:
sys_log.error(f"[OpenClawBot] start_scheduler: {e}")
def register_commands():
"""Telegram 只支援英文小寫指令"""
cmds = [
{'command': 'sales', 'description': '今日業績總覽'},
{'command': 'top', 'description': '熱銷商品 TOP10含商品ID'},
{'command': 'vendor', 'description': '熱銷廠商排行'},
{'command': 'trend', 'description': '近7日業績趨勢'},
{'command': 'compare', 'description': '同期比較vs上週/上月)'},
{'command': 'category', 'description': '分類業績鑽取'},
{'command': 'restock', 'description': '📦 補貨預測(銷售速度分析)'},
{'command': 'promo', 'description': '🎉 促銷效益追蹤 /promo 2026/04/01-2026/04/07'},
{'command': 'goal', 'description': '目標達成率(/goal 200000 設定)'},
{'command': 'chart', 'description': '業績趨勢圖'},
{'command': 'health', 'description': '商品健康分析'},
{'command': 'strategy', 'description': '商品策略矩陣'},
{'command': 'ppt', 'description': '生成業績簡報 /ppt daily|weekly|monthly|strategy'},
{'command': 'report', 'description': '下載完整業績報表 (Excel)'},
{'command': 'history', 'description': '月份業績總覽(/history 2026/03'},
{'command': 'news', 'description': '即時電商新聞'},
{'command': 'weather', 'description': '今日天氣(行銷參考)'},
{'command': 'menu', 'description': '顯示主選單'},
{'command': 'help', 'description': '使用說明'},
]
return _tg('setMyCommands', {'commands': cmds})
_AWAIT_PROMPTS = {
'date_sales': ('📅 請輸入查詢日期\n格式:`2026/04/15`', '業績日期'),
'date_range_sales': (
'📅 請輸入日期或日期區間\n\n'
'📌 單日:`2026/04/15`\n'
'📌 區間:`2026/04/01-2026/04/15`\n'
'📌 月份:`2026/04`',
'日期/區間',
),
'date_top': ('📅 請輸入查詢日期\n格式:`2026/04/15`', '商品日期'),
'date_analysis': ('📅 請輸入分析日期\n格式:`2026/04/15`', '分析日期'),
'date_ppt_daily': ('📅 請輸入日報日期\n格式:`2026/04/15`', 'PPT日期'),
'date_ppt_monthly':('📅 請輸入月報月份\n格式:`2026/03`', 'PPT月份'),
'goal_daily': ('🎯 請輸入每日業績目標金額NT$\n例如:`150000`', '日目標'),
'goal_monthly': ('🎯 請輸入每月業績目標金額NT$\n例如:`3000000`', '月目標'),
'goal_quarterly': ('🎯 請輸入每季業績目標金額NT$\n例如:`9000000`', '季目標'),
'goal_half': ('🎯 請輸入半年業績目標金額NT$\n例如:`18000000`', '半年目標'),
'goal_yearly': ('🎯 請輸入全年業績目標金額NT$\n例如:`36000000`', '年目標'),
'search_compare': ('🔍 請輸入商品關鍵字\n例如:`休足時間貼片`', '比價關鍵字'),
'date_trend_month': ('📅 請輸入查詢月份\n格式:`2026/03`', '趨勢月份'),
'date_trend_year': ('📅 請輸入查詢年份\n格式:`2026`', '趨勢年份'),
'date_trend_quarter':('📅 請輸入查詢季度\n格式:`2026/Q1`Q1~Q4', '趨勢季度'),
'date_competitor': ('📅 請輸入競品分析日期\n格式:`2026/04/15`', '競品日期'),
'promo_range': ('🎉 請輸入促銷日期範圍\n格式:`2026/04/01-2026/04/07`\n(活動開始日-活動結束日)', '促銷範圍'),
}
def sales_quick_kb(date_str):
try:
from datetime import datetime as dt
d = dt.strptime(date_str.replace('/', '-'), '%Y-%m-%d').date()
yesterday = (d - timedelta(days=1)).strftime('%Y/%m/%d')
return [
_row(('⬅️ 昨日業績', f'cmd:sales:{yesterday}'), ('🏆 熱銷商品', f'cmd:top:{date_str}')),
_row(('📋 完整報表', f'cmd:report:{date_str}')),
]
except Exception:
return None
# ── 資料查詢 ──────────────────────────────────────────────────
def _db():
return DatabaseManager().engine
def normalize_date(s: str) -> str:
return s.replace('-', '/') if s else s
def latest_date() -> str:
try:
with _db().connect() as c:
row = c.execute(
text('SELECT MAX("日期") FROM realtime_sales_monthly')
).fetchone()
return str(row[0]) if row and row[0] else None
except Exception:
return None
configure_menu_keyboards(latest_date_provider=latest_date, goals=_GOALS, taipei_tz=TAIPEI_TZ)
def query_sales(d: str) -> dict:
try:
with _db().connect() as c:
row = c.execute(text("""
SELECT COUNT(DISTINCT "訂單編號"),
COALESCE(SUM(CAST("總業績" AS FLOAT)),0),
COALESCE(SUM(CAST("總成本" AS FLOAT)),0),
COUNT(DISTINCT "商品ID")
FROM realtime_sales_monthly WHERE "日期"=:d
"""), {'d': d}).fetchone()
if row and row[0] > 0:
rev, cost, orders = row[1], row[2], row[0]
return {'found': True, 'date': d, 'orders': orders, 'revenue': rev,
'avg_order': rev/orders if orders else 0,
'gross_margin': (rev-cost)/rev*100 if rev>0 else 0,
'products': row[3]}
return {'found': False, 'date': d}
except Exception as e:
sys_log.error(f"[OpenClawBot] query_sales: {e}")
return {'found': False, 'date': d}
def query_top_products(d, lim=10):
try:
with _db().connect() as c:
rows = c.execute(text("""
SELECT "商品ID", "商品名稱", SUM(CAST("總業績" AS FLOAT)),
SUM(CAST("數量" AS INTEGER))
FROM realtime_sales_monthly WHERE "日期"=:d
GROUP BY "商品ID", "商品名稱" ORDER BY 3 DESC LIMIT :lim
"""), {'d': d, 'lim': lim}).fetchall()
return [{'id': r[0], 'name': r[1], 'revenue': r[2], 'qty': r[3]} for r in rows]
except Exception:
return []
def query_vendor_summary(start_date: str, end_date: str, lim: int = 30) -> dict:
"""查詢期間廠商業績摘要vendor PPT 用)
回傳:{
vendor_ranking: [{name, sales, profit, margin, qty, orders}, ...] (TOP lim),
kpis: {total_sales, total_profit, avg_margin, vendor_count},
period_label: 'YYYY/MM/DD ~ YYYY/MM/DD'
}
"""
try:
with _db().connect() as c:
row = c.execute(text("""
SELECT COUNT(DISTINCT "廠商名稱"),
COALESCE(SUM(CAST("總業績" AS FLOAT)), 0),
COALESCE(SUM(CAST("總成本" AS FLOAT)), 0)
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
AND "廠商名稱" IS NOT NULL AND "廠商名稱" != ''
"""), {'s': start_date.replace('/', '-'), 'e': end_date.replace('/', '-')}).fetchone()
vendor_rows = c.execute(text("""
SELECT "廠商名稱",
SUM(CAST("總業績" AS FLOAT)) AS sales,
SUM(CAST("總成本" AS FLOAT)) AS cost,
SUM(CAST("數量" AS INTEGER)) AS qty,
COUNT(DISTINCT "訂單編號") AS orders
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
AND "廠商名稱" IS NOT NULL AND "廠商名稱" != ''
GROUP BY "廠商名稱"
ORDER BY sales DESC
LIMIT :lim
"""), {'s': start_date.replace('/', '-'),
'e': end_date.replace('/', '-'),
'lim': lim}).fetchall()
vcount, total_sales, total_cost = (row[0] or 0), float(row[1] or 0), float(row[2] or 0)
total_profit = total_sales - total_cost
avg_margin = total_profit / total_sales * 100 if total_sales else 0
vendor_ranking = []
for r in vendor_rows:
sales = float(r[1] or 0)
cost = float(r[2] or 0)
profit = sales - cost
margin = profit / sales * 100 if sales else 0
vendor_ranking.append({
'name': r[0],
'sales': sales,
'profit': profit,
'margin': margin,
'qty': int(r[3] or 0),
'orders': int(r[4] or 0),
})
return {
'vendor_ranking': vendor_ranking,
'kpis': {
'total_sales': total_sales,
'total_profit': total_profit,
'avg_margin': avg_margin,
'vendor_count': vcount,
},
'period_label': f"{start_date} ~ {end_date}",
}
except Exception as e:
sys_log.error(f"[query_vendor_summary] {e}")
return {'vendor_ranking': [], 'kpis': {}, 'period_label': ''}
def query_top_vendors(d, lim=10):
try:
with _db().connect() as c:
rows = c.execute(text("""
SELECT "廠商名稱", SUM(CAST("總業績" AS FLOAT))
FROM realtime_sales_monthly
WHERE "日期"=:d AND "廠商名稱" IS NOT NULL AND "廠商名稱"!=''
GROUP BY "廠商名稱" ORDER BY 2 DESC LIMIT :lim
"""), {'d': d, 'lim': lim}).fetchall()
return [{'name': r[0], 'revenue': r[1]} for r in rows]
except Exception:
return []
def query_weekly_trend():
try:
with _db().connect() as c:
rows = c.execute(text("""
SELECT "日期",
COALESCE(SUM(CAST("總業績" AS FLOAT)),0),
COUNT(DISTINCT "訂單編號")
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY "日期" ORDER BY "日期" DESC LIMIT 7
""")).fetchall()
return [{'date': str(r[0]), 'revenue': r[1], 'orders': int(r[2] or 0)} for r in rows]
except Exception as e:
sys_log.error(f"[OpenClawBot] weekly_trend: {e}")
return []
def query_trend_range(start_str: str, end_str: str) -> list:
"""指定區間每日業績(用於趨勢圖,回傳 [{'date', 'revenue', 'orders'}] 按日期升序)"""
try:
with _db().connect() as c:
rows = c.execute(text("""
SELECT "日期",
COALESCE(SUM(CAST("總業績" AS FLOAT)), 0),
COUNT(DISTINCT "訂單編號")
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
GROUP BY "日期" ORDER BY "日期" ASC
"""), {'s': start_str.replace('/', '-'), 'e': end_str.replace('/', '-')}).fetchall()
return [{'date': str(r[0]), 'revenue': float(r[1]), 'orders': int(r[2] or 0)} for r in rows]
except Exception as e:
sys_log.error(f"[OpenClawBot] query_trend_range: {e}")
return []
def query_monthly_summary(year: int, month: int) -> dict:
"""取得指定年月業績摘要(逐日 + 合計 + TOP10商品/廠商)
支援 YYYY/MM/DD 與 YYYY-MM-DD 兩種日期格式
"""
month_str = f"{year}/{month:02d}"
# BETWEEN 比 LIKE 更可靠,且同時支援 DATE 與 TEXT 格式
import calendar as _cal2
last_day = _cal2.monthrange(year, month)[1]
start_date = f"{year}-{month:02d}-01"
end_date = f"{year}-{month:02d}-{last_day:02d}"
try:
with _db().connect() as c:
# 月合計 — 用 BETWEEN 避免 LIKE 格式相依
row = c.execute(text("""
SELECT COUNT(DISTINCT "訂單編號"),
COALESCE(SUM(CAST("總業績" AS FLOAT)),0),
COALESCE(SUM(CAST("總成本" AS FLOAT)),0),
COUNT(DISTINCT "商品ID"),
COUNT(DISTINCT "日期")
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE)
BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
"""), {'s': start_date, 'e': end_date}).fetchone()
daily_rows = c.execute(text("""
SELECT "日期", SUM(CAST("總業績" AS FLOAT)) as rev,
COUNT(DISTINCT "訂單編號") as orders
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE)
BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
GROUP BY "日期" ORDER BY "日期" ASC
"""), {'s': start_date, 'e': end_date}).fetchall()
prod_rows = c.execute(text("""
SELECT "商品ID", "商品名稱",
SUM(CAST("總業績" AS FLOAT)) as rev,
SUM(CAST("數量" AS INTEGER)) as qty,
COUNT(DISTINCT "訂單編號") as orders
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE)
BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
GROUP BY "商品ID", "商品名稱" ORDER BY 3 DESC LIMIT 50
"""), {'s': start_date, 'e': end_date}).fetchall()
vendor_rows = c.execute(text("""
SELECT "廠商名稱", SUM(CAST("總業績" AS FLOAT)) as rev
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE)
BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
AND "廠商名稱" IS NOT NULL AND "廠商名稱" != ''
GROUP BY "廠商名稱" ORDER BY 2 DESC LIMIT 10
"""), {'s': start_date, 'e': end_date}).fetchall()
if not row or row[0] == 0:
return {'found': False, 'month': month_str}
orders, revenue, cost = row[0], row[1], row[2]
return {
'found': True, 'month': month_str,
'orders': orders, 'revenue': revenue,
'cost': cost,
'gross_margin': (revenue - cost) / revenue * 100 if revenue > 0 else 0,
'avg_order': revenue / orders if orders else 0,
'products': row[3], 'days_with_data': row[4],
'daily': [{'date': str(r[0]), 'revenue': float(r[1]), 'orders': int(r[2])}
for r in daily_rows],
'top_products': [{'id': r[0], 'name': r[1], 'revenue': float(r[2]),
'qty': int(r[3] or 0), 'orders': int(r[4] or 0)}
for r in prod_rows],
'top_vendors': [{'name': r[0], 'revenue': float(r[1])} for r in vendor_rows],
}
except Exception as e:
sys_log.error(f"[OpenClawBot] query_monthly_summary {month_str}: {e}")
return {'found': False, 'month': month_str}
def query_date_range(start_str: str, end_str: str) -> dict:
"""查詢指定日期區間業績start/end 格式 YYYY/MM/DD"""
try:
with _db().connect() as c:
row = c.execute(text("""
SELECT COUNT(DISTINCT "訂單編號"),
COALESCE(SUM(CAST("總業績" AS FLOAT)),0),
COALESCE(SUM(CAST("總成本" AS FLOAT)),0),
COUNT(DISTINCT "商品ID"),
COUNT(DISTINCT "日期")
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
"""), {'s': start_str.replace('/', '-'), 'e': end_str.replace('/', '-')}).fetchone()
daily = c.execute(text("""
SELECT "日期", SUM(CAST("總業績" AS FLOAT))
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
GROUP BY "日期" ORDER BY "日期" ASC
"""), {'s': start_str.replace('/', '-'), 'e': end_str.replace('/', '-')}).fetchall()
if not row or row[0] == 0:
return {'found': False, 'range': f'{start_str}~{end_str}'}
orders, revenue, cost = row[0], row[1], row[2]
return {
'found': True, 'range': f'{start_str}~{end_str}',
'orders': orders, 'revenue': revenue,
'gross_margin': (revenue - cost) / revenue * 100 if revenue > 0 else 0,
'avg_order': revenue / orders if orders else 0,
'products': row[3], 'days_with_data': row[4],
'daily': [{'date': str(r[0]), 'revenue': float(r[1])} for r in daily],
}
except Exception as e:
sys_log.error(f"[OpenClawBot] query_date_range: {e}")
return {'found': False, 'range': f'{start_str}~{end_str}'}
def query_competitor_5forces(competitor: str = 'PChome',
period: str = '近 30 天') -> dict:
"""競業五力評分(半實作版)— 6 維度 0-10 分
momo / 競品評分基於:
- 商品力:自家 SKU 數已有vs 競品 SKU 數外部API待擴靜態 fallback
- 價格力:既有 competitor 比價結果(含則用)
- 行銷力mcp Dcard/Trends 訊號 + 靜態 fallback
- 服務力:靜態知識
- 品牌力mcp Dcard 提及度 + 靜態
- 財務力:靜態知識(上市公司資訊)
回傳:{
forces: {
product_power: {momo, competitor, analysis},
price_power, marketing_power, service_power,
brand_power, financial_power
},
competitor, period
}
"""
try:
with _db().connect() as c:
sku_row = c.execute(text("""
SELECT COUNT(DISTINCT "商品ID")
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '30 days'
""")).fetchone()
momo_sku = int(sku_row[0] or 0) if sku_row else 0
except Exception:
momo_sku = 0
# 商品力評分(依 SKU 數,業界 momo 通常 5000+ 為高分3000- 為中下)
momo_prod_score = min(10, max(2, momo_sku / 1000))
# PChome 假設值(無外部 API 取,用業界共識)
pc_prod_score = 7.5 # PChome 3C/家電 SKU 多
# 價格力評分(從靜態知識)
momo_price_score = 7.0
pc_price_score = 7.5
# 行銷力(嘗試從 mcp 取)
try:
from services.mcp_collector_service import mcp_collector
# 簡化:靜態評分
momo_mkt_score = 8.0 # 電視購物頻道 + 訂閱制
pc_mkt_score = 6.5
except Exception:
momo_mkt_score = 7.5
pc_mkt_score = 7.0
# 服務力(靜態:免運/到貨/退貨)
momo_svc_score = 7.0 # 24h 到貨大部分區域
pc_svc_score = 8.5 # 24h 到貨 + 3C 服務優勢
# 品牌力(從 Dcard / Trends 取訊號 — 簡化)
momo_brand_score = 7.5
pc_brand_score = 7.0
# 財務力(上市公司基本面)
momo_fin_score = 8.0 # 富邦集團,市值穩健
pc_fin_score = 6.5 # 早期老牌但獲利壓力
return {
'competitor': competitor,
'period': period,
'forces': {
'product_power': {
'momo': momo_prod_score, 'competitor': pc_prod_score,
'analysis': (
f"momo SKU 數約 {momo_sku:,} 件(近 30 天有交易),"
f"涵蓋生活百貨/美妝/母嬰廣度高;\n\n"
f"PChome 強項在 3C/家電獨家代理多SKU 廣度估 1.5x momo\n\n"
f"差異化建議momo 加碼母嬰高端、永續美妝、銀髮保健等"
f"PChome 弱項品類,避戰 3C 直球對決。"
),
},
'price_power': {
'momo': momo_price_score, 'competitor': pc_price_score,
'analysis': (
"依既有 PChome vs momo 比價資料:\n\n"
"• 美妝/保健:兩家平均價差 < 5%,主要差在獨家品牌折扣\n"
"• 3C/家電PChome 略低 5-10%(量價優勢 + 獨家代理)\n"
"• 母嬰:兩家差異小,主要看活動檔期\n\n"
"差異化建議momo 用會員專屬折扣(訂閱會員 95 折)+ "
"富邦銀行卡 1.5% 回饋避免直接價格戰。"
),
},
'marketing_power': {
'momo': momo_mkt_score, 'competitor': pc_mkt_score,
'analysis': (
"momo 行銷武器:\n"
"• 電視購物頻道整合(東森/momo 購物台)\n"
"• 直播帶貨(每日多場)\n"
"• 訂閱制(自動續訂)\n"
"• 社群行銷IG/FB 粉絲團活躍)\n\n"
"PChome 行銷武器:\n"
"• EDM + 推播(會員忠誠度高)\n"
"• 24h 物流主打廣告\n"
"• 較少直播帶貨佈局\n\n"
"差異化建議momo 持續加碼直播帶貨 + IP 聯名活動。"
),
},
'service_power': {
'momo': momo_svc_score, 'competitor': pc_svc_score,
'analysis': (
"momo 服務:\n"
"• 免運門檻NT$490\n"
"• 到貨:大部分區域 24h部分品類 6h\n"
"• 退貨7 天鑑賞期\n\n"
"PChome 服務3C 強項):\n"
"• 免運門檻NT$490\n"
"• 到貨24h 物流招牌(領先業界)\n"
"• 退貨7 天鑑賞期 + 1 年保固服務\n\n"
"差異化建議momo 強化「禮盒包裝服務」+ 「VIP 會員到府收件」"
"等 PChome 弱項服務點。"
),
},
'brand_power': {
'momo': momo_brand_score, 'competitor': pc_brand_score,
'analysis': (
"Dcard / Mobile01 / Google Trends 訊號:\n\n"
"• momo搜尋熱度穩定高社群討論「電視購物」品牌印象強"
"客群偏家庭主婦+上班族\n\n"
"• PChome3C/家電社群口碑強Mobile01 活躍),"
"但年輕族群Dcard偏好蝦皮、酷澎\n\n"
"差異化建議momo 鎖定 30-50 歲女性 + 家庭客群,"
"強化「值得信賴的家庭購物」品牌定位。"
),
},
'financial_power': {
'momo': momo_fin_score, 'competitor': pc_fin_score,
'analysis': (
"上市公司基本面(公開資料):\n\n"
"momo (8454 富邦媒)\n"
"• 市值約 NT$1100 億\n"
"• 富邦集團背景,金融資源充足\n"
"• 月營收公開於公開資訊觀測站\n\n"
"PChome (8044 網家)\n"
"• 上市公司,老牌電商\n"
"• 近年獲利壓力較大(受蝦皮/酷澎夾擊)\n\n"
"蝦皮(母公司 SEA Group, NYSE: SE全球生態系\n"
"酷澎:韓國總部,未上市,激進補貼\n\n"
"差異化建議momo 善用富邦集團資源(銀行/保險/電信交叉銷售)"
"形成生態系護城河。"
),
},
},
}
def query_price_elasticity(category: str = None, days: int = 90) -> dict:
"""價格彈性簡化版:分析品類(或全平台)的「價格甜蜜點」
對每個 SKU 算平均售價(總業績/數量),按價位分桶,看每桶的訂單數+業績。
識別「訂單最多的價位區間」= 該品類消費者最買單的價格甜蜜點。
回傳:{
category, days, sku_count, total_orders,
buckets: [{range, sku_count, total_orders, total_revenue, avg_price}],
sweet_spot: {range, total_orders, ratio},
top_sku_by_bucket: {bucket_key: [TOP 5 SKU]}
}
"""
try:
cat_filter = ('AND "商品分類L1" = :cat' if category else '')
bind = {'cat': category} if category else {}
with _db().connect() as c:
sku_rows = c.execute(text(f"""
SELECT "商品ID", "商品名稱",
"商品分類L1",
SUM(CAST("總業績" AS FLOAT)) AS rev,
SUM(CAST("數量" AS INTEGER)) AS qty,
COUNT(DISTINCT "訂單編號") AS orders
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days} days'
{cat_filter}
GROUP BY "商品ID", "商品名稱", "商品分類L1"
HAVING SUM(CAST("數量" AS INTEGER)) > 0
"""), bind).fetchall()
# Python 端做價位分桶PostgreSQL CASE 太冗長)
buckets_def = [
('< NT$200', 0, 200),
('NT$200-500', 200, 500),
('NT$500-1K', 500, 1000),
('NT$1K-2K', 1000, 2000),
('NT$2K-5K', 2000, 5000),
('NT$5K-10K', 5000, 10000),
('> NT$10K', 10000, float('inf')),
]
buckets = []
top_sku_by_bucket = {}
for label, lo, hi in buckets_def:
in_bucket = []
for r in sku_rows:
rev, qty = float(r[3] or 0), int(r[4] or 0)
if qty == 0:
continue
avg_price = rev / qty
if lo <= avg_price < hi:
in_bucket.append({
'id': r[0], 'name': r[1], 'cat': r[2] or '',
'avg_price': avg_price, 'rev': rev,
'qty': qty, 'orders': int(r[5] or 0),
})
in_bucket.sort(key=lambda x: -x['orders'])
total_orders = sum(s['orders'] for s in in_bucket)
total_rev = sum(s['rev'] for s in in_bucket)
avg_price_bucket = (sum(s['avg_price'] * s['qty'] for s in in_bucket)
/ sum(s['qty'] for s in in_bucket)
if sum(s['qty'] for s in in_bucket) else 0)
buckets.append({
'range': label,
'sku_count': len(in_bucket),
'total_orders': total_orders,
'total_revenue': total_rev,
'avg_price': avg_price_bucket,
})
top_sku_by_bucket[label] = in_bucket[:5]
# 價格甜蜜點:訂單最多的桶
if buckets:
sweet = max(buckets, key=lambda b: b['total_orders'])
total_o = sum(b['total_orders'] for b in buckets) or 1
sweet_spot = {
'range': sweet['range'],
'total_orders': sweet['total_orders'],
'ratio': sweet['total_orders'] / total_o * 100,
'avg_price': sweet['avg_price'],
'sku_count': sweet['sku_count'],
}
else:
sweet_spot = {}
return {
'found': len(sku_rows) > 0,
'category': category or '全平台',
'days': days,
'sku_count': len(sku_rows),
'total_orders': sum(b['total_orders'] for b in buckets),
'buckets': buckets,
'sweet_spot': sweet_spot,
'top_sku_by_bucket': top_sku_by_bucket,
}
except Exception as e:
sys_log.error(f"[query_price_elasticity] {e}")
return {'found': False, 'error': str(e)}
def query_new_products(days_recent: int = 30, days_baseline: int = 60) -> dict:
"""新品追蹤:近 days_recent 天有銷售、過去 days_baseline 天無銷售的商品
回傳:{
period, kpis: {new_count, new_revenue, new_pct, top1_revenue},
new_products: [TOP 50 含日銷售軌跡],
sub_categories: [新品依品類分佈],
daily_total: [{date, new_revenue}], # 新品整體日業績
}
"""
try:
with _db().connect() as c:
# 主查詢:近 N 天 EXCEPT 早期
new_rows = c.execute(text(f"""
WITH recent AS (
SELECT "商品ID", "商品名稱", "商品分類L1",
SUM(CAST("總業績" AS FLOAT)) AS rev,
SUM(CAST("數量" AS INTEGER)) AS qty,
COUNT(DISTINCT "訂單編號") AS orders,
MIN(CAST("日期" AS DATE)) AS first_seen
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days'
GROUP BY "商品ID", "商品名稱", "商品分類L1"
),
early AS (
SELECT DISTINCT "商品ID"
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN
CURRENT_DATE - INTERVAL '{days_recent + days_baseline} days' AND
CURRENT_DATE - INTERVAL '{days_recent + 1} days'
)
SELECT recent.* FROM recent
LEFT JOIN early ON recent."商品ID" = early."商品ID"
WHERE early."商品ID" IS NULL
ORDER BY recent.rev DESC LIMIT 50
""")).fetchall()
# 新品總業績 + 整體業績佔比
new_rev_total = sum(float(r[3] or 0) for r in new_rows)
total_row = c.execute(text(f"""
SELECT COALESCE(SUM(CAST("總業績" AS FLOAT)), 0)
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days'
""")).fetchone()
total_rev = float(total_row[0] or 0)
# 子品類分佈
sub_dist = c.execute(text(f"""
WITH recent AS (
SELECT "商品ID", "商品分類L1",
SUM(CAST("總業績" AS FLOAT)) AS rev
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days'
GROUP BY "商品ID", "商品分類L1"
),
early AS (
SELECT DISTINCT "商品ID"
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN
CURRENT_DATE - INTERVAL '{days_recent + days_baseline} days' AND
CURRENT_DATE - INTERVAL '{days_recent + 1} days'
)
SELECT COALESCE(recent."商品分類L1", '其他') AS cat,
COUNT(*) AS sku_count,
SUM(recent.rev) AS rev
FROM recent
LEFT JOIN early ON recent."商品ID" = early."商品ID"
WHERE early."商品ID" IS NULL
GROUP BY recent."商品分類L1"
ORDER BY 3 DESC LIMIT 10
""")).fetchall()
# 新品整體日業績曲線
new_ids = [r[0] for r in new_rows]
if new_ids:
# 用 ANY array 比較
daily_rows = c.execute(text(f"""
SELECT "日期", SUM(CAST("總業績" AS FLOAT)) AS rev
FROM realtime_sales_monthly
WHERE "商品ID" = ANY(:ids)
AND CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days'
GROUP BY "日期" ORDER BY "日期" ASC
"""), {'ids': new_ids}).fetchall()
else:
daily_rows = []
return {
'found': len(new_rows) > 0,
'period': f"{days_recent}vs 前 {days_baseline} 天 baseline",
'kpis': {
'new_count': len(new_rows),
'new_revenue': new_rev_total,
'total_revenue': total_rev,
'new_pct': new_rev_total / total_rev * 100 if total_rev else 0,
'top1_revenue': float(new_rows[0][3]) if new_rows else 0,
'days_recent': days_recent,
},
'new_products': [
{'id': r[0], 'name': r[1], 'category': r[2] or '',
'revenue': float(r[3] or 0), 'qty': int(r[4] or 0),
'orders': int(r[5] or 0), 'first_seen': str(r[6])}
for r in new_rows
],
'sub_categories': [
{'name': r[0], 'sku_count': int(r[1]),
'revenue': float(r[2] or 0)} for r in sub_dist
],
'daily_total': [
{'date': str(r[0]), 'revenue': float(r[1] or 0)}
for r in daily_rows
],
}
except Exception as e:
sys_log.error(f"[query_new_products] {e}")
return {'found': False, 'error': str(e)}
def query_forecast_pre_event(event_name: str, event_date: str,
before_days: int = 14, after_days: int = 7) -> dict:
"""檔期前瞻:給定檔期日 + 名稱,回傳:
- baseline 期業績(檔期日往前 60-30 天為日常 baseline
- 去年同檔期業績(去年同日期 ± 7 天)
- 本期準備窗口業績(檔期前 before_days
- TOP 商品baseline 期)作為庫存盤點對象
- 預期業績baseline × 預期拉抬倍數)
回傳:{
event_name, event_date, window_start, window_end,
baseline: {revenue, orders, avg_daily_revenue},
last_year: {revenue, orders, daily} (去年同檔期 ± 7 天),
prep_window: {revenue, orders, days_passed, daily},
top_products: [TOP 30 baseline 期商品],
forecast: {expected_revenue, lift_factor, confidence}
}
"""
from datetime import datetime as _dt, timedelta as _td
try:
ev_date = _dt.strptime(event_date.replace('/', '-'), '%Y-%m-%d').date()
window_start = ev_date - _td(days=before_days)
window_end = ev_date + _td(days=after_days)
baseline_start = ev_date - _td(days=60)
baseline_end = ev_date - _td(days=30)
ly_start = ev_date.replace(year=ev_date.year - 1) - _td(days=7)
ly_end = ev_date.replace(year=ev_date.year - 1) + _td(days=7)
with _db().connect() as c:
# baseline檔期前 60-30 天的常態日均)
baseline_row = c.execute(text("""
SELECT COUNT(DISTINCT "訂單編號"),
COALESCE(SUM(CAST("總業績" AS FLOAT)), 0),
COUNT(DISTINCT "日期")
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN :s AND :e
"""), {'s': baseline_start, 'e': baseline_end}).fetchone()
# 去年同檔期 ± 7 天
ly_row = c.execute(text("""
SELECT COUNT(DISTINCT "訂單編號"),
COALESCE(SUM(CAST("總業績" AS FLOAT)), 0)
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN :s AND :e
"""), {'s': ly_start, 'e': ly_end}).fetchone()
ly_daily = c.execute(text("""
SELECT "日期", SUM(CAST("總業績" AS FLOAT))
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN :s AND :e
GROUP BY "日期" ORDER BY "日期" ASC
"""), {'s': ly_start, 'e': ly_end}).fetchall()
# 本期準備窗口(檔期前 before_days 已過的天數)
today = _dt.now().date()
actual_end = min(today, window_end)
prep_row = c.execute(text("""
SELECT COUNT(DISTINCT "訂單編號"),
COALESCE(SUM(CAST("總業績" AS FLOAT)), 0),
COUNT(DISTINCT "日期")
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN :s AND :e
"""), {'s': window_start, 'e': actual_end}).fetchone()
# baseline 期 TOP 30 商品(庫存盤點對象)
prod_rows = c.execute(text("""
SELECT "商品ID", "商品名稱",
SUM(CAST("總業績" AS FLOAT)) AS rev,
SUM(CAST("數量" AS INTEGER)) AS qty,
COUNT(DISTINCT "訂單編號") AS orders
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN :s AND :e
GROUP BY "商品ID", "商品名稱"
ORDER BY 3 DESC LIMIT 30
"""), {'s': baseline_start, 'e': baseline_end}).fetchall()
# 計算 baseline 日均
b_orders, b_rev, b_days = int(baseline_row[0] or 0), float(baseline_row[1] or 0), int(baseline_row[2] or 0)
b_daily = b_rev / b_days if b_days else 0
# 預期拉抬倍數(依檔期靜態知識)
lift_factors = {
'母親節': 1.40, '520': 1.30, '618': 1.45, '父親節': 1.25,
'中秋': 1.25, '雙10': 1.30, '雙11': 1.65, '黑五': 1.45,
'雙12': 1.40, '聖誕': 1.30, '農曆年': 1.50, '婦女節': 1.20,
'情人節': 1.25, '勞動節': 1.15, '端午': 1.20,
}
lift = next((f for k, f in lift_factors.items() if k in event_name), 1.20)
expected_rev = b_daily * (before_days + after_days) * lift
return {
'found': True,
'event_name': event_name,
'event_date': event_date,
'window_start': window_start.strftime('%Y/%m/%d'),
'window_end': window_end.strftime('%Y/%m/%d'),
'baseline': {
'revenue': b_rev, 'orders': b_orders, 'days': b_days,
'avg_daily_revenue': b_daily,
'period': f"{baseline_start} ~ {baseline_end}",
},
'last_year': {
'revenue': float(ly_row[1] or 0),
'orders': int(ly_row[0] or 0),
'period': f"{ly_start} ~ {ly_end}",
'daily': [{'date': str(r[0]), 'revenue': float(r[1] or 0)}
for r in ly_daily],
},
'prep_window': {
'revenue': float(prep_row[1] or 0),
'orders': int(prep_row[0] or 0),
'days_passed': int(prep_row[2] or 0),
'days_total': before_days + after_days,
},
'top_products': [
{'id': r[0], 'name': r[1], 'revenue': float(r[2]),
'qty': int(r[3] or 0), 'orders': int(r[4] or 0)}
for r in prod_rows
],
'forecast': {
'expected_revenue': expected_rev,
'lift_factor': lift,
'confidence': 'high' if (ly_row[1] or 0) > 0 else 'low',
},
}
except Exception as e:
sys_log.error(f"[query_forecast_pre_event] {e}")
return {'found': False, 'error': str(e)}
def query_customer_analytics(start_date: str, end_date: str) -> dict:
"""客戶/訂單分析報告(簡化版 RFM — 因無 user_id改做訂單級分析
回傳:{
kpis: {total_orders, total_revenue, aov, repeat_rate},
aov_buckets: [{range, count, revenue}], # 客單分佈
weekday_dist: [{weekday, count, revenue}], # 星期分佈
repeat_products: [{name, repeat_count, total_orders}], # 商品復購
time_dist: [{hour, count}],
new_vs_active: ...,
}
"""
try:
s = start_date.replace('/', '-')
e = end_date.replace('/', '-')
with _db().connect() as c:
row = c.execute(text("""
SELECT COUNT(DISTINCT "訂單編號"),
COALESCE(SUM(CAST("總業績" AS FLOAT)), 0)
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
"""), {'s': s, 'e': e}).fetchone()
# AOV buckets
aov_rows = c.execute(text("""
WITH order_rev AS (
SELECT "訂單編號",
SUM(CAST("總業績" AS FLOAT)) AS rev
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
GROUP BY "訂單編號"
)
SELECT
CASE
WHEN rev < 500 THEN '< NT$500'
WHEN rev < 1000 THEN 'NT$500-1K'
WHEN rev < 2000 THEN 'NT$1K-2K'
WHEN rev < 5000 THEN 'NT$2K-5K'
WHEN rev < 10000 THEN 'NT$5K-10K'
ELSE '> NT$10K'
END AS bucket,
COUNT(*) AS cnt,
SUM(rev) AS total
FROM order_rev GROUP BY bucket
ORDER BY MIN(rev)
"""), {'s': s, 'e': e}).fetchall()
# 星期分佈
wd_rows = c.execute(text("""
SELECT EXTRACT(DOW FROM CAST("日期" AS DATE)) AS dow,
COUNT(DISTINCT "訂單編號") AS cnt,
SUM(CAST("總業績" AS FLOAT)) AS rev
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
GROUP BY dow ORDER BY dow
"""), {'s': s, 'e': e}).fetchall()
# 商品復購(同商品在多筆訂單中出現)
repeat_rows = c.execute(text("""
SELECT "商品名稱",
COUNT(DISTINCT "訂單編號") AS orders,
SUM(CAST("數量" AS INTEGER)) AS total_qty
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
GROUP BY "商品名稱"
HAVING COUNT(DISTINCT "訂單編號") >= 5
ORDER BY 2 DESC LIMIT 30
"""), {'s': s, 'e': e}).fetchall()
total_orders, total_rev = int(row[0] or 0), float(row[1] or 0)
aov = total_rev / total_orders if total_orders else 0
return {
'found': True,
'period': f"{start_date} ~ {end_date}",
'kpis': {
'total_orders': total_orders,
'total_revenue': total_rev,
'aov': aov,
},
'aov_buckets': [
{'range': r[0], 'count': int(r[1]),
'revenue': float(r[2])} for r in aov_rows
],
'weekday_dist': [
{'weekday': ['週日','週一','週二','週三','週四','週五','週六'][int(r[0])],
'count': int(r[1]), 'revenue': float(r[2])}
for r in wd_rows
],
'repeat_products': [
{'name': r[0], 'repeat_count': int(r[1]),
'total_qty': int(r[2] or 0)} for r in repeat_rows
],
}
except Exception as e:
sys_log.error(f"[query_customer_analytics] {e}")
return {'found': False}
def query_category_deep(category: str, days: int = 90) -> dict:
"""品類深度報告 — 單一品類最近 N 天縱向分析
回傳:{
category: 品類名,
period: 'YYYY/MM/DD ~ YYYY/MM/DD',
kpis: {revenue, orders, gross_margin, avg_order, sku_count, vendor_count, days},
daily: [{date, revenue, orders, qty}], # 逐日趨勢
weekly: [{week, revenue, orders}], # 週聚合
top_products: [TOP 50 該品類商品],
top_vendors: [TOP 30 該品類廠商],
sub_categories: [品類 L2 切分],
new_products: [近 30 天新進榜],
found: bool
}
"""
try:
with _db().connect() as c:
row = c.execute(text(f"""
SELECT MIN(CAST("日期" AS DATE)), MAX(CAST("日期" AS DATE))
FROM realtime_sales_monthly
WHERE "商品分類L1" = :cat
AND CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days} days'
"""), {'cat': category}).fetchone()
if not row or not row[0]:
return {'found': False}
start_date = str(row[0])
end_date = str(row[1])
kpi_row = c.execute(text("""
SELECT COUNT(DISTINCT "訂單編號"),
COALESCE(SUM(CAST("總業績" AS FLOAT)), 0),
COALESCE(SUM(CAST("總成本" AS FLOAT)), 0),
COUNT(DISTINCT "商品ID"),
COUNT(DISTINCT "廠商名稱"),
COUNT(DISTINCT "日期")
FROM realtime_sales_monthly
WHERE "商品分類L1" = :cat
AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
"""), {'cat': category, 's': start_date, 'e': end_date}).fetchone()
daily = c.execute(text("""
SELECT "日期",
SUM(CAST("總業績" AS FLOAT)) AS rev,
COUNT(DISTINCT "訂單編號") AS orders,
SUM(CAST("數量" AS INTEGER)) AS qty
FROM realtime_sales_monthly
WHERE "商品分類L1" = :cat
AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
GROUP BY "日期" ORDER BY "日期" ASC
"""), {'cat': category, 's': start_date, 'e': end_date}).fetchall()
prods = c.execute(text("""
SELECT "商品ID", "商品名稱",
SUM(CAST("總業績" AS FLOAT)) AS rev,
SUM(CAST("數量" AS INTEGER)) AS qty,
COUNT(DISTINCT "訂單編號") AS orders
FROM realtime_sales_monthly
WHERE "商品分類L1" = :cat
AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
GROUP BY "商品ID", "商品名稱"
ORDER BY 3 DESC LIMIT 50
"""), {'cat': category, 's': start_date, 'e': end_date}).fetchall()
vendors = c.execute(text("""
SELECT "廠商名稱",
SUM(CAST("總業績" AS FLOAT)) AS rev,
SUM(CAST("總成本" AS FLOAT)) AS cost
FROM realtime_sales_monthly
WHERE "商品分類L1" = :cat
AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
AND "廠商名稱" IS NOT NULL AND "廠商名稱" != ''
GROUP BY "廠商名稱" ORDER BY 2 DESC LIMIT 30
"""), {'cat': category, 's': start_date, 'e': end_date}).fetchall()
sub_cats = c.execute(text("""
SELECT COALESCE("商品分類L2", '其他') AS l2,
SUM(CAST("總業績" AS FLOAT)) AS rev
FROM realtime_sales_monthly
WHERE "商品分類L1" = :cat
AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
GROUP BY l2 ORDER BY 2 DESC LIMIT 10
"""), {'cat': category, 's': start_date, 'e': end_date}).fetchall()
# 近 30 天 vs 31-90 天,做新進榜判定
new_prods = c.execute(text("""
WITH recent AS (
SELECT "商品ID", "商品名稱",
SUM(CAST("總業績" AS FLOAT)) AS rev_recent
FROM realtime_sales_monthly
WHERE "商品分類L1" = :cat
AND CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY "商品ID", "商品名稱"
),
early AS (
SELECT "商品ID"
FROM realtime_sales_monthly
WHERE "商品分類L1" = :cat
AND CAST("日期" AS DATE) BETWEEN
CURRENT_DATE - INTERVAL '90 days' AND
CURRENT_DATE - INTERVAL '31 days'
GROUP BY "商品ID"
)
SELECT recent."商品ID", recent."商品名稱", recent.rev_recent
FROM recent
LEFT JOIN early ON recent."商品ID" = early."商品ID"
WHERE early."商品ID" IS NULL
ORDER BY recent.rev_recent DESC LIMIT 10
"""), {'cat': category}).fetchall()
orders, revenue, cost = int(kpi_row[0]), float(kpi_row[1]), float(kpi_row[2])
gm = (revenue - cost) / revenue * 100 if revenue > 0 else 0
return {
'found': True,
'category': category,
'period': f"{start_date} ~ {end_date}",
'kpis': {
'revenue': revenue,
'orders': orders,
'gross_margin': gm,
'avg_order': revenue / orders if orders else 0,
'sku_count': int(kpi_row[3] or 0),
'vendor_count': int(kpi_row[4] or 0),
'days': int(kpi_row[5] or 0),
},
'daily': [
{'date': str(r[0]), 'revenue': float(r[1] or 0),
'orders': int(r[2] or 0), 'qty': int(r[3] or 0)}
for r in daily
],
'top_products': [
{'id': r[0], 'name': r[1], 'revenue': float(r[2]),
'qty': int(r[3] or 0), 'orders': int(r[4] or 0)}
for r in prods
],
'top_vendors': [
{'name': r[0], 'sales': float(r[1] or 0),
'profit': float(r[1] or 0) - float(r[2] or 0),
'margin': ((float(r[1] or 0) - float(r[2] or 0)) / float(r[1] or 1) * 100)
if float(r[1] or 0) else 0}
for r in vendors
],
'sub_categories': [
{'name': r[0], 'revenue': float(r[1])} for r in sub_cats
],
'new_products': [
{'id': r[0], 'name': r[1], 'revenue': float(r[2])}
for r in new_prods
],
}
except Exception as e:
sys_log.error(f"[query_category_deep] {e}")
return {'found': False}
def query_period_summary(start_date: str, end_date: str) -> dict:
"""期間業績完整摘要quarterly / half_yearly / annual / ttm 共用)
回傳:{
kpis: {revenue, orders, gross_margin, avg_order, product_count, vendor_count, days},
monthly_breakdown: [{month, revenue, orders, gross_margin}],
top_products: [...],
top_categories: [...],
top_vendors: [...],
found: bool
}
"""
try:
s = start_date.replace('/', '-')
e = end_date.replace('/', '-')
with _db().connect() as c:
row = c.execute(text("""
SELECT COUNT(DISTINCT "訂單編號"),
COALESCE(SUM(CAST("總業績" AS FLOAT)), 0),
COALESCE(SUM(CAST("總成本" AS FLOAT)), 0),
COUNT(DISTINCT "商品ID"),
COUNT(DISTINCT "廠商名稱"),
COUNT(DISTINCT "日期")
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
"""), {'s': s, 'e': e}).fetchone()
# 月度聚合YYYY-MM
monthly_rows = c.execute(text("""
SELECT TO_CHAR(CAST("日期" AS DATE), 'YYYY-MM') AS ym,
SUM(CAST("總業績" AS FLOAT)) AS rev,
SUM(CAST("總成本" AS FLOAT)) AS cost,
COUNT(DISTINCT "訂單編號") AS orders
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
GROUP BY ym ORDER BY ym ASC
"""), {'s': s, 'e': e}).fetchall()
# TOP 50 商品
prod_rows = c.execute(text("""
SELECT "商品ID", "商品名稱",
SUM(CAST("總業績" AS FLOAT)) AS rev,
SUM(CAST("數量" AS INTEGER)) AS qty,
COUNT(DISTINCT "訂單編號") AS orders
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
GROUP BY "商品ID", "商品名稱"
ORDER BY 3 DESC LIMIT 50
"""), {'s': s, 'e': e}).fetchall()
# TOP 8 品類
cat_rows = c.execute(text("""
SELECT "商品分類L1", SUM(CAST("總業績" AS FLOAT)) AS rev
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
AND "商品分類L1" IS NOT NULL AND "商品分類L1" != ''
GROUP BY "商品分類L1" ORDER BY 2 DESC LIMIT 8
"""), {'s': s, 'e': e}).fetchall()
# TOP 30 廠商
vendor_rows = c.execute(text("""
SELECT "廠商名稱",
SUM(CAST("總業績" AS FLOAT)) AS rev,
SUM(CAST("總成本" AS FLOAT)) AS cost
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
AND "廠商名稱" IS NOT NULL AND "廠商名稱" != ''
GROUP BY "廠商名稱" ORDER BY 2 DESC LIMIT 30
"""), {'s': s, 'e': e}).fetchall()
if not row or row[0] == 0:
return {'found': False}
orders, revenue, cost = int(row[0]), float(row[1]), float(row[2])
gm = (revenue - cost) / revenue * 100 if revenue > 0 else 0
return {
'found': True,
'kpis': {
'revenue': revenue,
'orders': orders,
'gross_margin': gm,
'avg_order': revenue / orders if orders else 0,
'product_count': int(row[3] or 0),
'vendor_count': int(row[4] or 0),
'days': int(row[5] or 0),
},
'monthly_breakdown': [
{'month': r[0],
'revenue': float(r[1] or 0),
'gross_margin': ((float(r[1] or 0) - float(r[2] or 0)) / float(r[1] or 1) * 100)
if float(r[1] or 0) else 0,
'orders': int(r[3] or 0)}
for r in monthly_rows
],
'top_products': [
{'id': r[0], 'name': r[1], 'revenue': float(r[2]),
'qty': int(r[3] or 0), 'orders': int(r[4] or 0)}
for r in prod_rows
],
'top_categories': [
{'cat': r[0], 'revenue': float(r[1])} for r in cat_rows
],
'top_vendors': [
{'name': r[0],
'sales': float(r[1] or 0),
'profit': float(r[1] or 0) - float(r[2] or 0),
'margin': ((float(r[1] or 0) - float(r[2] or 0)) / float(r[1] or 1) * 100)
if float(r[1] or 0) else 0}
for r in vendor_rows
],
}
except Exception as e:
sys_log.error(f"[query_period_summary] {e}")
return {'found': False}
def query_available_months() -> list:
"""取得 DB 中有資料的月份清單(支援 YYYY/MM/DD 和 YYYY-MM-DD 兩種日期格式)"""
try:
with _db().connect() as c:
rows = c.execute(text("""
SELECT REPLACE(SUBSTRING("日期"::TEXT, 1, 7), '-', '/') AS ym,
COUNT(DISTINCT "日期") AS days
FROM realtime_sales_monthly
GROUP BY ym ORDER BY ym DESC LIMIT 24
""")).fetchall()
return [{'month': r[0], 'days': r[1]} for r in rows]
except Exception:
return []
def query_top_products_range(start_str: str, end_str: str, lim: int = 10) -> list:
"""指定區間熱銷商品"""
try:
with _db().connect() as c:
rows = c.execute(text("""
SELECT "商品ID", "商品名稱",
SUM(CAST("總業績" AS FLOAT)) as rev,
SUM(CAST("數量" AS INTEGER)) as qty
FROM realtime_sales_monthly
WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE)
GROUP BY "商品ID", "商品名稱" ORDER BY 3 DESC LIMIT :lim
"""), {'s': start_str.replace('/', '-'),
'e': end_str.replace('/', '-'), 'lim': lim}).fetchall()
return [{'id': r[0], 'name': r[1], 'revenue': float(r[2]), 'qty': int(r[3])}
for r in rows]
except Exception:
return []
def resolve_date(q: str) -> str:
"""從問題文字解析目標日期,回傳 YYYY/MM/DD"""
import re
today = datetime.now(TAIPEI_TZ).date()
if '昨天' in q or '昨日' in q:
return (today - timedelta(days=1)).strftime('%Y/%m/%d')
if '前天' in q:
return (today - timedelta(days=2)).strftime('%Y/%m/%d')
if '大前天' in q:
return (today - timedelta(days=3)).strftime('%Y/%m/%d')
# 明確日期格式2026/04/15 or 2026-04-15 or 04/15
m = re.search(r'(\d{4})[/-](\d{1,2})[/-](\d{1,2})', q)
if m:
return f"{m.group(1)}/{int(m.group(2)):02d}/{int(m.group(3)):02d}"
m = re.search(r'(\d{1,2})[/-](\d{1,2})(?![/-]\d)', q)
if m:
return f"{today.year}/{int(m.group(1)):02d}/{int(m.group(2)):02d}"
return latest_date() or today.strftime('%Y/%m/%d')
def resolve_query_intent(q: str) -> dict:
"""
NIM fallback 用:解析問題中的時間參數
types: 'day' / 'month' / 'range' / 'all'
Function Calling 架構不需要此函數,僅 NIM 備援使用)
"""
import re
today = datetime.now(TAIPEI_TZ).date()
# ── 月份查詢 ─────────────────────────────────────────────
# 「上個月」「上月」
if any(kw in q for kw in ['上個月', '上月', '上月份']):
first = today.replace(day=1)
last_m = (first - timedelta(days=1))
return {'type': 'month', 'year': last_m.year, 'month': last_m.month}
# 「這個月」「本月」
if any(kw in q for kw in ['這個月', '本月', '這月', '當月']):
return {'type': 'month', 'year': today.year, 'month': today.month}
# 「N月」or「N月份」
m = re.search(r'(\d{1,2})月(?:份)?', q)
if m:
month_num = int(m.group(1))
if 1 <= month_num <= 12:
year = today.year if month_num <= today.month else today.year - 1
# 也可能帶年份
ym = re.search(r'(\d{4})年.*?(\d{1,2})月', q)
if ym:
year = int(ym.group(1))
month_num = int(ym.group(2))
return {'type': 'month', 'year': year, 'month': month_num}
# ── 週查詢 ───────────────────────────────────────────────
if any(kw in q for kw in ['上週', '上个星期', '上星期', '上週']):
mon = today - timedelta(days=today.weekday() + 7)
sun = mon + timedelta(days=6)
return {'type': 'range', 'start': mon.strftime('%Y/%m/%d'),
'end': sun.strftime('%Y/%m/%d'), 'label': '上週'}
if any(kw in q for kw in ['這週', '本週', '這周', '本周']):
mon = today - timedelta(days=today.weekday())
return {'type': 'range', 'start': mon.strftime('%Y/%m/%d'),
'end': today.strftime('%Y/%m/%d'), 'label': '本週'}
# ── 近N天 ─────────────────────────────────────────────────
m = re.search(r'近(\d+)[天日]', q)
if m:
n = int(m.group(1))
start = (today - timedelta(days=n - 1)).strftime('%Y/%m/%d')
return {'type': 'range', 'start': start,
'end': today.strftime('%Y/%m/%d'), 'label': f'{n}'}
# ── 多月查詢(全部資料/歷史)──────────────────────────────
if any(kw in q for kw in ['全部', '所有', '歷史', '整年', '今年', '全年', '各月']):
return {'type': 'all'}
# ── 預設:單日 ────────────────────────────────────────────
return {'type': 'day', 'date': resolve_date(q)}
# ── 格式化 ────────────────────────────────────────────────────
MEDALS = ['🥇','🥈','🥉','4','5','6','7','8','9','🔟']
def fmt_sales(sales, top, report_url):
if not sales.get('found'):
return (f"⚠️ *查無資料*\n\n"
f"`{sales.get('date','?')}` 尚無業績資料\n"
f"請確認當日資料是否已匯入系統。")
rev = float(sales.get('revenue', 0))
orders = sales.get('orders', 0) or 0
avg_o = float(sales.get('avg_order', 0))
margin = float(sales.get('gross_margin', 0))
prods = sales.get('products', 0) or 0
rev_wan = rev / 10000
lines = [
f"📊 *{sales['date']} 業績總覽*",
f"{'' * 26}",
f"",
f"💰 總業績  `NT$ {rev:,.0f}`",
f"📦 訂單數量 `{orders:,}` 筆",
f"🛒 平均客單 `NT$ {avg_o:,.0f}`",
f"📈 毛利率  `{margin:.1f}%`",
f"🛍 商品件數 `{prods:,}` 件",
]
if top:
lines.append(f"")
lines.append(f"{'' * 26}")
lines.append(f"🏆 *今日熱銷 TOP3*")
for i, p in enumerate(top[:3]):
pid = p.get('id', '') or ''
sid = _short_id(pid)
link = _pchome_link(pid, p['name'], 22)
rev_p = p['revenue']
qty = p.get('qty', '-')
lines.append(f" {MEDALS[i]} {link}")
lines.append(f" 🆔 `{sid}` 💰 `NT$ {rev_p:,.0f}` 📦 {qty}")
lines.append(f"")
lines.append(f"📥 點選下方「完整報表」按鈕下載 Excel")
return "\n".join(lines)
def _esc(s: str) -> str:
"""Escape Telegram Markdown v1 special chars in plain text"""
for ch in ('_', '*', '`', '['):
s = s.replace(ch, f'\\{ch}')
return s
PCHOME_URL = 'https://24h.pchome.com.tw/prod/'
def _pchome_link(pid: str, name: str, max_len: int = 22) -> str:
"""生成可點選的 PChome 商品頁 Markdown 連結Telegram Markdown v1"""
safe = name[:max_len].replace('[', '').replace(']', '').replace('(', '').replace(')', '')
if pid and pid.strip():
return f"[{safe}]({PCHOME_URL}{pid.strip()})"
return _esc(name[:max_len])
def fmt_products(products, date_str):
if not products:
return f"⚠️ *查無資料*\n\n`{date_str}` 尚無商品業績資料"
# 計算合計
total_rev = sum(p['revenue'] for p in products)
total_qty = sum(p.get('qty', 0) or 0 for p in products)
lines = [
f"🏆 *{date_str} 熱銷商品排行*",
f"{len(products)} 件商品 | 合計 `NT$ {total_rev:,.0f}` | {total_qty:,}",
f"{'' * 30}",
"",
]
for i, p in enumerate(products):
pid = p.get('id', '') or ''
sid = _short_id(pid)
link = _pchome_link(pid, p['name'], 22)
rev = p['revenue']
qty = p.get('qty', 0) or 0
pct = rev / total_rev * 100 if total_rev else 0
medal = MEDALS[i] if i < len(MEDALS) else f"`{i+1}.`"
bar_len = max(1, int(pct / 100 * 8))
mini_bar = '' * bar_len + '·' * (8 - bar_len)
lines.append(f"{medal} {link}")
lines.append(f" 🆔 `{sid}` 💰 `NT$ {rev:,.0f}` 📦 {qty}件 `{mini_bar}` {pct:.1f}%")
lines.append("")
lines.append(f"_💡 點商品名稱可開啟 PChome 商品頁_")
return "\n".join(lines)
def fmt_vendors(vendors, date_str):
if not vendors:
return f"⚠️ *查無資料*\n\n`{date_str}` 尚無廠商業績資料"
total_rev = sum(v['revenue'] for v in vendors)
lines = [
f"🏭 *{date_str} 熱銷廠商排行*",
f"{len(vendors)} 家廠商 | 合計 `NT$ {total_rev:,.0f}`",
f"{'' * 30}",
"",
]
for i, v in enumerate(vendors):
medal = MEDALS[i] if i < len(MEDALS) else f"`{i+1}.`"
name = _esc(v['name'][:24])
rev = v['revenue']
pct = rev / total_rev * 100 if total_rev else 0
bar_len = max(1, int(pct / 100 * 8))
mini_bar = '' * bar_len + '·' * (8 - bar_len)
lines.append(f"{medal} *{name}*")
lines.append(f" 💰 `NT$ {rev:,.0f}` `{mini_bar}` 佔比 {pct:.1f}%")
lines.append("")
return "\n".join(lines)
def fmt_trend(weekly, period_label: str = ''):
"""格式化趨勢資料 — 彩色進度條 + emoji + 統計摘要(週/月用)"""
if not weekly:
return "⚠️ *尚無趨勢資料*\n\n請確認資料是否已匯入。"
# 按日期升序排列
data = sorted(weekly, key=lambda x: x['date'])
revs = [w['revenue'] for w in data]
max_rev = max(revs) if revs else 1
min_rev = min(revs) if revs else 0
avg_rev = sum(revs) / len(revs) if revs else 0
total = sum(revs)
BAR_LEN = 6
WEEKDAYS_ZH = ['週一', '週二', '週三', '週四', '週五', '週六', '週日']
label = period_label or f'{len(data)}'
lines = [f"📈 *業績趨勢 — {label}*", ""]
for i, w in enumerate(data):
rev = w['revenue']
filled = max(0, min(BAR_LEN, int(rev / max_rev * BAR_LEN) if max_rev else 0))
empty = BAR_LEN - filled
# 台灣慣例:上升=🟥紅, 下降=🟩綠, 初始=🟦藍
if i == 0:
bar_str = '🟦' * filled + '▪️' * empty
dir_icon = '🔵'
pct_str = ''
else:
diff = rev - data[i - 1]['revenue']
prev = data[i - 1]['revenue'] or 1
pct_chg = diff / prev * 100
if diff > 0:
bar_str = '🟥' * filled + '▪️' * empty
dir_icon = '📈'
pct_str = f" ▲`{pct_chg:.1f}%`"
elif diff < 0:
bar_str = '🟩' * filled + '▪️' * empty
dir_icon = '📉'
pct_str = f" ▼`{abs(pct_chg):.1f}%`"
else:
bar_str = '🟦' * filled + '▪️' * empty
dir_icon = ''
pct_str = ''
# 日期 + 星期
try:
from datetime import datetime as _dt2
d_obj = _dt2.strptime(w['date'].replace('/', '-'), '%Y-%m-%d')
date_disp = d_obj.strftime('%m/%d')
wday = WEEKDAYS_ZH[d_obj.weekday()]
except Exception:
date_disp = w['date'][-5:]
wday = ''
rev_wan = rev / 10000
lines.append(f"{dir_icon} `{date_disp}` {wday} {bar_str}{pct_str}")
lines.append(f"   💰 `NT$ {rev:>10,.0f}` ({rev_wan:.1f}萬)")
# 統計摘要
lines.append("")
lines.append("─────────────────────")
lines.append("📊 *統計摘要*")
lines.append(f" 📦 合計:`NT$ {total:,.0f}` ({total/10000:.1f}萬)")
lines.append(f" 📐 日均:`NT$ {avg_rev:,.0f}` ({avg_rev/10000:.1f}萬)")
if len(revs) >= 2:
up_days = sum(1 for j in range(1, len(revs)) if revs[j] > revs[j - 1])
down_days = sum(1 for j in range(1, len(revs)) if revs[j] < revs[j - 1])
flat_days = len(revs) - 1 - up_days - down_days
lines.append(f" 📈 上升 {up_days}天 📉 下降 {down_days}天 → 持平 {flat_days}")
lines.append(f" 🏆 最高:`NT$ {max_rev:,.0f}` ({max_rev/10000:.1f}萬)")
lines.append(f" 🔻 最低:`NT$ {min_rev:,.0f}` ({min_rev/10000:.1f}萬)")
last_diff = revs[-1] - revs[-2]
last_pct = last_diff / (revs[-2] or 1) * 100
icon = '🔴' if last_diff >= 0 else '🟢'
arrow = '' if last_diff >= 0 else ''
lines.append(f" {icon} 最新日:{arrow}`{abs(last_pct):.1f}%` (`NT$ {last_diff:+,.0f}`)")
return "\n".join(lines)
def fmt_trend_summary(data: list, period_label: str) -> str:
"""長週期趨勢摘要(不顯示逐日列表,只顯示統計,搭配圖表使用)"""
if not data:
return "⚠️ *尚無趨勢資料*\n\n請確認資料是否已匯入。"
data = sorted(data, key=lambda x: x['date'])
revs = [w['revenue'] for w in data]
total = sum(revs)
avg_rev = total / len(revs) if revs else 0
max_rev = max(revs)
min_rev = min(revs)
max_date = data[revs.index(max_rev)]['date'][-5:]
min_date = data[revs.index(min_rev)]['date'][-5:]
up_days = sum(1 for j in range(1, len(revs)) if revs[j] > revs[j-1])
dn_days = sum(1 for j in range(1, len(revs)) if revs[j] < revs[j-1])
ft_days = len(revs) - 1 - up_days - dn_days
def _fmt_date(d):
# 將 YYYY-MM-DD 或 YYYY/MM/DD 轉成 YYYY/MM/DD
return str(d).replace('-', '/')[:10]
start_d = _fmt_date(data[0]['date'])
end_d = _fmt_date(data[-1]['date'])
# 前半段 vs 後半段比較
mid = len(revs) // 2
first = sum(revs[:mid]) / mid if mid else 0
last = sum(revs[mid:]) / (len(revs) - mid) if len(revs) > mid else 0
trend_icon = '📈' if last >= first else '📉'
trend_pct = (last - first) / first * 100 if first else 0
lines = [
f"📊 *業績趨勢摘要 — {period_label}*",
f" {start_d} ~ {end_d}{len(revs)} 天有資料",
"",
f" 💰 *合計*`NT$ {total:,.0f}` ({total/10000:.1f}萬)",
f" 📐 *日均*`NT$ {avg_rev:,.0f}` ({avg_rev/10000:.1f}萬)",
"",
f" 🏆 *最高*`NT$ {max_rev:,.0f}` ({max_rev/10000:.1f}萬) [{max_date}]",
f" 🔻 *最低*`NT$ {min_rev:,.0f}` ({min_rev/10000:.1f}萬) [{min_date}]",
"",
f" 📈 上升 {up_days}天 📉 下降 {dn_days}天 → 持平 {ft_days}",
f" {trend_icon} 整體趨勢:後半段日均 vs 前半段 {'+' if trend_pct >= 0 else ''}{trend_pct:.1f}%",
"",
"_📊 走勢圖如下_",
]
return "\n".join(lines)
def gen_aggregated_chart(data_points: list, title: str, granularity: str = 'weekly') -> str:
"""產生聚合柱狀圖 PNG — 週/月粒度,上升紅/下降綠
granularity: 'weekly' → 按週分組, 'monthly' → 按月分組
"""
try:
import matplotlib
matplotlib.use('Agg')
_setup_mpl_chinese()
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from datetime import datetime as _dt3
import tempfile
from collections import defaultdict
data = sorted(data_points, key=lambda x: x['date'])
if not data:
return ''
buckets = defaultdict(float)
for w in data:
try:
d_obj = _dt3.strptime(w['date'].replace('/', '-'), '%Y-%m-%d')
if granularity == 'monthly':
key = d_obj.strftime('%Y/%m')
else:
# ISO week key: YYYY-Www
iso = d_obj.isocalendar()
key = f"{iso[0]}/W{iso[1]:02d}"
except Exception:
continue
buckets[key] += w['revenue']
if not buckets:
return ''
labels = sorted(buckets.keys())
revenues = [buckets[k] / 10000 for k in labels] # 萬元
UP_COLOR = '#E53935'
DOWN_COLOR = '#43A047'
NEUTRAL = '#1565C0'
colors = []
for i, r in enumerate(revenues):
if i == 0:
colors.append(NEUTRAL)
elif r >= revenues[i - 1]:
colors.append(UP_COLOR)
else:
colors.append(DOWN_COLOR)
fig, ax = plt.subplots(figsize=(max(10, len(labels) * 0.9), 6))
fig.patch.set_facecolor('#FAFAFA')
ax.set_facecolor('#FAFAFA')
bars = ax.bar(range(len(labels)), revenues, color=colors,
edgecolor='white', linewidth=0.8, width=0.65)
# 金額標籤
for i, (bar, r) in enumerate(zip(bars, revenues)):
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + max(revenues) * 0.02,
f'{r:.1f}', ha='center', va='bottom', fontsize=8.5,
color=colors[i], fontweight='bold')
# 漲跌百分比標籤
for i in range(1, len(revenues)):
pct = (revenues[i] - revenues[i-1]) / revenues[i-1] * 100 if revenues[i-1] else 0
arrow = '' if pct >= 0 else ''
ax.text(i, revenues[i] / 2,
f'{arrow}{abs(pct):.1f}%', ha='center', va='center',
fontsize=7.5, color='white', fontweight='bold')
# 平均線
avg = sum(revenues) / len(revenues)
ax.axhline(avg, color='#FF6F00', linestyle='--', linewidth=1.5, alpha=0.8,
label=f'平均 {avg:.1f}')
ax.set_xticks(range(len(labels)))
ax.set_xticklabels(labels, fontsize=9, rotation=30 if len(labels) > 8 else 0)
ax.set_ylabel('業績(萬元)', fontsize=11)
ax.set_title(title, fontsize=13, fontweight='bold', pad=12)
ax.grid(True, axis='y', alpha=0.25, linestyle='--', color='#BDBDBD')
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'{x:,.0f}'))
ax.legend(fontsize=9, framealpha=0.85)
up_p = mpatches.Patch(color=UP_COLOR, label='▲ 上升')
dn_p = mpatches.Patch(color=DOWN_COLOR, label='▼ 下降')
ax.legend(handles=[up_p, dn_p] + ax.get_legend_handles_labels()[0],
loc='upper left', fontsize=9, framealpha=0.85)
plt.tight_layout()
tmp = tempfile.NamedTemporaryFile(
suffix='.png', prefix='ocbot_agg_', delete=False, dir='/tmp')
plt.savefig(tmp.name, dpi=130, bbox_inches='tight')
plt.close()
return tmp.name
except ImportError:
sys_log.warning('[OpenClawBot] matplotlib not installed')
return ''
except Exception as e:
sys_log.error(f'[OpenClawBot] gen_aggregated_chart: {e}')
return ''
# ── AI 自然語言 ───────────────────────────────────────────────
# 功能說明關鍵詞P8讓使用者問功能時能得到正確答案
_HELP_KEYWORDS = (
'怎麼用', '如何用', '怎麼操作', '教我', '說明', '功能', '按鈕',
'怎麼查', '哪個按鈕', '在哪裡', '怎麼產出', '怎麼看', '如何查',
'圖片比價', '照片比價', '如何比價', '怎麼比價',
'簡報怎麼', '怎麼出簡報', '如何產報告', 'PPT怎麼', 'ppt怎麼',
'目標怎麼設', '如何設定目標', '怎麼設目標',
'告警', '異常通知', '怎麼知悉',
'早報', '晚報', '週報', '自動推播',
'使用說明', '操作說明', 'help', '幫助',
)
_WAKEUP_KEYWORDS = (
'小o',
'小o小龍蝦',
'小o_小龍蝦',
'小龍蝦',
'openclaw',
)
_BUSINESS_KEYWORDS = (
'業績', '營收', '銷售', '銷量', '熱銷', '商品', '廠商', '目標',
'報表', '趨勢', '分析', '簡報', '比價', '競品', '補貨', '促銷',
'異常', '健康', '分類', '策略', '天氣', '新聞', '匯率', '節慶',
'價格', '今日', '昨日', '今天', '本週', '上週', '本月', '上月', '今年',
)
_GREETING_KEYWORDS = (
'你好', '', '哈囉', '早安', '午安', '晚安', '在嗎', '你有空', '在不在',
'hello', 'hi', 'hey',
)
def _normalize_nl_query(q: str) -> str:
"""V-Fix去除 NL 句尾符號與空白,降低誤判率。"""
return re.sub(r'[\s_\-,:;,.,。!?"“”\(\)\[\]<>]+', '', (q or '').lower())
def _contains_business_signal(q: str) -> bool:
"""V-Fix是否包含可直接進 AI 查詢邏輯的業務關鍵字。"""
ql = (q or '').lower()
return any(kw in ql for kw in _BUSINESS_KEYWORDS)
def _looks_like_wakeup_prompt(q: str) -> bool:
"""V-Fix防止只叫名字/打招呼時走自由生成。"""
ql = (q or '').lower().strip()
if not ql:
return False
compact = _normalize_nl_query(ql)
if not compact:
return False
if compact in _WAKEUP_KEYWORDS:
return True
if (
any(name in compact for name in _WAKEUP_KEYWORDS)
and not _contains_business_signal(ql)
and len(compact) <= 18
):
return True
if compact in _GREETING_KEYWORDS and len(compact) <= 10:
return True
return False
def _is_help_question(q: str) -> bool:
ql = q.lower()
return any(kw in ql for kw in _HELP_KEYWORDS)
# ══════════════════════════════════════════════════════════════
# Gemini Function Calling 工具定義
# AI 自主決定要呼叫哪些工具,不再靠 if/else 規則
# ══════════════════════════════════════════════════════════════
_FC_TOOLS = [{
"function_declarations": [
{
"name": "query_sales",
"description": (
"查詢公司內部業績資料庫。"
"當用戶詢問自家業績、銷售數字、訂單量、毛利率、熱銷商品、廠商排行等內部數據時使用。"
),
"parameters": {
"type": "OBJECT",
"properties": {
"period": {
"type": "STRING",
"enum": ["today","yesterday","this_week","last_week",
"this_month","last_month","date","month","range","all"],
"description": "查詢時間範圍"
},
"date": {"type": "STRING", "description": "具體日期 YYYY/MM/DDperiod=date 時填"},
"year": {"type": "INTEGER", "description": "年份period=month 時填"},
"month": {"type": "INTEGER", "description": "月份1-12period=month 時填"},
"start_date": {"type": "STRING", "description": "開始日期 YYYY/MM/DDperiod=range 時填"},
"end_date": {"type": "STRING", "description": "結束日期 YYYY/MM/DDperiod=range 時填"},
},
"required": ["period"]
}
},
{
"name": "get_market_intel",
"description": (
"取得外部市場情報。"
"當用戶詢問市場趨勢、電商新聞、消費者討論PTT/Dcard"
"熱搜關鍵字、YouTube爆紅商品、匯率、天氣、即將到來的節慶促銷檔期等外部資訊時使用。"
),
"parameters": {
"type": "OBJECT",
"properties": {
"sources": {
"type": "ARRAY",
"items": {
"type": "STRING",
"enum": ["news","trends","social","youtube","weather","exchange","calendar"]
},
"description": "需要哪些情報來源,可多選。全部不確定時選 ['news','trends','calendar']"
}
},
"required": ["sources"]
}
},
{
"name": "get_knowledge",
"description": (
"從歷史知識庫和過去 AI 分析記錄中語義檢索相關資訊。"
"當需要參考過去的分析結論、歷史業績模式、或相似問題的回答時使用。"
),
"parameters": {
"type": "OBJECT",
"properties": {
"query": {"type": "STRING", "description": "檢索關鍵字或問題描述"}
},
"required": ["query"]
}
},
{
"name": "check_data_freshness",
"description": (
"查詢內部業績資料的最新可用日期與可用月份清單。"
"ADR-019 Phase 2在回答任何特定時段本月/本週/某月某日)業績問題前,"
"先呼叫此工具確認資料是否已涵蓋該期間,避免回覆「業績為零」這類因 ETL "
"尚未匯入造成的虛假結論。月初/季初、用戶用『本月/本週』等相對時間詞時務必先呼叫。"
),
"parameters": {"type": "OBJECT", "properties": {}, "required": []}
}
]
}]
def _execute_tool(name: str, args: dict) -> dict:
"""執行 Gemini 指定的工具,回傳結構化結果"""
now = datetime.now(TAIPEI_TZ)
today = now.strftime("%Y/%m/%d")
yd = (now - timedelta(days=1)).strftime("%Y/%m/%d")
# ── query_sales ───────────────────────────────────────────
if name == "query_sales":
period = args.get("period", "today")
result = {}
if period in ("today", "date"):
d = args.get("date", today)
sales = query_sales(d)
tops = query_top_products(d, 10)
vens = query_top_vendors(d, 5)
result = {"date": d, "sales": sales, "top_products": tops, "top_vendors": vens}
elif period == "yesterday":
sales = query_sales(yd)
tops = query_top_products(yd, 10)
result = {"date": yd, "sales": sales, "top_products": tops}
elif period in ("this_week", "last_week"):
trend = query_weekly_trend()
result = {"weekly_trend": trend}
elif period == "this_month":
ms = query_monthly_summary(now.year, now.month)
result = {"monthly": ms, "year": now.year, "month": now.month}
elif period == "last_month":
first = now.replace(day=1)
lm = (first - timedelta(days=1))
ms = query_monthly_summary(lm.year, lm.month)
result = {"monthly": ms, "year": lm.year, "month": lm.month}
elif period == "month":
yr = args.get("year", now.year)
mo = args.get("month", now.month)
ms = query_monthly_summary(yr, mo)
result = {"monthly": ms, "year": yr, "month": mo}
elif period == "range":
s = args.get("start_date", yd)
e = args.get("end_date", today)
rng = query_date_range(s, e)
tops = query_top_products_range(s, e, 10)
result = {"range": rng, "start": s, "end": e, "top_products": tops}
elif period == "all":
history = query_daily_history(30)
avail = query_available_months()
result = {"history_30d": history, "available_months": avail}
# 附上可查詢月份清單
avail = query_available_months()
result["available_months"] = [m["month"] for m in avail] if avail else []
result["report_url"] = f"{MOMO_BASE_URL}/reports/daily/{args.get('date', today)}"
return result
# ── get_market_intel ──────────────────────────────────────
elif name == "get_market_intel":
sources = args.get("sources", ["news", "trends", "calendar"])
data = {}
source_map = {
"news": lambda: {**get_tw_media_news(), **{"google": get_ecommerce_news()}},
"trends": get_taiwan_trends,
"social": get_dcard_trends,
"youtube": get_youtube_trending,
"weather": get_taiwan_weather,
"exchange": get_twbank_exchange_rates,
"calendar": get_upcoming_events,
}
for src in sources:
if src in source_map:
try:
data[src] = source_map[src]()
except Exception as e:
data[src] = {"error": str(e)}
return data
# ── get_knowledge ─────────────────────────────────────────
elif name == "get_knowledge":
if not _LEARNING_ENABLED:
return {"results": [], "note": "知識庫未啟用"}
try:
items = retrieve_knowledge(args.get("query", ""), top_k=4, min_quality=0.4)
return {"results": [
{"category": i["category"], "title": i["title"],
"content": i["content"][:400], "score": i["similarity"]}
for i in items
]}
except Exception as e:
return {"results": [], "error": str(e)}
# ── check_data_freshness ──────────────────────────────────
elif name == "check_data_freshness":
# ADR-019 Phase 2讓 agent 在回答業績問題前 probe 資料缺口
latest = latest_date() # 'YYYY-MM-DD' 或 None
avail_months = query_available_months() or []
result = {
"latest_data_date": latest,
"today": today,
"available_months": [m["month"] for m in avail_months],
"month_count": len(avail_months),
}
if latest:
try:
latest_dt = datetime.strptime(latest.replace('/', '-'), '%Y-%m-%d')
gap_days = (now.date() - latest_dt.date()).days
result["gap_days"] = gap_days
result["current_month_has_data"] = (
latest_dt.year == now.year and latest_dt.month == now.month
)
result["data_freshness_warning"] = (
"⚠️ 當月尚無資料,請改詢問上月" if not result["current_month_has_data"]
else ("⚠️ 資料落後 " + str(gap_days) + "" if gap_days > 2 else None)
)
except (ValueError, AttributeError):
pass
return result
return {"error": f"unknown tool: {name}"}
def openclaw_answer(question: str, chat_id: int = None):
"""
Function Calling 架構 — AI 自主決定查什麼、怎麼回答
不再靠 if/else 規則判斷意圖
ADR-019 Phase 4可選 chat_id 啟用對話 state。傳入後 agent 會看到該 chat 的
最近對話歷史,並把本輪 (question, answer) 寫回 session 供下輪使用。
chat_id 為 None 時行為與舊版完全相同(無 multi-turn 記憶)。
"""
from services import openclaw_session
now = datetime.now(TAIPEI_TZ)
today_str = now.strftime("%Y/%m/%d")
history_ctx = openclaw_session.history_as_prompt(chat_id) if chat_id else ""
# ── 只叫名 / 問候:先導回主選單,避免題外市場回覆 ───────────────
if _looks_like_wakeup_prompt(question):
wakeup_text = (
"👋 *OpenClaw小O* 在!\n\n"
"你可以直接點下面按鈕,或直接問我:\n"
" 「今天業績如何?」\n"
" 「怎麼查看熱銷商品?」\n"
" 「有什麼市場情報?」"
)
return wakeup_text, main_menu_keyboard()
# ── 功能說明直接導 help ───────────────────────────────────
if _is_help_question(question):
help_text = (
"📖 *OpenClaw 功能說明*\n\n"
"你可以直接點下方按鈕進入對應功能,或問我:\n\n"
"🔍 *常見問題*\n"
" 「今天業績」→ 點 📊業績查詢 → 今日\n"
" 「熱銷商品」→ 點 🏆商品廠商 → 熱銷商品\n"
" 「出日報PPT」→ 點 📄簡報報表 → 日報\n"
" 「促銷分析」→ 點 📈智能分析 → 促銷追蹤\n"
" 「設定月目標」→ 點 🎯目標管理 → 月目標\n"
" 「圖片比價」→ 直接傳圖片給我\n"
" 「競品比較」→ 點 🔍競品日報\n\n"
"📌 *完整說明*:點下方「❓使用說明」按鈕\n\n"
"_或直接問我任何問題我會自動找資料回答_"
)
return help_text, quick_menu_keyboard()
from services.ollama_service import ollama_service
# ── Ollama 首選(使用傳統 prompt 注入上下文)─────────────────
try:
if ollama_service.check_connection():
intent = resolve_query_intent(question)
today = today_str
yd = (now - timedelta(days=1)).strftime("%Y/%m/%d")
d = intent.get("date", yd) if intent["type"] == "day" else yd
db_ctx = ""
if intent["type"] == "day":
s = query_sales(d)
t = query_top_products(d, 8)
if s.get("found"):
db_ctx = (
f"日期{d} 業績NT${s.get('revenue',0):,.0f} "
f"訂單{s.get('orders',0)}筆 毛利{s.get('gross_margin',0):.1f}% "
f"TOP5: " + " / ".join(
f"[{p.get('id','')}]{p['name']}(NT${p['revenue']:,.0f})"
for p in t[:5])
)
elif intent["type"] == "month":
ms = query_monthly_summary(intent["year"], intent["month"])
if ms.get("found"):
db_ctx = (f"{intent['year']}{intent['month']:02d}"
f"業績NT${ms.get('revenue',0):,.0f} "
f"訂單{ms.get('orders',0)}"
f"毛利{ms.get('gross_margin',0):.1f}%")
mcp_ctx = build_mcp_context(question)
sys_prompt = (
f"你是 OpenClaw小O電商智能助理。今天{today}\n"
+ (f"【最近對話】\n{history_ctx}\n\n" if history_ctx else "")
+ (f"【業績資料】{db_ctx}\n" if db_ctx else "")
+ (f"【市場情報】{mcp_ctx[:400]}\n" if mcp_ctx else "")
+ "請用繁體中文直接回答不要開場白300字以內。"
)
# Phase 1 v5.0: 包 ai_call_logger 追蹤 Bot Q&A 主鏈 Ollama
_qa_req_id = f"qa-{chat_id or 0}-{int(_time_mod.time())}"
with log_ai_call(
caller='openclaw_bot_main',
provider='gcp_ollama',
model=getattr(ollama_service, 'model', 'llama3.1:8b'),
request_id=_qa_req_id,
meta={'chat_id_hash': hashlib.sha1(str(chat_id or 0).encode()).hexdigest()[:8], 'has_db_ctx': bool(db_ctx)},
) as _ctx:
resp = ollama_service.generate(question, system_prompt=sys_prompt, timeout=180)
if resp.success and resp.content:
if chat_id:
openclaw_session.append_turn(chat_id, question, resp.content)
if _LEARNING_ENABLED:
import threading as _thr
_thr.Thread(target=store_conversation,
args=(0, 0, question, resp.content, "ollama", []),
daemon=True).start()
return resp.content, None
else:
sys_log.warning(f"[Ollama] 生成失敗: {resp.error}fallback 到 Gemini")
_ctx.set_error(f"ollama generate failed: {resp.error}")
_ctx.fallback_to_caller('openclaw_bot_gemini')
except Exception as e:
sys_log.warning(f"[Ollama] 例外發生: {e}fallback 到 Gemini")
if not _gemini_fallback_allowed('openclaw_bot_fc') and not NVIDIA_API_KEY:
return "AI 引擎未設定,請確認 API Key 或啟動 Ollama 服務)", None
# ── Gemini Function Calling (備援 1) ─────────────────────────
gemini_fc_api_key = _gemini_fallback_api_key('openclaw_bot_fc')
if gemini_fc_api_key:
try:
sys_msg = (
f"你是 OpenClaw小O服務「小龍蝦」電商業務團隊的 AI 助理。\n"
f"今天是 {today_str}\n"
+ (f"\n【最近對話歷史】\n{history_ctx}\n" if history_ctx else "")
+ "你有四個工具可以使用:\n"
"1. query_sales — 查自家業績資料庫\n"
"2. get_market_intel — 取得外部市場情報(新聞/熱搜/PTT口碑/匯率/天氣/節慶)\n"
"3. get_knowledge — 查歷史分析知識庫\n"
"4. check_data_freshness — 確認業績資料最新可用日期,回答任何特定時段問題前必先呼叫\n\n"
"決策規則ADR-019 Phase 2\n"
"- 用戶用『本月/本週/今天』等相對時間 → 先 check_data_freshness\n"
"- 若 data_freshness_warning 顯示當月無資料,禁止編造『業績為零』,"
"改主動問用戶是否要改看上月(並附 latest_data_date\n"
"- 工具結果回來後用繁體中文自然回答,不要開場白,不要多餘客套話\n"
"- 同時呼叫多個工具時請平行送出\n"
"- 若【最近對話歷史】顯示用戶剛被詢問某參數(如月份),優先用該答案接續執行"
)
# 第一輪:讓 Gemini 判斷需要呼叫哪些工具
conversation = [
{"role": "user", "parts": [{"text": sys_msg + "\n\n用戶問:" + question}]}
]
payload = {
"contents": conversation,
"tools": _FC_TOOLS,
"tool_config": {"function_calling_config": {"mode": "AUTO"}},
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 600},
}
# Phase 1 v5.0: 包 ai_call_logger 追蹤 Gemini FC 第一輪
_qa_gemini_req_id = f"qa-{chat_id or 0}-{int(_time_mod.time())}"
with log_ai_call(
caller='openclaw_bot_gemini',
provider='gemini',
model=GEMINI_MODEL,
request_id=_qa_gemini_req_id,
meta={'chat_id_hash': hashlib.sha1(str(chat_id or 0).encode()).hexdigest()[:8], 'turn': 1},
) as _ctx_g1:
r1 = requests.post(
f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={gemini_fc_api_key}",
headers={"Content-Type": "application/json"},
json=payload, timeout=30,
)
r1.raise_for_status()
resp1 = r1.json()
# Gemini REST: usageMetadata.{promptTokenCount, candidatesTokenCount}
_um = resp1.get("usageMetadata", {}) or {}
_ctx_g1.set_tokens(
input=_um.get("promptTokenCount", 0),
output=_um.get("candidatesTokenCount", 0),
)
candidate = resp1.get("candidates", [{}])[0]
parts = candidate.get("content", {}).get("parts", [])
# 如果沒有 function call直接回傳文字
tool_calls = [p["functionCall"] for p in parts if "functionCall" in p]
if not tool_calls:
text = "".join(p.get("text", "") for p in parts if "text" in p).strip()
if text:
if chat_id:
openclaw_session.append_turn(chat_id, question, text)
if _LEARNING_ENABLED:
import threading as _thr
_thr.Thread(target=store_conversation,
args=(0, 0, question, text, "direct", []),
daemon=True).start()
return text, None
# 第二輪:執行所有工具,把結果送回 Gemini
tool_results = []
used_sources = []
for tc in tool_calls:
fn_name = tc["name"]
fn_args = tc.get("args", {})
sys_log.info(f"[FC] calling {fn_name}({fn_args})")
fn_result = _execute_tool(fn_name, fn_args)
used_sources.append(fn_name)
tool_results.append({
"functionResponse": {
"name": fn_name,
"response": fn_result
}
})
# 組建多輪對話
conversation.append({"role": "model", "parts": parts})
conversation.append({"role": "user", "parts": tool_results})
payload2 = {
"contents": conversation,
"tools": _FC_TOOLS,
"tool_config": {"function_calling_config": {"mode": "NONE"}}, # 第二輪強制回文字
"generationConfig": {
"temperature": 0.3,
"maxOutputTokens": 600,
},
}
# Phase 1 v5.0: 包 ai_call_logger 追蹤 Gemini FC 第二輪
with log_ai_call(
caller='openclaw_bot_gemini',
provider='gemini',
model=GEMINI_MODEL,
request_id=_qa_gemini_req_id,
meta={'chat_id_hash': hashlib.sha1(str(chat_id or 0).encode()).hexdigest()[:8], 'turn': 2, 'tools_used': used_sources},
) as _ctx_g2:
r2 = requests.post(
f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={gemini_fc_api_key}",
headers={"Content-Type": "application/json"},
json=payload2, timeout=35,
)
r2.raise_for_status()
resp2 = r2.json()
_um2 = resp2.get("usageMetadata", {}) or {}
_ctx_g2.set_tokens(
input=_um2.get("promptTokenCount", 0),
output=_um2.get("candidatesTokenCount", 0),
)
parts2 = resp2.get("candidates", [{}])[0].get("content", {}).get("parts", [])
final = "".join(p.get("text", "") for p in parts2 if "text" in p).strip()
if final:
sys_log.info(f"[FC] done tools={used_sources} reply={len(final)}chars")
if chat_id:
openclaw_session.append_turn(chat_id, question, final)
if _LEARNING_ENABLED:
import threading as _thr
_thr.Thread(target=store_conversation,
args=(0, 0, question, final, ",".join(used_sources), used_sources),
daemon=True).start()
return final, None
except Exception as e:
sys_log.warning(f"[FC] Gemini Function Calling failed: {e}, fallback NIM")
# ── NIM 備援(不支援 FC用傳統 prompt─────────────────
if NVIDIA_API_KEY:
try:
intent = resolve_query_intent(question)
today = today_str
yd = (now - timedelta(days=1)).strftime("%Y/%m/%d")
d = intent.get("date", yd) if intent["type"] == "day" else yd
db_ctx = ""
if intent["type"] == "day":
s = query_sales(d)
t = query_top_products(d, 8)
if s.get("found"):
db_ctx = (
f"日期{d} 業績NT${s.get('revenue',0):,.0f} "
f"訂單{s.get('orders',0)}筆 毛利{s.get('gross_margin',0):.1f}% "
f"TOP5: " + " / ".join(
f"[{p.get('id','')}]{p['name']}(NT${p['revenue']:,.0f})"
for p in t[:5])
)
elif intent["type"] == "month":
ms = query_monthly_summary(intent["year"], intent["month"])
if ms.get("found"):
db_ctx = (f"{intent['year']}{intent['month']:02d}"
f"業績NT${ms.get('revenue',0):,.0f} "
f"訂單{ms.get('orders',0)}"
f"毛利{ms.get('gross_margin',0):.1f}%")
mcp_ctx = build_mcp_context(question)
nim_prompt = (
f"你是 OpenClaw小O電商智能助理。今天{today}\n"
+ (f"【業績資料】{db_ctx}\n" if db_ctx else "")
+ (f"【市場情報】{mcp_ctx[:400]}\n" if mcp_ctx else "")
+ f"\n用戶問:{question}\n"
"請用繁體中文直接回答不要開場白300字以內。"
)
# Phase 1 v5.0: 包 ai_call_logger 追蹤 Bot Q&A NIM 三層 fallback
_qa_nim_req_id = f"qa-{chat_id or 0}-{int(_time_mod.time())}"
with log_ai_call(
caller='openclaw_bot_nim',
provider='nim',
model=CHAT_MODEL,
request_id=_qa_nim_req_id,
meta={'chat_id_hash': hashlib.sha1(str(chat_id or 0).encode()).hexdigest()[:8], 'has_db_ctx': bool(db_ctx)},
) as _ctx_nim:
r = requests.post(
f"{NVIDIA_BASE_URL}/chat/completions",
headers={"Authorization": f"Bearer {NVIDIA_API_KEY}",
"Content-Type": "application/json"},
json={
"model": CHAT_MODEL,
"messages": [{"role": "user", "content": nim_prompt}],
"max_tokens": 500, "temperature": 0.3,
},
timeout=20,
)
r.raise_for_status()
_body = r.json()
_u = _body.get("usage", {}) or {}
_ctx_nim.set_tokens(
input=_u.get("prompt_tokens", 0),
output=_u.get("completion_tokens", 0),
)
return _body["choices"][0]["message"]["content"].strip(), None
except Exception as e:
sys_log.error(f"[FC] NIM fallback error: {e}")
return "AI 引擎全部異常Ollama 超時Gemini/NIM 備援亦無回應或達速率限制,請稍後再試)", None
# ── 指令處理 ──────────────────────────────────────────────────
_CMD_TO_NL = {
'sales': lambda a: f"請查 {a or '今日'} 的業績數字(包含營收、訂單數、毛利率)",
'top': lambda a: f"請列出 {a or '今日'} 的 TOP10 熱銷商品",
'vendor': lambda a: f"請列出 {a or '今日'} 的 TOP10 熱銷廠商",
}
def _agent_dispatch_cmd(cmd, arg, chat_id, reply_to) -> bool:
"""ADR-019 Phase 3: Feature-flagged. 將白名單 cmd 翻成 NL question 交 agent 處理。
Agent 自動 probe 資料新鮮度(透過 Phase 2 的 check_data_freshness tool缺資料時
主動詢問用戶。回 True 表示已交 agent 處理handle_cmd 不再走原 dispatch。
回 False 表示維持原行為(含 feature flag 關閉、cmd 不在白名單、agent 失敗等)。
"""
if not _OPENCLAW_AGENT_DISPATCH_ENABLED:
return False
if cmd not in _AGENT_DISPATCH_CMDS:
return False
if cmd not in _CMD_TO_NL:
return False # 翻譯規則尚未建立 → 安全降級
nl_question = _CMD_TO_NL[cmd](arg)
try:
sys_log.info(f"[AgentDispatch] cmd:{cmd}:{arg or ''} → NL: {nl_question}")
txt, kb = openclaw_answer(nl_question, chat_id=chat_id)
send_message(chat_id, txt, reply_to, keyboard=kb)
return True
except Exception as e:
sys_log.error(f"[AgentDispatch] cmd:{cmd} agent failed, fallback to direct handler: {e}")
return False
def handle_cmd(cmd, arg, chat_id, reply_to):
ld = latest_date() or datetime.now(TAIPEI_TZ).strftime('%Y/%m/%d')
target = normalize_date(arg) if arg else ld
# ADR-019 Phase 3: agent dispatch hookfeature flag 預設 OFF
if (not _CMD_FROM_CALLBACK_CTX.get()) and _agent_dispatch_cmd(cmd, arg, chat_id, reply_to):
return
def _send_mcp_text_result(title: str, data, empty_message: str) -> bool:
"""相容新版 MCP 文字回傳;已處理則回 True舊 dict 格式則回 False。"""
if isinstance(data, str):
text = data.strip()
if text:
send_message(chat_id, f"{title}\n\n{text[:3600]}", reply_to, parse_mode=None)
else:
send_message(chat_id, empty_message, reply_to, parse_mode=None)
return True
return False
if cmd in ('sales', '業績'):
s = query_sales(target)
t = query_top_products(target, 3)
send_message(chat_id, fmt_sales(s, t, f"{MOMO_BASE_URL}/reports/daily/{target}"),
reply_to, sales_quick_kb(target) if s.get('found') else None)
elif cmd in ('top', '熱銷'):
p = query_top_products(target, 10)
kb = [_row(('📊 查業績', f'cmd:sales:{target}'),
('📋 完整報表', f'cmd:report:{target}'))]
send_message(chat_id, fmt_products(p, target), reply_to, kb)
elif cmd in ('vendor', '廠商'):
v = query_top_vendors(target, 10)
kb = [_row(('🏆 熱銷商品', f'cmd:top:{target}'),
('📊 查業績', f'cmd:sales:{target}'))]
send_message(chat_id, fmt_vendors(v, target), reply_to, kb)
elif cmd in ('trend', '趨勢'):
import calendar as _cal
from datetime import date as _date
now_d = datetime.now(TAIPEI_TZ).date()
sub = arg.lower().strip() if arg else '7'
# 決定查詢區間
if sub in ('', '7', 'week', 'weekly', '', '近7日', '七天'):
ld_str = latest_date() or now_d.strftime('%Y/%m/%d')
end_d = datetime.strptime(ld_str.replace('/', '-'), '%Y-%m-%d').date()
start_d = end_d - timedelta(days=6)
period_label = '近7日'
elif sub in ('month', '30', '', '本月', '近30日', '近一月'):
end_d = now_d
start_d = end_d - timedelta(days=29)
period_label = '近30日'
elif sub in ('quarter', 'q', '', '近季', '近3月', '近三月'):
end_d = now_d
start_d = end_d - timedelta(days=89)
period_label = '近3個月'
elif sub in ('half', '半年', '近半年', '6月', '六個月'):
end_d = now_d
start_d = end_d - timedelta(days=179)
period_label = '近半年'
elif sub in ('year', 'yearly', '', '本年', '近年', '近一年'):
end_d = now_d
start_d = end_d - timedelta(days=364)
period_label = '近一年'
elif re.fullmatch(r'\d{4}/\d{1,2}', sub):
yr_s, mo_s = sub.split('/')
yr, mo = int(yr_s), int(mo_s)
start_d = _date(yr, mo, 1)
end_d = _date(yr, mo, _cal.monthrange(yr, mo)[1])
period_label = f'{yr}{mo:02d}'
elif re.fullmatch(r'\d{4}', sub):
yr = int(sub)
start_d = _date(yr, 1, 1)
end_d = _date(yr, 12, 31)
period_label = f'{yr}年度'
elif re.fullmatch(r'\d{4}/[qQ]([1-4])', sub):
m2 = re.fullmatch(r'(\d{4})/[qQ]([1-4])', sub)
yr, qn = int(m2.group(1)), int(m2.group(2))
q_months = [(1, 3), (4, 6), (7, 9), (10, 12)]
sm, em = q_months[qn - 1]
start_d = _date(yr, sm, 1)
end_d = _date(yr, em, _cal.monthrange(yr, em)[1])
period_label = f'{yr}年Q{qn}'
else:
ld_str = latest_date() or now_d.strftime('%Y/%m/%d')
end_d = datetime.strptime(ld_str.replace('/', '-'), '%Y-%m-%d').date()
start_d = end_d - timedelta(days=6)
period_label = '近7日'
start_str = start_d.strftime('%Y/%m/%d')
end_str = end_d.strftime('%Y/%m/%d')
days_count = (end_d - start_d).days + 1
data = query_trend_range(start_str, end_str)
if not data:
data = query_weekly_trend()
chart_title = f'業績趨勢走勢圖 — {period_label}{start_str}~{end_str}'
if days_count <= 35:
# 週/月:逐日文字 + 折線圖
send_message(chat_id, fmt_trend(data, period_label), reply_to, _submenu_trend())
try:
png = gen_trend_chart(data_points=data, title=chart_title)
if png:
send_photo(chat_id, png, caption=f'📈 {period_label} 趨勢走勢圖')
try:
os.unlink(png)
except Exception:
sys_log.exception('[OpenClawBot] trend chart temp cleanup failed: %s', png)
except Exception as _te:
sys_log.warning(f'[OpenClawBot] trend chart error: {_te}')
else:
# 季/半年/年:摘要 + 聚合柱狀圖(不洗版面)
granularity = 'monthly' if days_count > 100 else 'weekly'
send_message(chat_id, fmt_trend_summary(data, period_label), reply_to, _submenu_trend())
try:
png = gen_aggregated_chart(data, chart_title, granularity=granularity)
if png:
unit = '' if granularity == 'monthly' else ''
send_photo(chat_id, png, caption=f'📊 {period_label} 業績走勢(按{unit}')
try:
os.unlink(png)
except Exception:
sys_log.exception('[OpenClawBot] trend agg chart temp cleanup failed: %s', png)
except Exception as _te:
sys_log.warning(f'[OpenClawBot] trend agg chart error: {_te}')
elif cmd in ('report', '報表'):
send_message(chat_id, f"⏳ 正在產生 {target} 完整報表...", reply_to, parse_mode=None)
try:
import os as _os
pdf_path = generate_daily_pdf(target)
if pdf_path:
if pdf_path.endswith('.xlsx'):
ext = 'Excel'
elif pdf_path.endswith('.csv'):
ext = 'CSV'
else:
ext = 'PDF'
send_document(
chat_id, pdf_path,
caption=f"📊 {target} 業績報表({ext}",
reply_to=reply_to
)
_os.unlink(pdf_path)
else:
send_message(chat_id,
f"⚠️ 報表產生失敗,請稍後再試",
reply_to)
except Exception as e:
sys_log.error(f"[OpenClawBot] report cmd error: {e}")
send_message(chat_id,
f"⚠️ 報表產生發生錯誤,請稍後再試",
reply_to)
elif cmd == 'news':
try:
from services.mcp_context_service import get_ecommerce_news
data = get_ecommerce_news()
if _send_mcp_text_result("📰 即時電商新聞", data, "⚠️ 新聞資料暫時無法取得"):
return
news = data.get('news', [])
sys_log.info(f"[OpenClawBot] news: {len(news)} items")
if news:
lines = ["📰 即時電商新聞"]
for n in news[:6]:
title = n['title'][:60]
url = n.get('url', '')
src = n.get('source', '')
if url:
lines.append(f"• <a href='{url}'>{title}</a>{src}")
else:
lines.append(f"{title}{src}")
send_message(chat_id, "\n".join(lines), reply_to, parse_mode='HTML')
else:
send_message(chat_id, "⚠️ 新聞資料暫時無法取得", reply_to, parse_mode=None)
except Exception as e:
sys_log.error(f"[OpenClawBot] news error: {e}", exc_info=True)
send_message(chat_id, "⚠️ 新聞功能暫時異常,請稍後再試", reply_to, parse_mode=None)
elif cmd == 'weather':
try:
from services.mcp_context_service import get_taiwan_weather
data = get_taiwan_weather()
if _send_mcp_text_result("🌤 台灣天氣預報", data, "⚠️ 天氣資料暫時無法取得"):
return
weather = data.get('weather', {})
sys_log.info(f"[OpenClawBot] weather: {list(weather.keys())}")
if weather:
lines = ["🌤 台灣天氣預報"]
for city, w in list(weather.items())[:6]:
wx = w.get('Wx', w.get('weatherDesc', ''))
pop = w.get('PoP', '')
tmin = w.get('MinT', '')
tmax = w.get('MaxT', '')
tmp = w.get('temp', f"{tmin}~{tmax}°C" if tmin else '')
lines.append(f"{city}{wx} {tmp}" +
(f" 降雨{pop}%" if pop else ""))
send_message(chat_id, "\n".join(lines), reply_to, parse_mode=None)
else:
send_message(chat_id, "⚠️ 天氣資料暫時無法取得", reply_to, parse_mode=None)
except Exception as e:
sys_log.error(f"[OpenClawBot] weather error: {e}", exc_info=True)
send_message(chat_id, "⚠️ 天氣功能暫時異常,請稍後再試", reply_to, parse_mode=None)
elif cmd == 'trends':
try:
from services.mcp_context_service import get_taiwan_trends
data = get_taiwan_trends()
if _send_mcp_text_result("🔥 台灣 Google 熱搜(即時)", data, "⚠️ 熱搜資料暫時無法取得"):
return
trends = data.get('trends', [])
if trends:
lines = ["🔥 *台灣 Google 熱搜(即時)*\n"]
for i, t in enumerate(trends[:12], 1):
lines.append(f"{i}. {t['keyword']}")
send_message(chat_id, "\n".join(lines), reply_to, parse_mode='Markdown')
else:
send_message(chat_id, "⚠️ 熱搜資料暫時無法取得", reply_to)
except Exception as e:
send_message(chat_id, f"⚠️ 取得失敗: {e}", reply_to)
elif cmd == 'dcard':
try:
from services.mcp_context_service import get_dcard_trends
data = get_dcard_trends()
if _send_mcp_text_result("💬 Dcard 消費者討論熱點", data, "⚠️ Dcard 資料暫時無法取得"):
return
posts = data.get('posts', [])
if posts:
lines = ["💬 *Dcard 消費者討論熱點*\n"]
for p in posts[:8]:
lines.append(f"• [{p['board']}] {p['title'][:50]}")
send_message(chat_id, "\n".join(lines), reply_to, parse_mode='Markdown')
else:
send_message(chat_id, "⚠️ Dcard 資料暫時無法取得", reply_to)
except Exception as e:
send_message(chat_id, f"⚠️ 取得失敗: {e}", reply_to)
elif cmd == 'exchange':
try:
from services.mcp_context_service import get_twbank_exchange_rates
data = get_twbank_exchange_rates()
if _send_mcp_text_result("💱 台灣銀行即時匯率", data, "⚠️ 匯率資料暫時無法取得"):
return
rates = data.get('rates', {})
if rates:
lines = ["💱 *台灣銀行即時匯率*\n"]
for code, info in rates.items():
name = info.get('name', code)
if 'buy' in info:
lines.append(f"{name}{code} 買入 `{info['buy']}` / 賣出 `{info['sell']}`")
elif 'mid' in info:
lines.append(f"{name}{code} `{info['mid']}`")
lines.append(f"\n_資料來源台灣銀行 {data.get('fetched_at','')[:16]}_")
send_message(chat_id, "\n".join(lines), reply_to, parse_mode='Markdown')
else:
send_message(chat_id, "⚠️ 匯率資料暫時無法取得", reply_to)
except Exception as e:
send_message(chat_id, f"⚠️ 取得失敗: {e}", reply_to)
elif cmd == 'calendar':
try:
from services.mcp_context_service import get_upcoming_events
data = get_upcoming_events()
if _send_mcp_text_result("📅 近期電商節慶行事曆", data, "✅ 近 60 天無重大電商節慶"):
return
events = data.get('events', [])
if events:
lines = ["📅 *近期電商節慶行事曆*\n"]
for ev in events:
days = ev['days_to_event']
day_str = f"還有 {days}" if days > 0 else ("今天" if days == 0 else f"進行中(+{-days}天)")
lines.append(f"{ev['status']} *{ev['name']}*{ev['date']}{day_str}")
lines.append(f" 建議提前 {ev['warmup_days']} 天開始備戰")
send_message(chat_id, "\n".join(lines), reply_to, parse_mode='Markdown')
else:
send_message(chat_id, "✅ 近 60 天無重大電商節慶", reply_to)
except Exception as e:
send_message(chat_id, f"⚠️ 取得失敗: {e}", reply_to)
elif cmd == 'youtube':
try:
from services.mcp_context_service import get_youtube_trending
data = get_youtube_trending()
if _send_mcp_text_result("▶️ YouTube 熱門開箱/推薦影片", data, "⚠️ YouTube 資料暫時無法取得"):
return
videos = data.get('videos', [])
if videos:
lines = ["▶️ *YouTube 熱門開箱/推薦影片*\n"]
for v in videos[:6]:
title = v['title'][:55]
url = v.get('url', '')
if url:
lines.append(f"• <a href='{url}'>{title}</a>")
else:
lines.append(f"{title}")
send_message(chat_id, "\n".join(lines), reply_to, parse_mode='HTML')
else:
send_message(chat_id, "⚠️ YouTube 資料暫時無法取得", reply_to)
except Exception as e:
send_message(chat_id, f"⚠️ 取得失敗: {e}", reply_to)
# ── PChome 比價指令 ───────────────────────────────────────────
elif cmd in ('competitor', '比價', 'price'):
# /competitor → 昨日 TOP30 競品比對日報
# /competitor 商品名 → 單品即時比價
if not _PCHOME_AVAILABLE:
send_message(chat_id, '⚠️ PChome 比價服務未啟用', reply_to)
return
keyword = arg.strip() if arg else ''
is_date = bool(keyword and re.match(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', keyword))
if keyword and not is_date:
# 即時比價(商品關鍵字)
send_message(chat_id, f'🔍 正在比價「{keyword}」,請稍候...', reply_to, parse_mode=None)
def _compare_bg(_kw, _chat_id, _reply_to):
try:
from services.pchome_crawler import search_pchome as _sp, find_best_match as _fm
# 搜尋 momo products
with _db().connect() as c:
rows = c.execute(text("""
SELECT p.name, p.i_code,
COALESCE(pr.price, 0)
FROM products p
LEFT JOIN (
SELECT product_id, price,
ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY timestamp DESC) as rn
FROM price_records
) pr ON p.id=pr.product_id AND pr.rn=1
WHERE p.name ILIKE :kw
ORDER BY pr.price DESC NULLS LAST
LIMIT 5
"""), {'kw': f'%{_kw}%'}).fetchall()
results = []
for row in rows:
name, icode, price = row[0], row[1], float(row[2] or 0)
cmp = pchome_compare(name, price, icode)
results.append(cmp)
import time; time.sleep(0.6)
if not results:
# 沒有 momo 商品 → 直接搜尋 PChome
pc_results = _sp(_kw, limit=5)
if pc_results:
lines = [f'🔍 *PChome 商品搜尋:{_kw}*', '']
for i, p in enumerate(pc_results[:5], 1):
stk = '' if p['in_stock'] else ''
lines.append(f'{i}. {p["name"][:30]}')
lines.append(f' {stk} `NT$ {p["price"]:,.0f}` [查看]({p["url"]})')
send_message(_chat_id, '\n'.join(lines), _reply_to)
else:
send_message(_chat_id, f'⚠️ 在 PChome 找不到「{_kw}」相關商品', _reply_to)
return
msg = pchome_fmt_compare(results, _kw)
kb = [_row(('🔍 重新搜尋', 'await:search_compare'),
('📊 競品日報', 'menu:competitor'))]
send_message(_chat_id, msg, _reply_to, kb)
except Exception as _e:
sys_log.error(f'[PChome] compare_bg: {_e}', exc_info=True)
send_message(_chat_id, f'⚠️ 比價失敗:{str(_e)[:80]}', _reply_to)
threading.Thread(target=_compare_bg, args=(keyword, chat_id, reply_to), daemon=True).start()
else:
# 無關鍵字或日期參數 → 昨日(或指定日期)熱銷競品日報(背景執行)
if is_date:
yesterday = normalize_date(keyword)
date_label = yesterday
else:
yesterday = (datetime.now(TAIPEI_TZ).date() - timedelta(days=1)).strftime('%Y/%m/%d')
date_label = f'昨日 ({yesterday[-5:]})'
send_message(chat_id,
f'📊 正在分析 {date_label} TOP30 熱銷商品 vs PChome 比價,預計 30~60 秒...',
reply_to, parse_mode=None)
def _daily_report_bg(_date_str, _chat_id, _reply_to):
try:
results = pchome_batch(_db(), top_n=30, date_str=_date_str)
pchome_save(_db(), results)
msg = pchome_fmt_report(results, _date_str)
kb = [_row(('🔍 搜尋比價', 'await:search_compare'),
('📄 比價簡報', 'menu:competitor_ppt'))]
send_message(_chat_id, msg, _reply_to, kb)
except Exception as _e:
sys_log.error(f'[PChome] daily_report_bg: {_e}', exc_info=True)
send_message(_chat_id, f'⚠️ 競品分析失敗:{str(_e)[:80]}', _reply_to)
threading.Thread(target=_daily_report_bg, args=(yesterday, chat_id, reply_to), daemon=True).start()
# ── v5 新增指令 ──────────────────────────────────────────────
elif cmd in ('compare', '同比'):
data = query_comparison(target)
send_message(
chat_id,
fmt_comparison(data, target),
reply_to,
[_row(('📊 今日業績', f'cmd:sales:{target}'), ('🧬 策略矩陣', f'cmd:strategy:{target}'))],
)
elif cmd in ('category', '分類'):
cats = query_category_sales(target)
kb = [_row(('🗂 鑽取分類', 'menu:category'), ('🏆 熱銷商品', f'cmd:top:{target}'))]
send_message(chat_id, fmt_category(cats, target), reply_to, kb)
elif cmd in ('catdetail', '分類細項'):
# arg = "母嬰:2026/04/15" or "母嬰"
parts_cd = arg.split(':') if arg else []
cat_name = parts_cd[0].strip() if parts_cd else ''
date_cd = parts_cd[1].strip() if len(parts_cd) > 1 else target
if not cat_name:
send_message(chat_id, "⚠️ 請指定分類名稱", reply_to)
else:
items = query_category_detail(cat_name, date_cd)
d_label = date_cd[-5:] if date_cd else '近7日'
msg = fmt_category_detail(cat_name, items, d_label)
kb = [_row(('⬅ 分類總覽', 'menu:category'), ('🏆 熱銷商品', f'cmd:top:{target}'))]
send_message(chat_id, msg, reply_to, kb)
elif cmd in ('restock', '補貨'):
send_message(chat_id, "⏳ 正在計算補貨預測...", reply_to, parse_mode=None)
items = query_restock_forecast(20)
msg = fmt_restock_forecast(items)
kb = [
_row(('🏆 熱銷商品', f'cmd:top:{target}'), ('🧬 商品健康', f'cmd:health:{target}')),
_row(('🔄 重新計算', 'cmd:restock')),
]
send_message(chat_id, msg, reply_to, kb)
elif cmd in ('promo', '促銷'):
# arg = "2026/04/01-2026/04/07"
if arg and '-' in arg and re.search(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', arg):
parts_p = arg.split('-')
# 可能是 "2026/04/01-2026/04/07" → split on '-' 中間需要處理日期格式
# 先嘗試用空格分割,再用 '-' 分割最後部分
m = re.findall(r'\d{4}[/\-]\d{1,2}[/\-]\d{1,2}', arg)
if len(m) >= 2:
start_s = normalize_date(m[0])
end_s = normalize_date(m[1])
send_message(chat_id, f"⏳ 正在比較 {start_s} ~ {end_s} 促銷效益...", reply_to, parse_mode=None)
data = query_promo_comparison(start_s, end_s)
msg = fmt_promo_comparison(data)
promo_range_arg = f'{start_s}-{end_s}'
kb = [
_row(('🎉 再查一個促銷', 'await:promo_range'),
('📊 業績查詢', f'cmd:sales:{start_s}')),
_row(('📊 產出促銷簡報', f'cmd:ppt:promo {promo_range_arg}')),
]
send_message(chat_id, msg, reply_to, kb)
else:
send_message(chat_id,
"⚠️ 格式錯誤\n請輸入:`2026/04/01-2026/04/07`", reply_to)
else:
send_message(chat_id,
"🎉 *促銷效益追蹤*\n\n請輸入活動日期範圍:\n`/promo 2026/04/01-2026/04/07`\n或點選 🎉 促銷追蹤 按鈕",
reply_to, [_row(('🎉 設定促銷範圍', 'await:promo_range'))])
elif cmd in ('goal', '目標'):
# /goal 200000 設定日目標
# /goal monthly 5000000 設定月目標
if arg:
parts2 = arg.split()
if parts2[0].lower() == 'monthly' and len(parts2) > 1:
try:
_GOALS['monthly'] = float(parts2[1].replace(',', ''))
send_message(chat_id,
f"✅ 月目標設定為 `NT$ {_GOALS['monthly']:,.0f}`",
reply_to, parse_mode='Markdown')
except ValueError:
send_message(chat_id, "⚠️ 格式:`/goal monthly 5000000`",
reply_to, parse_mode='Markdown')
else:
try:
_GOALS['daily'] = float(parts2[0].replace(',', ''))
send_message(chat_id,
f"✅ 日目標設定為 `NT$ {_GOALS['daily']:,.0f}`",
reply_to, parse_mode='Markdown')
except ValueError:
send_message(chat_id, "⚠️ 格式:`/goal 200000`",
reply_to, parse_mode='Markdown')
else:
status = get_goal_status(target)
kb = [_row(('📊 今日業績', f'cmd:sales:{target}'), ('🔄 同期比較', f'cmd:compare:{target}'))]
send_message(chat_id, fmt_goal_status(status), reply_to, kb)
elif cmd in ('chart', '圖表'):
send_message(chat_id, "⏳ 正在產生趨勢圖...", reply_to, parse_mode=None)
try:
png = gen_trend_chart(14)
if png:
send_photo(chat_id, png,
caption=f"📉 近14日業績趨勢",
reply_to=reply_to)
os.unlink(png)
# 再發熱銷商品圖
png2 = gen_products_chart(target, 10)
if png2:
send_photo(chat_id, png2,
caption=f"🏆 {target} 熱銷商品 TOP10",
reply_to=None)
os.unlink(png2)
else:
send_message(chat_id,
"⚠️ 圖表功能需安裝 matplotlibpip install matplotlib",
reply_to, parse_mode=None)
except Exception as e:
sys_log.error(f"[OpenClawBot] chart cmd error: {e}")
send_message(chat_id, "⚠️ 圖表產生失敗", reply_to, parse_mode=None)
elif cmd in ('health', '健康'):
anomalies = query_anomalies(target)
strat = analyze_product_strategy(target, 10)
lines = [f"🏥 *商品健康報告* _({target})_", ""]
# ── 異常偵測 ──
if anomalies:
lines.append(f"⚠️ *業績異常商品* _偏差 > 30%_")
for a in anomalies[:6]:
pct = a.get('pct') or 0
icon = '📈' if pct > 0 else '📉'
sid = _short_id(a['id'])
name = _esc(a['name'][:18])
today_r = a.get('today', 0)
avg_r = a.get('avg7', 0)
lines.append(
f"{icon} *{abs(pct):.0f}%* {'急升' if pct > 0 else '急降'} · {name}"
)
lines.append(
f" 今 `${today_r:,.0f}` 7日均 `${avg_r:,.0f}`"
f" `{sid}`"
)
lines.append("")
else:
lines.append("✅ *無業績異常商品* _7日均值偏差均在 30% 以內_")
lines.append("")
# ── 策略分佈 ──
if strat:
from collections import Counter
cnt = Counter(s['strategy'] for s in strat)
total = len(strat)
tag_icon = {'加碼': '🔥', '機會': '💡', '收割': '', '觀察': '⚠️', '持穩': ''}
lines.append(f"📊 *策略分佈* _{total}_")
for k, v in cnt.most_common():
bar = '' * v + '' * (total - v)
icon = tag_icon.get(k, '')
lines.append(f" {icon} {k} {v} 件 `{bar}`")
kb = [
_row(('🎲 策略矩陣', f'cmd:strategy:{target}'), ('🏆 熱銷商品', f'cmd:top:{target}')),
_row(('📊 今日業績', f'cmd:sales:{target}'), ('🔄 同期比較', f'cmd:compare:{target}')),
]
send_message(chat_id, "\n".join(lines), reply_to, kb)
elif cmd in ('strategy', '策略'):
strat = analyze_product_strategy(target, 10)
kb = [
_row(('🏥 商品健康', f'cmd:health:{target}'), ('🔄 同期比較', f'cmd:compare:{target}')),
_row(('🏆 熱銷商品', f'cmd:top:{target}'), ('📊 今日業績', f'cmd:sales:{target}')),
]
send_message(chat_id, fmt_strategy(strat, target), reply_to, kb)
elif cmd in ('cache', '快取'):
# /cache status 任何已授權用戶可看
# /cache flush <type> admin only — 清除指定/全部 PPT 快取
# /cache cleanup [days] [confirm] admin onlyconfirm 才實刪)— 清磁碟
sub = (arg or '').strip().lower()
# critic Medium-3破壞性操作前先取當前 user_id 並驗 admin
_curr_uid = _CURRENT_USER_ID_CTX.get()
if sub.startswith('flush'):
# admin guard
if not _is_admin(_curr_uid):
send_message(chat_id,
"⛔ `/cache flush` 限管理員執行。\n"
"請聯繫系統管理員,或設定 `OPENCLAW_ADMIN_USER_IDS` 環境變數。",
reply_to, parse_mode='Markdown')
return
parts = sub.split(maxsplit=1)
target_type = parts[1].strip() if len(parts) > 1 else None
try:
affected = _invalidate_ppt_cache(target_type if target_type and target_type != 'all' else None)
from services.ppt_generator import TEMPLATE_VERSIONS
ver = TEMPLATE_VERSIONS.get(target_type, '') if target_type else ''
msg = (f"✅ PPT 快取已強制失效\n"
f"類型:{target_type or '全部'}\n"
f"當前模板版本:{ver}\n"
f"影響筆數:{affected}\n"
f"下次請求將以新模板重新生成。\n"
f"執行者user_id={_curr_uid}")
sys_log.warning(f"[PPT cache flush] admin={_curr_uid} type={target_type or 'ALL'} affected={affected}")
except Exception as e:
msg = f"❌ 快取清除失敗:{e}"
send_message(chat_id, msg, reply_to, parse_mode=None)
elif sub.startswith('status') or sub == '':
try:
from database.manager import DatabaseManager
from database.ppt_reports import PPTReport
from sqlalchemy import func as _f
session = DatabaseManager().get_session()
now_naive = datetime.now(TAIPEI_TZ).replace(tzinfo=None)
rows = (session.query(PPTReport.report_type,
_f.count(PPTReport.id),
_f.sum(PPTReport.file_size))
.filter(or_(PPTReport.expires_at.is_(None),
PPTReport.expires_at > now_naive))
.group_by(PPTReport.report_type).all())
session.close()
from services.ppt_generator import TEMPLATE_VERSIONS
lines = ["📦 *PPT 快取狀態(未過期)*", ""]
if not rows:
lines.append("(目前無有效快取)")
else:
for rt, cnt, size in rows:
size_kb = (size or 0) / 1024
ver = TEMPLATE_VERSIONS.get(rt, '')
lines.append(f"• `{rt}`{cnt} 筆 · {size_kb:,.0f} KB · 模板 `{ver}`")
lines.append("")
lines.append("使用 `/cache flush <type>` 強制清除")
send_message(chat_id, '\n'.join(lines), reply_to, parse_mode='Markdown')
except Exception as e:
send_message(chat_id, f"❌ 查詢快取失敗:{e}", reply_to, parse_mode=None)
elif sub.startswith('cleanup'):
# /cache cleanup [days] 乾跑(任何已授權用戶可預覽)
# /cache cleanup [days] confirm admin only — 真正執行刪除
# 注意days < 1 強制乾跑critic Medium-3 防呆)
parts = sub.split()
confirm = 'confirm' in parts or 'real' in parts
days = 7
for p in parts[1:]:
if p.isdigit():
days = int(p)
# 防呆days < 1 強制乾跑
forced_dry = days < 1
# admin guard只有實刪需要 admin乾跑任何人可看
if confirm and not forced_dry and not _is_admin(_curr_uid):
send_message(chat_id,
"⛔ `/cache cleanup ... confirm` 限管理員執行。\n"
"可改用乾跑模式(不加 `confirm`)預覽影響範圍。",
reply_to, parse_mode='Markdown')
return
dry = forced_dry or (not confirm)
try:
stat = cleanup_expired_ppt_cache(days_old=days, dry_run=dry)
if not dry:
sys_log.warning(f"[PPT cleanup] admin={_curr_uid} days={days} stat={stat}")
tag = "(乾跑—未實刪)" if dry else "(已實刪)"
if forced_dry:
tag += " [days<1 強制乾跑]"
msg = (f"🧹 PPT 磁碟清理 {tag}\n"
f"門檻:過期超過 {days}\n"
f"{'將刪檔' if dry else '刪檔'}{stat['deleted_files']}\n"
f"{'將刪 row' if dry else '刪 row'}{stat['deleted_rows']}\n"
f"{'將釋放' if dry else '釋放'}空間:{stat['freed_bytes']/1024:,.0f} KB"
+ (f"\n錯誤:{len(stat['errors'])}" if stat['errors'] else "")
+ ("\n\n如要真正刪除,加上 `confirm`\n"
f"`/cache cleanup {days} confirm`" if dry and not forced_dry else ""))
except Exception as e:
msg = f"❌ 清理失敗:{e}"
send_message(chat_id, msg, reply_to, parse_mode='Markdown' if dry and not forced_dry else None)
else:
send_message(chat_id,
"用法:\n"
"• `/cache status` 查看快取狀態\n"
"• `/cache flush monthly` 清月報快取\n"
"• `/cache flush all` 清全部\n"
"• `/cache cleanup [N天] [dry]` 清磁碟過期檔案",
reply_to, parse_mode='Markdown')
elif cmd in ('ppt', 'slides', '簡報'):
# /ppt daily [日期]
# /ppt weekly
# /ppt monthly [YYYY/MM]
# /ppt strategy [日期]
sub_type = arg.lower().strip() if arg else 'daily'
sub_arg = ''
if ' ' in sub_type:
parts = sub_type.split(' ', 1)
sub_type = parts[0]
sub_arg = parts[1].strip()
send_message(chat_id, f"⏳ 正在生成 *{sub_type}* 簡報請稍候30~60秒...",
reply_to, parse_mode='Markdown')
def _ppt_background(_sub_type, _sub_arg, _chat_id, _target, _reply_to):
try:
ppt_path = _generate_ppt_cmd(_sub_type, _sub_arg, _chat_id, _target,
_reply_to=_reply_to)
if ppt_path and os.path.exists(ppt_path):
type_labels = {
'daily': '📊 日報', 'weekly': '📈 週報',
'monthly': '📅 月報', 'strategy': '🧬 策略簡報',
'competitor': '🔍 競品比較', 'compare': '🔍 競品比較',
'競品': '🔍 競品比較', 'promo': '🎉 促銷效益報告',
}
label = type_labels.get(_sub_type, '簡報')
caption = f"{label} — 由 OpenClaw AI 自動生成\n💡 可用 PowerPoint / Keynote / Google Slides 開啟"
send_document(_chat_id, ppt_path, caption=caption, reply_to=_reply_to)
if not _is_cached_ppt_file(ppt_path):
try:
os.unlink(ppt_path)
except Exception:
pass
else:
send_message(_chat_id, "⚠️ 簡報生成失敗,請稍後再試", _reply_to)
except PPTDataInsufficientError as e:
# ADR-019 Phase 1用戶已收到 inline keyboard 詢問,靜默結束
sys_log.info(f"[OpenClawBot] /ppt skipped due to data gap: {e}")
except Exception as e:
sys_log.error(f"[OpenClawBot] /ppt bg error: {e}", exc_info=True)
send_message(_chat_id, f"⚠️ 簡報生成失敗:{str(e)[:100]}", _reply_to)
threading.Thread(
target=_ppt_background,
args=(sub_type, sub_arg, chat_id, target, reply_to),
daemon=True
).start()
elif cmd in ('history', 'monthly', '月報', '月份'):
# /history [YYYY/MM] — 顯示月份業績,不帶參數時列出所有可用月份
months = query_available_months()
if arg and re.match(r'\d{4}/\d{2}', arg):
yr, mo_s = arg.split('/')
ms = query_monthly_summary(int(yr), int(mo_s))
msg = fmt_monthly(ms)
else:
# 列出可用月份清單
if months:
lines = ["📅 *業績月份索引*",
f"{len(months)} 個月份有資料", ""]
for am in months:
lines.append(f" 📊 `{am['month']}` _{am['days']} 天_")
lines.append("\n_用法`/history 2026/03` 查看指定月份業績_")
msg = "\n".join(lines)
else:
msg = "⚠️ 暫無月份資料"
# 快捷按鈕最近3個月 + 月報PPT僅在查看特定月份時顯示
kb_months = _chunk_rows(
[(f"📊 {am['month']}", f"cmd:history:{am['month']}") for am in months[:3]],
row_size=2,
)
if arg and re.match(r'\d{4}/\d{2}', arg):
# 查看特定月份時額外顯示「產出月報PPT」按鈕
kb = kb_months if kb_months else []
kb.append(_row((f'📊 產出 {arg} 月報', f'cmd:ppt:monthly {arg}')))
else:
kb = kb_months if kb_months else None
send_message(chat_id, msg, reply_to, kb)
# ── 原有指令 ─────────────────────────────────────────────────
elif cmd in ('menu', 'start', '選單'):
send_message(chat_id,
"👋 *OpenClaw小O* — 電商智能助理\n\n"
"點下方按鈕,或直接用中文跟我說話 👇",
reply_to, main_menu_keyboard())
elif cmd in ('help', '幫助', '說明'):
reply = (
"📖 *OpenClaw 完整功能指南*\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
"💬 *自然語言對話(直接發訊息)*\n"
" 「今天業績多少?」\n"
" 「哪個商品最需要加碼?」\n"
" 「上個月整體表現如何?」\n"
" 「幫我看外部熱搜跟銷售的落差」\n"
" 「怎麼用圖片比價?」 ← 也可問功能說明\n\n"
"📊 *業績查詢 ▸ 點「業績查詢」按鈕*\n"
" 今日/昨日業績 → 業績摘要 + TOP3 商品(可點連結)\n"
" 每週/每月/每季/半年 → 趨勢走勢\n"
" 同期比較 → vs 上週同日\n"
" 分類業績 → 各品類佔比\n"
" 日期/區間 → 單日或起迄區間 格式: `2026/04/01-2026/04/15`\n"
" 月份總覽 → 列出所有可查月份,點月份查詳情\n\n"
"🏆 *商品廠商 ▸ 點「商品廠商」按鈕*\n"
" 熱銷商品 TOP10 → 含商品ID + 可點 PChome 連結\n"
" 熱銷廠商 TOP10\n"
" 商品健康分析 → 偏低/異常/策略分佈\n"
" 補貨預測 → 動銷率 × 庫存天數估算\n"
" 分類鑽取 → 進分類後再看商品排行\n\n"
"🎯 *目標管理 ▸ 點「目標管理」按鈕*\n"
" 查看達成率 → 日/週(自動推算)/月/季/半年/年\n"
" 設定目標 → 點按鈕輸入金額\n"
" 月倒計時 → 顯示每日需達金額\n\n"
"📈 *智能分析 ▸ 點「智能分析」按鈕*\n"
" 策略矩陣 → 加碼/機會/收割/觀察/持穩\n"
" 業績趨勢 → 7日/30日/季/半年/年,含趨勢圖\n"
" 商品健康 → 異常商品 + 策略建議\n"
" 促銷追蹤 → 輸入活動區間,計算業績增幅\n"
" 📊 趨勢圖表 → 直接生成折線圖 + 熱銷橫條圖\n"
" 同期比較 → vs 上週/上月同日\n\n"
"📄 *簡報報表 ▸ 點「簡報報表」按鈕*\n"
" 日報/週報/月報 → 自動生成 .pptx\n"
" 策略簡報(日/週/月/季/半年/年)\n"
" 促銷效益簡報 → 輸入活動區間\n"
" 下載 Excel 報表\n"
" 指定日期日報 / 指定月份月報\n\n"
"🌐 *市場情報 ▸ 點「市場情報」按鈕*\n"
" 電商新聞 → 即時台灣電商資訊\n"
" 台北天氣 → 含行銷建議\n"
" 關鍵字比價 → 輸入商品名,搜尋 PChome 比價\n"
" 📷 圖片比價 → 直接傳商品照片,自動辨識比價\n\n"
"🔍 *競品日報 ▸ 點「競品日報」按鈕*\n"
" 今日/昨日/本週/本月競品比價簡報\n\n"
"⏰ *自動推播(每日無需操作)*\n"
" 08:00 競品比價日報\n"
" 08:30 每日早報(昨日業績 + TOP15 熱銷)\n"
" 21:00 每日晚報(今日業績 + TOP15 + 明日建議)\n"
" 週一 09:00 週報\n"
" 09/12/15/18 時 異常偵測(偏差>30% 即告警)\n\n"
"💡 *日期格式*`2026/04/10` 或 `2026-04-10` 皆可\n"
"💡 *有疑問*:直接用中文問我,我會回答!"
)
help_kb = quick_menu_keyboard()
send_message(chat_id, reply, reply_to, help_kb)
elif cmd == 'ack':
# 告警確認(來自 anomaly alert 的 inline button
action_map = {'anomaly': '已知悉', 'tracking': '追蹤中'}
label = action_map.get(arg or '', '確認')
send_message(chat_id, f"✅ *{label}* — 感謝回覆,繼續監控中。", reply_to)
# ── 回饋學習:告警 ack → quality_score +0.1 ────────
if _LEARNING_ENABLED:
import threading as _thr
feedback_type = 'acknowledged' if arg == 'anomaly' else 'tracking'
_thr.Thread(
target=update_feedback,
args=(feedback_type, msg.get('from', {}).get('id', 0)),
daemon=True
).start()
elif cmd == 'learn':
# ── 學習系統狀態查詢 ──────────────────────────────────
if _LEARNING_ENABLED:
st = get_learning_stats()
msg_text = (
"🧠 *OpenClaw 自主學習狀態*\n\n"
f"📚 知識庫:`{st.get('total_knowledge',0):,}` 條\n"
f"💬 對話記憶:`{st.get('total_conversations',0):,}` 筆\n"
f"📊 市場洞察:`{st.get('total_insights',0):,}` 份\n"
f"🛒 商品知識:`{st.get('total_products',0):,}` 個\n"
f"👍 用戶回饋:`{st.get('total_feedback',0):,}` 次\n"
f"⭐ 平均品質分:`{st.get('avg_quality',0):.2f}`\n"
f"📈 本週新增:`{st.get('knowledge_last7d',0)}` 條\n\n"
"_每次 AI 回答、PPT 分析、告警確認都會自動加入學習庫_"
)
else:
msg_text = "⚠️ 學習系統暫未啟用"
send_message(chat_id, msg_text, reply_to, parse_mode='Markdown')
elif cmd == 'import_confirm':
# ── Excel 匯入確認 ────────────────────────────────────
pending = _excel_pending.pop(chat_id, None)
if not pending:
send_message(chat_id, "⚠️ 匯入已逾期或不存在,請重新上傳 Excel 檔案。", reply_to)
return
file_path = pending['file_path']
filename = pending['filename']
def _do_import():
try:
send_message(chat_id,
f"⏳ *正在匯入 Excel 資料...*\n`{filename}`\n\n請稍候,資料量大時需要一點時間。",
None, parse_mode='Markdown')
from services.import_service import ImportService, Session
from database.import_models import ImportJob
import pytz as _pytz
_TAIPEI = _pytz.timezone('Asia/Taipei')
from datetime import datetime as _dt
svc = ImportService()
# 建立匯入任務
session = Session()
job = ImportJob(
job_type='telegram_upload',
status='pending',
drive_file_name=filename,
local_file_path=file_path,
created_at=_dt.now(_TAIPEI).replace(tzinfo=None)
)
session.add(job)
session.commit()
job_id = job.id
session.close()
# 執行匯入
success = svc.process_daily_sales_import(job_id, file_path)
status = svc.get_job_status(job_id)
# 清理暫存檔
try:
import os as _os
_os.unlink(file_path)
except Exception:
pass
if success and status.get('status') == 'completed':
sr = status.get('success_rows', 0) or 0
tr = status.get('total_rows', 0) or 0
summ = status.get('import_summary') or {}
if isinstance(summ, str):
import json as _json
try: summ = _json.loads(summ)
except Exception: summ = {}
date_min = summ.get('date_min', '')
date_max = summ.get('date_max', '')
sync_status = summ.get('sync_status', '')
date_range_str = (
f"`{date_min}` ~ `{date_max}`"
if date_min and date_max and date_min != date_max
else f"`{date_min}`" if date_min else ''
)
result_msg = (
f"✅ *Excel 匯入成功!*\n"
f"{'' * 26}\n\n"
f"📄 *檔案*`{filename}`\n"
f"📦 *匯入筆數*`{sr:,}` / `{tr:,}` 筆\n"
f"📅 *涵蓋日期*{date_range_str}\n"
)
if sync_status:
result_msg += f"🔄 *處理狀態*`{sync_status}`\n"
result_msg += f"\n_✨ 業績資料已更新可立即查詢_"
# 取涵蓋日期的 latest date 顯示快速按鈕
quick_date = date_max.replace('-', '/') if date_max else (latest_date() or '')
import_kb = [
_row((f'📊 查看 {quick_date} 業績', f'cmd:sales:{quick_date}'),
('🏆 熱銷商品排行', f'cmd:top:{quick_date}')),
_row(('📄 產出日報 PPT', f'cmd:ppt:daily {quick_date}'),
('📅 月份總覽', 'cmd:history')),
] if quick_date else None
send_message(chat_id, result_msg, None, import_kb)
else:
err = status.get('error_message', '') or '未知錯誤'
send_message(chat_id,
f"❌ *匯入失敗*\n\n`{filename}`\n\n錯誤訊息:\n`{err[:200]}`",
None, parse_mode='Markdown')
except Exception as _ie:
sys_log.error(f"[ExcelImport] import_confirm error: {_ie}", exc_info=True)
send_message(chat_id, f"❌ 匯入過程發生例外錯誤:`{str(_ie)[:150]}`", None)
threading.Thread(target=_do_import, daemon=True).start()
elif cmd == 'import_cancel':
# ── Excel 匯入取消 ────────────────────────────────────
pending = _excel_pending.pop(chat_id, None)
if pending:
try:
import os as _os
_os.unlink(pending['file_path'])
except Exception:
pass
send_message(chat_id, "✖ 已取消匯入,暫存檔案已刪除。", reply_to)
elif cmd == 'photo_search_help':
photo_help = (
"📷 *圖片比價功能說明*\n\n"
"直接在此群組傳送商品圖片,我會自動:\n"
" 1⃣ 辨識圖片中的商品名稱\n"
" 2⃣ 在 PChome 搜尋同款商品\n"
" 3⃣ 回傳比價結果與競品連結\n\n"
"*使用方式*\n"
" 📤 直接拍照或截圖傳送\n"
" 🏷 或輸入「搜尋 商品名稱」(文字比價)\n\n"
"*支援商品類型*\n"
" ✅ 美妝保養品 ✅ 保健食品\n"
" ✅ 母嬰用品 ✅ 個人清潔\n"
" ✅ 食品飲料 ✅ 家電用品\n\n"
"_💡 圖片越清晰、商品標籤越完整辨識準確度越高_"
)
send_message(chat_id, photo_help, reply_to, _submenu_market())
# ── Phase 38: AI 觀測台 4 個指令(對應 /observability/* 6 頁的 4 個關鍵指標)───
elif cmd == 'obs_ai_calls':
# 24h AI 呼叫統計:總次數 / token / cost / RAG 命中 / 錯誤
try:
from database.manager import DatabaseManager
from sqlalchemy import text as _sa
session = DatabaseManager().get_session()
row = session.execute(
_sa("""
SELECT COUNT(*),
COALESCE(SUM(input_tokens + output_tokens), 0),
COALESCE(SUM(cost_usd), 0),
COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')),
COUNT(*) FILTER (WHERE rag_hit),
COUNT(*) FILTER (WHERE cache_hit)
FROM ai_calls
WHERE called_at >= NOW() - INTERVAL '24 hours'
"""),
).fetchone()
top_provider = session.execute(
_sa("""
SELECT provider, COUNT(*) AS calls, COALESCE(SUM(cost_usd), 0) AS cost
FROM ai_calls
WHERE called_at >= NOW() - INTERVAL '24 hours'
GROUP BY provider ORDER BY calls DESC LIMIT 5
"""),
).fetchall()
session.close()
calls, tokens, cost, errors, rag, cache = row or (0, 0, 0, 0, 0, 0)
err_rate = (errors / calls * 100) if calls else 0
cache_rate = (cache / calls * 100) if calls else 0
rag_rate = (rag / calls * 100) if calls else 0
lines = [
"📊 *AI 呼叫總覽(過去 24 小時)*", "",
f"• 總呼叫:*{calls:,}* 次",
f"• Token 用量:*{tokens:,}*",
f"• 成本:*${cost:.2f} USD*",
f"• 錯誤:*{errors}* 次({err_rate:.1f}%",
f"• RAG 命中:*{rag}* 次({rag_rate:.1f}%",
f"• 快取命中:*{cache}* 次({cache_rate:.1f}%",
"",
"*依供應商分組Top 5*",
]
for p, c, ct in top_provider:
lines.append(f"• `{p}`{c} 次 · ${ct:.2f}")
lines.append("")
lines.append("詳細查詢mo.wooo.work/observability/ai\\_calls")
kb = [_row(('🏥 主機健康', 'cmd:obs_health'), ('💰 預算控管', 'cmd:obs_budget')),
_row(('💬 反饋趨勢', 'cmd:obs_quality'), ('← 返回主選單', 'menu:main'))]
send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown')
except Exception as e:
send_message(chat_id, f"❌ 查詢 AI 呼叫統計失敗:{e}", reply_to, parse_mode=None)
elif cmd == 'obs_health':
# 三主機 Ollama 即時 + 24h uptime
try:
from services.ollama_service import (
OLLAMA_HOST_PRIMARY, OLLAMA_HOST_SECONDARY, OLLAMA_HOST_FALLBACK,
_is_unhealthy,
)
import requests as _req
from database.manager import DatabaseManager
from sqlalchemy import text as _sa
lines = ["🏥 *主機健康監控*", ""]
lines.append("*三主機即時狀態:*")
host_states = {}
for label, host in [
('GCP-A (Primary)', OLLAMA_HOST_PRIMARY),
('GCP-B (Secondary)', OLLAMA_HOST_SECONDARY),
('111 (Fallback)', OLLAMA_HOST_FALLBACK),
]:
healthy = False
try:
r = _req.get(f"{host.rstrip('/')}/api/tags", timeout=3)
healthy = (r.status_code == 200)
except Exception:
pass
short_label = 'GCP-A' if label.startswith('GCP-A') else 'GCP-B' if label.startswith('GCP-B') else '111'
host_states[short_label] = healthy
emoji = "" if healthy else ""
mark = " ⚠️標記異常" if _is_unhealthy(host) else ""
lines.append(f"{emoji} *{label}*`{host}`{mark}")
# 24h uptime
try:
session = DatabaseManager().get_session()
rows = session.execute(
_sa("""
SELECT host_label,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE healthy) AS up,
COALESCE(AVG(response_ms) FILTER (WHERE healthy), 0) AS avg_ms
FROM host_health_probes
WHERE probed_at >= NOW() - INTERVAL '24 hours'
GROUP BY host_label ORDER BY host_label
"""),
).fetchall()
session.close()
if rows:
lines.append("")
lines.append("*過去 24h 在線率:*")
for label, total, up, avg_ms in rows:
pct = (up / total * 100) if total else 0
lines.append(f"{label}*{pct:.1f}%* · {int(avg_ms)}ms 平均")
except Exception:
pass
lines.append("")
lines.append("詳細查詢mo.wooo.work/observability/host\\_health")
# Phase 41 E-3: 任一主機標記異常 → 顯示 AutoHeal inline 按鈕
kb_rows = []
try:
from services.ollama_service import (
OLLAMA_HOST_PRIMARY as _P, OLLAMA_HOST_SECONDARY as _S, OLLAMA_HOST_FALLBACK as _F,
)
heal_buttons = []
for label, host in [('GCP-A', _P), ('GCP-B', _S), ('111', _F)]:
if _is_unhealthy(host) or host_states.get(label) is False:
heal_buttons.append((f'🩹 修 {label}', f'cmd:obs_heal:{label}'))
if heal_buttons:
# 兩兩成行
for i in range(0, len(heal_buttons), 2):
kb_rows.append(_row(*heal_buttons[i:i+2]))
except Exception:
pass
kb_rows.append(_row(('📊 AI 呼叫', 'cmd:obs_ai_calls'), ('💰 預算控管', 'cmd:obs_budget')))
kb_rows.append(_row(('💬 反饋趨勢', 'cmd:obs_quality'), ('← 返回主選單', 'menu:main')))
send_message(chat_id, '\n'.join(lines), reply_to, kb_rows, parse_mode='Markdown')
except Exception as e:
send_message(chat_id, f"❌ 查詢主機健康失敗:{e}", reply_to, parse_mode=None)
elif cmd == 'obs_heal':
# Phase 41 E-3 (L2)Telegram inline 觸發 AutoHeal
# arg 為 'GCP-A' / 'GCP-B' / '111'
try:
label_map = {
'GCP-A': 'Primary (GCP)',
'GCP-B': 'Secondary (GCP)',
'111': 'Fallback (111)',
}
host_label = label_map.get(arg.strip())
if not host_label:
send_message(chat_id, f"❌ 未知主機標籤:{arg}", reply_to, parse_mode=None)
return
from services.auto_heal_service import auto_heal_service
from services.ollama_service import (
_is_unhealthy as _iu,
OLLAMA_HOST_PRIMARY as _P2, OLLAMA_HOST_SECONDARY as _S2, OLLAMA_HOST_FALLBACK as _F2,
)
host_url = {'Primary (GCP)': _P2, 'Secondary (GCP)': _S2, 'Fallback (111)': _F2}.get(host_label)
def _latest_probe_unhealthy(label: str) -> bool:
"""用 DB 最新探針補足 `_is_unhealthy()` 30 秒記憶體 TTL 的盲點。"""
try:
from database.manager import DatabaseManager
from sqlalchemy import text as _sa
_s = DatabaseManager().get_session()
try:
row = _s.execute(_sa("""
SELECT healthy
FROM host_health_probes
WHERE host_label = :label
AND probed_at >= NOW() - INTERVAL '30 minutes'
ORDER BY probed_at DESC
LIMIT 1
"""), {'label': label}).fetchone()
return bool(row is not None and row[0] is False)
finally:
_s.close()
except Exception:
return False
if not (_iu(host_url) or _latest_probe_unhealthy(host_label)):
send_message(chat_id, f"⚠️ {host_label} 目前未標記異常,無需 AutoHeal", reply_to, parse_mode=None)
return
result = auto_heal_service.handle_exception(
error_type='ollama_unhealthy',
context={
'host_label': host_label, 'host_url': host_url,
'error_message': f'Ollama {host_label} marked unhealthy',
'triggered_by': f'telegram_user_{_CURRENT_USER_ID_CTX.get() or "tg_admin"}',
},
)
ok = bool(getattr(result, 'success', False))
action = getattr(result, 'action', None) or ''
msg = getattr(result, 'message', '') or ''
ack = (f"✅ AutoHeal {host_label}\n動作:{action}\n{msg}"
if ok else f"❌ AutoHeal 失敗({action}\n{msg}")
send_message(chat_id, ack[:1200], reply_to, parse_mode=None)
except Exception as e:
send_message(chat_id, f"❌ AutoHeal 觸發異常:{e}", reply_to, parse_mode=None)
elif cmd == 'obs_budget':
# 當月預算 vs 實際 spent
try:
from database.manager import DatabaseManager
from sqlalchemy import text as _sa
from datetime import datetime as _dt
today = _dt.now()
month_start = _dt(today.year, today.month, 1)
session = DatabaseManager().get_session()
budgets = session.execute(
_sa("""
SELECT period, provider, budget_usd, alert_pct
FROM ai_call_budgets
ORDER BY period, provider NULLS FIRST
"""),
).fetchall()
spent_rows = session.execute(
_sa("""
SELECT provider, COALESCE(SUM(cost_usd), 0)
FROM ai_calls
WHERE called_at >= :ms
GROUP BY provider
"""),
{'ms': month_start},
).fetchall()
session.close()
spent_map = {r[0]: float(r[1] or 0) for r in spent_rows}
lines = [f"💰 *預算控管({today.year}-{today.month:02d}*", ""]
warn = False
for period, provider, budget, alert_pct in budgets:
spent = spent_map.get(provider, 0.0) if provider else sum(spent_map.values())
ratio = (spent / float(budget)) if float(budget) > 0 else 0
pct = ratio * 100
if ratio >= 1.0:
icon = "🚨"
warn = True
elif ratio >= float(alert_pct or 80) / 100:
icon = "⚠️"
warn = True
else:
icon = ""
lines.append(f"{icon} `{period}` `{provider or '(全部)'}`${spent:.2f} / ${float(budget):.2f} ({pct:.0f}%)")
if not budgets:
lines.append("(尚無預算設定,需先跑 migrations/025")
if warn:
lines.append("")
lines.append("⚠️ *已有供應商超出告警閾值*,請至 Web 介面確認。")
lines.append("")
lines.append("詳細查詢mo.wooo.work/observability/budget")
# Phase 41 E-3: warn 時顯示 force-throttle inline button (L2)
kb_rows = []
if warn:
kb_rows.append(_row(('⚡ 立即重算節流狀態', 'cmd:obs_force_throttle')))
kb_rows.append(_row(('📊 AI 呼叫', 'cmd:obs_ai_calls'), ('🏥 主機健康', 'cmd:obs_health')))
kb_rows.append(_row(('💬 反饋趨勢', 'cmd:obs_quality'), ('← 返回主選單', 'menu:main')))
send_message(chat_id, '\n'.join(lines), reply_to, kb_rows, parse_mode='Markdown')
except Exception as e:
send_message(chat_id, f"❌ 查詢預算失敗:{e}", reply_to, parse_mode=None)
elif cmd == 'obs_overview':
# Phase 49: 觀測台總覽(一頁式 KPI
try:
from database.manager import DatabaseManager
from sqlalchemy import text as _sa
from datetime import datetime as _dt
today = _dt.now()
month_start = _dt(today.year, today.month, 1)
session = DatabaseManager().get_session()
host_rows = session.execute(_sa("""
SELECT host_label, COUNT(*), COUNT(*) FILTER (WHERE healthy)
FROM host_health_probes
WHERE probed_at >= NOW() - INTERVAL '24 hours'
GROUP BY host_label ORDER BY host_label
""")).fetchall()
ai = session.execute(_sa("""
SELECT COUNT(*), COALESCE(SUM(cost_usd), 0),
COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')),
COUNT(*) FILTER (WHERE rag_hit)
FROM ai_calls WHERE called_at >= NOW() - INTERVAL '24 hours'
""")).fetchone()
month_cost = session.execute(
_sa("SELECT COALESCE(SUM(cost_usd), 0) FROM ai_calls WHERE called_at >= :ms"),
{'ms': month_start},
).fetchone()[0] or 0
ep_pending = session.execute(
_sa("SELECT COUNT(*) FROM learning_episodes WHERE promotion_status = 'awaiting_review' AND reviewed_at IS NULL"),
).fetchone()[0] or 0
session.close()
ai_total = int(ai[0] or 0)
err_rate = (int(ai[2] or 0) / ai_total * 100) if ai_total else 0
rag_rate = (int(ai[3] or 0) / ai_total * 100) if ai_total else 0
lines = ["🛰 *觀測台總覽24h*", ""]
lines.append("*三主機在線率:*")
for label, total, up in host_rows:
pct = (float(up) / float(total) * 100) if total else 0
emoji = "" if pct >= 99 else "⚠️" if pct >= 90 else "🚨"
lines.append(f"{emoji} {label}*{pct:.1f}%*")
lines.append("")
lines.append(f"📊 AI 呼叫:*{ai_total:,}* 次(錯誤 {err_rate:.1f}%")
lines.append(f"💰 24h 成本:*${float(ai[1] or 0):.2f}* · 當月 *${float(month_cost):.2f}*")
lines.append(f"💡 RAG 命中率:*{rag_rate:.1f}%*")
if ep_pending:
lines.append(f"📋 待審 episodes*{ep_pending}* 筆")
lines.append("")
lines.append("詳細mo.wooo.work/observability/overview")
kb = [_row(('🤖 Agent 編排', 'cmd:obs_orchestration'), ('💼 商業面 AI', 'cmd:obs_business')),
_row(('🏥 主機健康', 'cmd:obs_health'), ('📊 AI 呼叫', 'cmd:obs_ai_calls')),
_row(('← 返回主選單', 'menu:main'))]
send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown')
except Exception as e:
send_message(chat_id, f"❌ 查詢觀測台總覽失敗:{e}", reply_to, parse_mode=None)
elif cmd == 'obs_orchestration':
# Phase 49: Agent 編排矩陣4 Agent × Models
try:
from database.manager import DatabaseManager
from sqlalchemy import text as _sa
agent_groups = [
('🤖 OpenClaw', ['openclaw_qa', 'openclaw_qa_gemini_fallback', 'openclaw_qa_nim', 'openclaw_daily', 'openclaw_daily_gemini_fallback', 'openclaw_daily_nim', 'openclaw_meta', 'openclaw_meta_gemini_fallback', 'openclaw_meta_nim', 'openclaw_monthly', 'openclaw_monthly_gemini_fallback', 'openclaw_monthly_nim', 'openclaw_weekly', 'openclaw_weekly_gemini_fallback', 'openclaw_weekly_nim', 'openclaw_bot_main', 'openclaw_bot_gemini', 'openclaw_bot_nim', 'sales_copy', 'code_review_openclaw', 'code_review_openclaw_gemini', 'openclaw_daily_insight', 'openclaw_daily_insight_gemini_fallback', 'openclaw_daily_insight_nim']),
('🔍 Hermes', ['hermes_analyst', 'hermes_intent', 'code_review_hermes']),
('🧬 NemoTron', ['nemotron_dispatch']),
('🐘 ElephantAlpha', ['ea_engine', 'code_review_elephant']),
]
session = DatabaseManager().get_session()
lines = ["🌐 *Agent 編排矩陣24h*", ""]
for label, callers in agent_groups:
row = session.execute(_sa("""
SELECT COUNT(*),
COALESCE(SUM(cost_usd), 0),
COUNT(*) FILTER (WHERE provider IN ('gcp_ollama','ollama_secondary','ollama_111','ollama_other')),
COUNT(*) FILTER (WHERE rag_hit),
COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only'))
FROM ai_calls
WHERE called_at >= NOW() - INTERVAL '24 hours'
AND caller = ANY(:c)
"""), {'c': callers}).fetchone()
calls = int(row[0] or 0)
if calls == 0:
lines.append(f"{label}:(無呼叫)")
continue
cost = float(row[1] or 0)
ollama_pct = float(row[2] or 0) / calls * 100
rag_pct = float(row[3] or 0) / calls * 100
err_pct = float(row[4] or 0) / calls * 100
lines.append(f"{label}*{calls:,}* 次 · ${cost:.2f}")
lines.append(f" 本地 Ollama {ollama_pct:.0f}% · RAG {rag_pct:.0f}% · 錯誤 {err_pct:.1f}%")
session.close()
lines.append("")
lines.append("詳細mo.wooo.work/observability/agent\\_orchestration")
kb = [_row(('🛰 觀測台總覽', 'cmd:obs_overview'), ('💼 商業面 AI', 'cmd:obs_business')),
_row(('← 返回主選單', 'menu:main'))]
send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown')
except Exception as e:
send_message(chat_id, f"❌ 查詢 Agent 編排失敗:{e}", reply_to, parse_mode=None)
elif cmd == 'obs_business':
# Phase 49: 商業面 × AI 編排AI 在做什麼生意)
try:
from database.manager import DatabaseManager
from sqlalchemy import text as _sa
session = DatabaseManager().get_session()
rec_rows = session.execute(_sa("""
SELECT strategy, COUNT(*), COALESCE(AVG(confidence), 0)
FROM ai_price_recommendations
WHERE created_at >= NOW() - INTERVAL '7 days'
GROUP BY strategy ORDER BY 2 DESC
""")).fetchall()
verdict_rows = session.execute(_sa("""
SELECT verdict, COUNT(*) FROM action_outcomes
WHERE created_at >= NOW() - INTERVAL '30 days'
GROUP BY verdict
""")).fetchall()
unfollowed = session.execute(_sa("""
SELECT COUNT(*) FROM ai_price_recommendations r
WHERE r.created_at >= NOW() - INTERVAL '7 days'
AND r.confidence >= 0.7
AND NOT EXISTS (
SELECT 1 FROM action_plans p
WHERE p.sku = r.sku
AND p.created_at >= r.created_at
AND p.created_at < r.created_at + INTERVAL '7 days'
)
""")).fetchone()[0] or 0
session.close()
lines = ["💼 *商業面 × AI 編排*", ""]
if rec_rows:
lines.append("*AI 價格決策 7d*")
for strategy, cnt, conf in rec_rows:
lines.append(f"{strategy}*{int(cnt):,}* 筆(信心 {float(conf):.2f}")
else:
lines.append("(過去 7 日無 AI 價格決策)")
if unfollowed > 0:
lines.append("")
lines.append(f"⚠️ *未跟進機會:{unfollowed} 筆*high-confidence 卻無 action_plan")
if verdict_rows:
lines.append("")
lines.append("*Outcomes Verdict 30d*")
for v, c in verdict_rows:
icon = "" if v == 'effective' else "" if v == 'backfired' else ""
lines.append(f"{icon} {v}*{int(c):,}*")
lines.append("")
lines.append("詳細mo.wooo.work/observability/business\\_intel")
kb = [_row(('🛰 觀測台總覽', 'cmd:obs_overview'), ('🌐 Agent 編排', 'cmd:obs_orchestration')),
_row(('← 返回主選單', 'menu:main'))]
send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown')
except Exception as e:
send_message(chat_id, f"❌ 查詢商業面失敗:{e}", reply_to, parse_mode=None)
elif cmd == 'obs_trigger_review':
# Phase 44 (L2)Telegram inline 觸發程式碼審查流程
try:
import subprocess
import threading
from services.code_review_pipeline_service import CodeReviewPipeline
commit_sha = subprocess.check_output(
['git', 'rev-parse', 'HEAD'], stderr=subprocess.DEVNULL,
).decode().strip()
changed = subprocess.check_output(
['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', commit_sha],
stderr=subprocess.DEVNULL,
).decode().strip().split('\n')
changed = [f for f in changed if f]
if not changed:
send_message(chat_id, "⚠️ 最新提交沒有變更檔案,無需程式碼審查", reply_to, parse_mode=None)
return
pipeline = CodeReviewPipeline(
commit_sha=commit_sha,
changed_files=changed,
branch='main',
deploy_type='telegram_observability',
)
threading.Thread(target=pipeline.run, daemon=True).start()
ack = (
f"🔬 程式碼審查流程已派出\n\n"
f"流程編號: {pipeline.pipeline_id}\n"
f"提交: {commit_sha[:8]}\n"
f"變更檔案: {len(changed)}\n\n"
f"5 個步驟完成後會推 Telegram 通知。"
)
send_message(chat_id, ack, reply_to, parse_mode=None)
except Exception as e:
send_message(chat_id, f"❌ 程式碼審查觸發失敗:{e}", reply_to, parse_mode=None)
elif cmd == 'obs_force_throttle':
# Phase 41 E-3 (L2)Telegram inline 觸發立即重算 cost throttle
try:
from services.cost_throttle_service import (
evaluate_throttle_status, is_cost_throttle_enabled,
)
if not is_cost_throttle_enabled():
send_message(chat_id, "⚠️ COST_THROTTLE_ENABLED=false先設環境變數", reply_to, parse_mode=None)
return
new_state = evaluate_throttle_status()
throttled = [p for p, s in new_state.items() if s.get('throttled')]
if throttled:
ack = "⚡ 已立即重算 throttle被節流的 provider\n" + '\n'.join(f"{p}" for p in throttled)
else:
ack = "⚡ 已立即重算 throttle目前無 provider 被節流)"
send_message(chat_id, ack[:1200], reply_to, parse_mode=None)
except Exception as e:
send_message(chat_id, f"❌ force-throttle 失敗:{e}", reply_to, parse_mode=None)
elif cmd == 'obs_quality':
# 30d caller 反饋趨勢
try:
from services.feedback_quality_tracker import (
compute_caller_quality_trend, get_caller_recommendations,
)
trends = compute_caller_quality_trend(days=30)
recs = get_caller_recommendations(days=30)
sorted_trends = sorted(trends.items(), key=lambda kv: kv[1].get('avg_score', 5))[:8]
lines = ["💬 *Caller 反饋趨勢(過去 30 日)*", ""]
if not sorted_trends:
lines.append("(過去 30 日無反饋資料)")
else:
lines.append("*平均分數最低 8 名:*")
for caller, info in sorted_trends:
avg = info.get('avg_score', 0)
up = info.get('thumbs_up', 0)
dn = info.get('thumbs_down', 0)
n = info.get('total_feedback', 0)
trend = info.get('trend', 'unknown')
icon = {'positive': '📈', 'negative': '📉', 'neutral': ''}.get(trend, '')
lines.append(f"{icon} `{caller}` *{avg:.2f}*/5 · 👍{up} 👎{dn} · N={n}")
if recs:
lines.append("")
lines.append("*智能建議:*")
for r in recs[:5]:
icon = "⚠️" if r.get('action') == 'review' else ""
lines.append(f"{icon} `{r.get('caller')}`{r.get('reason')}")
lines.append("")
lines.append("詳細查詢mo.wooo.work/observability/quality\\_trend")
kb = [_row(('📊 AI 呼叫', 'cmd:obs_ai_calls'), ('🏥 主機健康', 'cmd:obs_health')),
_row(('💰 預算控管', 'cmd:obs_budget'), ('← 返回主選單', 'menu:main'))]
send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown')
except Exception as e:
send_message(chat_id, f"❌ 查詢反饋趨勢失敗:{e}", reply_to, parse_mode=None)
else:
# 不認識的指令 → 自然語言
txt, kb = openclaw_answer(cmd + (' ' + arg if arg else ''), chat_id=chat_id)
send_message(chat_id, txt, reply_to, kb)
def _write_event_ignore_audit(event_id: str, user_label: str, ts_label: str) -> None:
"""將 EA HITL 忽略決策寫入 ai_insights供 webhook / polling 共用語意。"""
from database.manager import get_session
session = get_session()
try:
session.execute(
text("""
INSERT INTO ai_insights
(insight_type, content, confidence, created_by, status, metadata_json)
VALUES (:type, :content, :conf, :by, :status, :meta)
"""),
{
"type": "human_review",
"content": f"[EA HITL] 事件 {event_id}{user_label} 忽略",
"conf": 1.0,
"by": f"telegram:{user_label}",
"status": "ignored",
"meta": json.dumps({
"event_id": event_id,
"decided_by": user_label,
"decided_at": ts_label,
"decision": "ignored",
}, ensure_ascii=False),
},
)
session.commit()
finally:
session.close()
def _handle_event_ignore_callback(data: str, cq: dict, chat_id, message_id) -> None:
"""處理 `momo:eig:<event_id>` webhook callback避免 HITL 按鈕無反應。"""
from html import escape as _html_escape
parts = data.split(':', 2)
event_id = parts[2].strip() if len(parts) >= 3 else ''
if not event_id:
send_message(chat_id, "⚠️ event_id 缺失,忽略動作未生效", None, None, parse_mode=None)
sys_log.warning("[EA HITL] empty event_id callback rejected: %r", data)
return
user = cq.get('from') or {}
user_label_raw = (
user.get('username')
or user.get('first_name')
or str(user.get('id') or '?')
)
ts_label_raw = datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M')
try:
_write_event_ignore_audit(event_id, user_label_raw, ts_label_raw)
except Exception as audit_err:
sys_log.warning(f"[EA HITL] ai_insights audit 寫入失敗(不阻斷 UI: {audit_err}")
user_label_safe = _html_escape(str(user_label_raw))
ts_label_safe = _html_escape(ts_label_raw)
original = (cq.get('message') or {}).get('text') or (cq.get('message') or {}).get('caption') or '事件已忽略'
text = (
_html_escape(str(original))[:3400]
+ f"\n\n🛑 <b>已忽略</b> by {user_label_safe} @ {ts_label_safe}"
)
edited = False
if message_id:
result = edit_message_text(chat_id, message_id, text, keyboard=None, parse_mode="HTML")
edited = bool(isinstance(result, dict) and result.get("ok"))
if not edited:
send_message(
chat_id,
f"🛑 已忽略事件 {event_id} by {user_label_raw} @ {ts_label_raw}",
None,
None,
parse_mode=None,
)
sys_log.info(f"[EA HITL] event_ignore event_id={event_id} by={user_label_raw}")
def _clean_vision_product_name(raw: str) -> str:
"""把 vision 模型回應收斂成可直接丟給比價查詢的商品名稱。"""
text = (raw or '').strip()
if not text:
return ''
text = re.sub(r"^```(?:text)?\s*", "", text, flags=re.IGNORECASE).strip()
text = re.sub(r"\s*```$", "", text).strip()
first_line = next((line.strip() for line in text.splitlines() if line.strip()), '')
first_line = re.sub(r"^(商品名稱|品名|辨識結果|結果)\s*[:]\s*", "", first_line).strip()
first_line = first_line.strip("`*_ - ")
if not first_line:
return ''
refusal_patterns = ('無法辨識', '看不清', '無法確認', '不確定', 'unknown', 'not sure')
lowered = first_line.lower()
if any(pattern in lowered for pattern in refusal_patterns):
return ''
return first_line[:60]
def _identify_product_name_with_ollama_vision(img_b64: str, request_id: str) -> str:
"""圖片比價的主辨識路徑Ollama vision 三主機級聯。"""
if not _OLLAMA_AVAILABLE:
return ''
prompt = (
"這是一張商品圖片。請辨識商品名稱,包含品牌、型號、規格。"
"只回商品名稱,不要解釋,不要 markdown不超過 30 字;"
"如果是多個商品,只取最顯眼的一個。必須使用繁體中文。"
)
timeout = int(os.getenv('OPENCLAW_IMAGE_OLLAMA_TIMEOUT', '45'))
with log_ai_call(
caller='openclaw_bot_image',
provider='gcp_ollama',
model=IMAGE_VISION_OLLAMA_MODEL,
request_id=request_id,
meta={'route': 'ollama_first', 'task': 'image_product_recognition'},
) as ctx:
try:
resp = OllamaService(model=IMAGE_VISION_OLLAMA_MODEL).generate(
prompt=prompt,
model=IMAGE_VISION_OLLAMA_MODEL,
temperature=0.1,
timeout=timeout,
options={'num_predict': 64},
images=[img_b64],
)
ctx.set_provider(get_provider_tag(resp.host or ''))
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 not resp.success:
ctx.set_error(resp.error or 'ollama vision failed')
ctx.fallback_to_caller('openclaw_bot_image_gemini')
return ''
product_name = _clean_vision_product_name(resp.content)
if not product_name:
ctx.set_error('empty_or_unusable_vision_response')
ctx.fallback_to_caller('openclaw_bot_image_gemini')
return product_name
except Exception as exc:
ctx.set_error(f"{type(exc).__name__}: {exc}")
ctx.fallback_to_caller('openclaw_bot_image_gemini')
sys_log.warning(f"[VisionSearch] Ollama vision failed: {exc}")
return ''
def _identify_product_name_with_gemini_vision(img_b64: str, request_id: str) -> str:
"""圖片比價的雲端備援:只有 Ollama vision 失敗後才呼叫。"""
gemini_api_key = _gemini_fallback_api_key('openclaw_image_vision')
if not gemini_api_key:
return ''
vision_payload = {
'contents': [{
'parts': [
{'text': (
'這是一張商品圖片。請辨識商品名稱(品牌、型號、規格),'
'輸出格式:只回商品名稱,不超過 30 字,繁體中文。'
'如果是多個商品,只取最顯眼的一個。'
)},
{'inline_data': {'mime_type': 'image/jpeg', 'data': img_b64}},
],
}],
}
with log_ai_call(
caller='openclaw_bot_image_gemini',
provider='gemini',
model=IMAGE_VISION_GEMINI_MODEL,
request_id=request_id,
meta={'fallback_from': 'openclaw_bot_image', 'task': 'image_product_recognition'},
) as ctx:
try:
vis_r = requests.post(
f"{GEMINI_BASE_URL}/{IMAGE_VISION_GEMINI_MODEL}:generateContent?key={gemini_api_key}",
json=vision_payload, timeout=20,
)
vis_r.raise_for_status()
body = vis_r.json()
usage = body.get('usageMetadata', {}) or {}
ctx.set_tokens(
input=usage.get('promptTokenCount', 0),
output=usage.get('candidatesTokenCount', 0),
)
raw = (
body
.get('candidates', [{}])[0]
.get('content', {})
.get('parts', [{}])[0]
.get('text', '')
)
product_name = _clean_vision_product_name(raw)
if not product_name:
ctx.set_error('empty_or_unusable_vision_response')
return product_name
except Exception as exc:
ctx.set_error(f"{type(exc).__name__}: {exc}")
sys_log.warning(f"[VisionSearch] Gemini vision fallback failed: {exc}")
return ''
# ── Webhook ───────────────────────────────────────────────────
@openclaw_bot_bp.route('/bot/telegram/webhook', methods=['POST'])
def telegram_webhook():
try:
# 每個 webhook request 先清空 ContextVar避免同 worker thread 延用上一個 user。
_CURRENT_USER_ID_CTX.set(None)
update = request.get_json(silent=True)
if not update:
return jsonify({'ok': True})
# ── Telegram retry 去重 ───────────────────────────────
uid = update.get('update_id')
# ── Callback Query按鈕─────────────────────────────
if 'callback_query' in update:
cq = update['callback_query']
cq_id = cq['id']
data = _normalize_callback_data(cq.get('data', ''))
chat_id = cq['message']['chat']['id']
chat_type = cq['message']['chat'].get('type', '')
cq_from_id = (cq.get('from') or {}).get('id')
cq_message_id = cq.get('message', {}).get('message_id')
duplicate_key = _build_callback_dedupe_key(
update_id=uid,
cq_id=cq_id,
message_id=cq_message_id,
data=data,
chat_id=chat_id,
user_id=cq_from_id,
)
if _is_duplicate_update(duplicate_key):
sys_log.debug(
f"[OpenClawBot] duplicate callback uid={uid} cq_id={cq_id}, skip"
)
answer_callback(cq_id)
return jsonify({'ok': True})
sys_log.info(f'[OpenClawBot] CB: chat={chat_id} type={chat_type} data={data} allowed={ALLOWED_GROUP}')
# fail-closed未授權一律安靜拒絕關閉 loading不回任何訊息避免偵察
if not _is_authorized(chat_type, chat_id, cq_from_id):
sys_log.warning(
f'[OpenClawBot] CB rejected: chat={chat_id} type={chat_type} user={cq_from_id}'
)
answer_callback(cq_id)
return jsonify({'ok': False, 'error': 'forbidden'})
# critic Medium-3把 user_id 放進 ContextVar 供 handle_cmd 內部 _is_admin 讀
_user_token = _CURRENT_USER_ID_CTX.set(cq_from_id)
answer_callback(cq_id)
send_typing(chat_id)
if data.startswith('momo:eig:'):
_handle_event_ignore_callback(data, cq, chat_id, cq_message_id)
return jsonify({'ok': True})
# ── Phase 11 RAG 反饋v5.0 護欄 #1─────────────────────
# rag_fb:{log_id}:{score} → 寫回 rag_query_log.feedback_score
# pg_ok:{episode_id} → PromotionGate 人工通過
# pg_no:{episode_id} → PromotionGate 人工駁回
# 早於 menu: / cmd: / await: 處理;命中即 short-circuit。
if data.startswith('rag_fb:'):
try:
parts = data.split(':')
if len(parts) >= 3:
log_id = int(parts[1])
score = int(parts[2])
from services.rag_service import rag_service as _rag
ok = _rag.feedback(log_id, score)
ack_text = "已記錄 👍" if score >= 4 else "已記錄 👎"
if not ok:
ack_text = "反饋寫入失敗(已 log"
sys_log.info(
f"[OpenClawBot] RAG feedback log_id={log_id} score={score} ok={ok}"
)
send_message(chat_id, ack_text, None, None)
except Exception as exc:
sys_log.warning(f"[OpenClawBot] rag_fb 處理失敗: {exc}")
return jsonify({'ok': True})
if data.startswith('pg_ok:') or data.startswith('pg_no:'):
try:
action, _eid = data.split(':', 1)
episode_id = int(_eid)
from services.learning_pipeline import promotion_gate, hash_human_approver
approver_hash = hash_human_approver(str(cq_from_id or ''))
if action == 'pg_ok':
# 人工通過 → 直接 promote不重跑 4 stage
insight_id = promotion_gate.promote(
episode_id,
human_approver=approver_hash,
)
ack = (
f"已晉升至 ai_insights #{insight_id}"
if insight_id else "晉升失敗(已 log"
)
else:
promotion_gate.reject(
episode_id, 'rejected_human',
detail='human rejected via Telegram',
human_approver=approver_hash,
)
ack = "已駁回rejected_human"
sys_log.info(
f"[OpenClawBot] PromotionGate {action} episode_id={episode_id} "
f"by={approver_hash}"
)
send_message(chat_id, ack, None, None)
except Exception as exc:
sys_log.warning(f"[OpenClawBot] pg_ok/pg_no 處理失敗: {exc}")
return jsonify({'ok': True})
if data.startswith('menu:'):
# 顯示子選單或返回主選單
key = data[5:]
fn = _SUBMENUS.get(key)
if fn:
kb = fn()
titles = {
'main': '👋 *OpenClaw* — 請選擇功能類別',
'sales': '📊 *業績查詢* — 選擇日期或直接輸入',
'products': '🏆 *商品廠商* — 選擇查詢範圍',
'goals': '🎯 *目標管理* — 查看或設定業績目標',
'analysis': '📈 *智能分析* — 選擇分析類型',
'trend': '📈 *業績趨勢* — 選擇時間範圍',
'reports': '📄 *簡報報表* — 選擇報告類型',
'market': '🌐 *市場情報* — 即時資訊',
'competitor': '📊 *競品比價日報* — 選擇分析日期',
'competitor_ppt': '📄 *競品比價簡報* — 選擇時間範圍',
'category': '🗂 *分類業績鑽取* — 點選分類深入分析',
'observability': '🛰 *AI 觀測台* — 系統指標與成本控管',
}
if cq_message_id:
result = edit_message_text(
chat_id,
cq_message_id,
titles.get(key, '請選擇'),
kb,
)
if _should_fallback_send_message(result):
send_message(chat_id, titles.get(key, '請選擇'), None, kb)
else:
send_message(chat_id, titles.get(key, '請選擇'), None, kb)
elif data.startswith('await:'):
# 進入輸入等待狀態
action = data[6:]
if action in _AWAIT_PROMPTS:
prompt_text, label = _AWAIT_PROMPTS[action]
_input_pending[chat_id] = {'action': action, 'label': label}
cancel_kb = [_row(('✖ 取消', 'menu:main'))]
if cq_message_id:
result = edit_message_text(
chat_id,
cq_message_id,
f"{prompt_text}\n\n_輸入 `/取消` 可退出_",
cancel_kb,
parse_mode='Markdown',
)
if _should_fallback_send_message(result):
send_message(
chat_id,
f"{prompt_text}\n\n_輸入 `/取消` 可退出_",
None,
cancel_kb,
parse_mode='Markdown',
)
else:
send_message(
chat_id,
f"{prompt_text}\n\n_輸入 `/取消` 可退出_",
None,
cancel_kb,
parse_mode='Markdown',
)
elif data.startswith('cmd:'):
parts = data[4:].split(':', 1)
with _run_with_callback_cmd_context():
if cq_message_id:
_orig_send_message = send_message
def _callback_send_message(
_chat_id,
_text,
_reply_to=None,
_keyboard=None,
_parse_mode="Markdown",
**_kwargs,
):
if _reply_to is None and "reply_to" in _kwargs:
_reply_to = _kwargs.pop("reply_to")
if "keyboard" in _kwargs:
_keyboard = _kwargs.pop("keyboard")
if "parse_mode" in _kwargs:
_parse_mode = _kwargs.pop("parse_mode")
if _reply_to == cq_message_id:
result = edit_message_text(
_chat_id,
cq_message_id,
_text,
_keyboard,
_parse_mode,
)
if not _should_fallback_send_message(result):
return result
return _orig_send_message(
_chat_id,
_text,
_reply_to,
_keyboard,
_parse_mode,
**_kwargs,
)
with _CALLBACK_SEND_LOCK:
try:
globals()['send_message'] = _callback_send_message
handle_cmd(parts[0], parts[1] if len(parts) > 1 else '', chat_id, cq_message_id)
finally:
globals()['send_message'] = _orig_send_message
else:
handle_cmd(parts[0], parts[1] if len(parts) > 1 else '', chat_id, cq_message_id)
return jsonify({'ok': True})
if _is_duplicate_update(uid):
sys_log.debug(f"[OpenClawBot] duplicate update_id={uid}, skip")
return jsonify({'ok': True})
# ── Message ───────────────────────────────────────────
msg = update.get('message') or update.get('edited_message')
if not msg:
return jsonify({'ok': True})
chat = msg.get('chat', {})
chat_id = chat.get('id')
chat_type = chat.get('type', '')
text_raw = (msg.get('text') or '').strip()
msg_id = msg.get('message_id')
# fail-closed 統一授權檢查(覆蓋 group/supergroup/private/channel/unknown
_uid = (msg.get('from') or {}).get('id')
if not _is_authorized(chat_type, chat_id, _uid):
sys_log.warning(
f'[OpenClawBot] MSG rejected: chat={chat_id} type={chat_type} user={_uid}'
)
# 靜默拒絕:不回 Telegram 訊息(避免陌生人偵察 bot 存在與白名單機制)
return jsonify({'ok': False, 'error': 'forbidden'})
# critic Medium-3把 user_id 放進 ContextVar 供 handle_cmd 內部 _is_admin 讀
_CURRENT_USER_ID_CTX.set(_uid)
if chat_type in ('group', 'supergroup'):
# 移除 @mention不強制要求但如有則移除
question = text_raw.replace(BOT_USERNAME, '').strip()
else:
# 已通過授權的 private chat
question = text_raw
# ── 圖片訊息Ollama-first Vision 商品辨識 ─────────────────
if not question and msg.get('photo'):
send_typing(chat_id)
try:
photos = msg['photo']
file_id = photos[-1]['file_id'] # 取最大尺寸
# 取得 file path
r_file = requests.get(
f"{BOT_API_URL}/getFile", params={'file_id': file_id}, timeout=10
).json()
file_path_tg = r_file.get('result', {}).get('file_path', '')
if not file_path_tg:
send_message(chat_id, "⚠️ 無法取得圖片", msg_id)
return jsonify({'ok': True})
# 下載圖片
img_url = f"https://api.telegram.org/file/bot{BOT_TOKEN}/{file_path_tg}"
img_data = requests.get(img_url, timeout=15).content
import base64 as _b64
img_b64 = _b64.b64encode(img_data).decode()
req_id = f"img-{chat_id or 0}-{msg_id or 0}"
product_name = _identify_product_name_with_ollama_vision(img_b64, req_id)
if not product_name:
product_name = _identify_product_name_with_gemini_vision(img_b64, req_id)
if product_name:
send_message(chat_id,
f"🔍 辨識到商品:*{product_name}*\n正在搜尋 momo 比價...",
msg_id, parse_mode='Markdown')
# 直接執行比價
handle_cmd('competitor', product_name, chat_id, msg_id)
else:
send_message(
chat_id,
"⚠️ 無法辨識圖片中的商品,請直接輸入商品名稱搜尋",
msg_id,
[_row(('🔍 文字搜尋', 'await:search_compare'))],
)
except Exception as _img_e:
sys_log.error(f"[VisionSearch] {_img_e}")
send_message(chat_id, "⚠️ 圖片處理失敗,請直接輸入商品名稱搜尋", msg_id)
return jsonify({'ok': True})
# ── Excel 文件匯入document────────────────────────────
if not question and msg.get('document'):
doc = msg['document']
fname = doc.get('file_name', '')
if fname.lower().endswith(('.xlsx', '.xls')):
send_typing(chat_id)
threading.Thread(
target=_handle_excel_import,
args=(doc, chat_id, msg_id),
daemon=True
).start()
else:
send_message(chat_id,
f"⚠️ 僅支援 `.xlsx` / `.xls` 格式的業績 Excel 檔案\n"
f"收到的檔案:`{fname}`",
msg_id, parse_mode='Markdown')
return jsonify({'ok': True})
if not question:
return jsonify({'ok': True})
# ── 頻率限制 ─────────────────────────────────────────────
_uid_rl = msg.get('from', {}).get('id', 0)
if not _check_rate_limit(_uid_rl):
send_message(chat_id, "⚠️ 操作太頻繁,請稍後再試(每分鐘上限 30 次)。", msg_id)
return jsonify({'ok': True})
sys_log.info(f"[OpenClawBot] ← chat={chat_id} msg={question[:60]}")
send_typing(chat_id)
# ── 輸入等待狀態處理 ─────────────────────────────────────
if chat_id in _input_pending and question not in ('/取消', '/cancel'):
pending = _input_pending.pop(chat_id)
action = pending['action']
label = pending.get('label', '')
val = question.strip().replace(',', '').replace('NT$', '').replace('$', '').strip()
if action.startswith('goal_'):
period_map = {
'goal_daily': 'daily',
'goal_monthly': 'monthly',
'goal_quarterly': 'quarterly',
'goal_half': 'half',
'goal_yearly': 'yearly',
}
period = period_map[action]
try:
amount = float(val)
_GOALS[period] = amount
send_message(chat_id,
f"✅ *{label}* 已設定為 `NT$ {amount:,.0f}`",
msg_id, _submenu_goals(), parse_mode='Markdown')
except ValueError:
send_message(chat_id,
f"⚠️ 格式錯誤,請輸入數字(例如:`150000`",
msg_id,
[_row((f'重新設定 {label}', f'await:{action}')), _BACK],
parse_mode='Markdown')
sys_log.info(f"[OpenClawBot] → replied chat={chat_id}")
return jsonify({'ok': True})
elif action == 'search_compare':
# PChome 比價關鍵字輸入
handle_cmd('competitor', val, chat_id, msg_id)
elif action == 'date_range_sales':
# 支援:單日 / 日期區間 / 月份
dates = re.findall(r'\d{4}[/\-]\d{1,2}[/\-]\d{1,2}', val)
month_only = re.match(r'(\d{4})[/\-](\d{1,2})$', val.strip())
if len(dates) >= 2:
s = normalize_date(dates[0])
e = normalize_date(dates[1])
if s == e:
# 起迄同日 → 單日業績
handle_cmd('sales', s, chat_id, msg_id)
else:
# 日期區間 → 趨勢
from datetime import datetime as _dt2
s_d = _dt2.strptime(s.replace('/', '-'), '%Y-%m-%d').date()
e_d = _dt2.strptime(e.replace('/', '-'), '%Y-%m-%d').date()
days_c = (e_d - s_d).days + 1
send_message(chat_id,
f"⏳ 查詢 {s} ~ {e}{days_c}天)...",
msg_id, parse_mode=None)
data_r = query_trend_range(s, e)
if data_r:
period_label = f'{s} ~ {e}{days_c}天)'
send_message(chat_id, fmt_trend(data_r, period_label),
msg_id, _submenu_sales())
else:
send_message(chat_id,
f"⚠️ `{s}` ~ `{e}` 查無業績資料",
msg_id, [_row(('重新輸入', 'await:date_range_sales'))],
parse_mode='Markdown')
elif len(dates) == 1:
# 單一日期
handle_cmd('sales', normalize_date(dates[0]), chat_id, msg_id)
elif month_only:
# 月份格式2026/04
handle_cmd('history', f"{month_only.group(1)}/{int(month_only.group(2)):02d}",
chat_id, msg_id)
else:
send_message(chat_id,
"⚠️ 格式錯誤\n📌 單日:`2026/04/15`\n📌 區間:`2026/04/01-2026/04/15`\n📌 月份:`2026/04`",
msg_id,
[_row(('重新輸入', 'await:date_range_sales')), _BACK],
parse_mode='Markdown')
sys_log.info(f"[OpenClawBot] → replied chat={chat_id}")
return jsonify({'ok': True})
elif action.startswith('date_trend_'):
# 趨勢區間查詢(月份/年份/季度)
trend_val = val.replace('-', '/')
handle_cmd('trend', trend_val, chat_id, msg_id)
elif action.startswith('date_'):
# 驗證日期格式
date_val = val.replace('-', '/')
if re.match(r'\d{4}/\d{1,2}(/\d{1,2})?$', date_val):
if action == 'date_sales':
handle_cmd('sales', date_val, chat_id, msg_id)
elif action == 'date_top':
handle_cmd('top', date_val, chat_id, msg_id)
elif action == 'date_analysis':
# 分析日期:同時出矩陣 + 健康
handle_cmd('strategy', date_val, chat_id, msg_id)
elif action == 'date_ppt_daily':
handle_cmd('ppt', f'daily {date_val}', chat_id, msg_id)
elif action == 'date_ppt_monthly':
handle_cmd('ppt', f'monthly {date_val}', chat_id, msg_id)
elif action == 'date_competitor':
handle_cmd('ppt', f'competitor {date_val}', chat_id, msg_id)
else:
send_message(chat_id,
f"⚠️ 日期格式錯誤,請重新輸入(例如:`2026/04/15`",
msg_id, [_row(('重新輸入', f'await:{action}')), _BACK], parse_mode='Markdown')
elif action == 'promo_range':
# 促銷範圍:格式 2026/04/01-2026/04/07
m = re.findall(r'\d{4}[/\-]\d{1,2}[/\-]\d{1,2}', val)
if len(m) >= 2:
handle_cmd('promo', f'{normalize_date(m[0])}-{normalize_date(m[1])}', chat_id, msg_id)
else:
send_message(chat_id,
"⚠️ 格式錯誤,例如:`2026/04/01-2026/04/07`",
msg_id, [_row(('重新輸入', 'await:promo_range'))],
parse_mode='Markdown')
sys_log.info(f"[OpenClawBot] → replied chat={chat_id}")
return jsonify({'ok': True})
# 取消輸入等待
if question in ('/取消', '/cancel') and chat_id in _input_pending:
_input_pending.pop(chat_id, None)
send_message(chat_id, '已取消', msg_id, main_menu_keyboard())
return jsonify({'ok': True})
# 解析指令(/xxx 或已知指令詞)
q = question.lstrip('/')
parts = q.split(None, 1)
cmd = parts[0].split('@', 1)[0].lower() if parts else ''
arg = parts[1] if len(parts) > 1 else ''
KNOWN = {
'sales', 'top', 'vendor', 'trend', 'report', 'news',
'weather', 'menu', 'start', 'help',
'compare', 'category', 'catdetail', 'goal', 'chart', 'health', 'strategy',
'competitor', 'price', 'restock', 'promo', 'ppt',
'業績', '熱銷', '廠商', '趨勢', '報表', '選單', '幫助',
'同比', '分類', '分類細項', '目標', '圖表', '健康', '策略', '比價',
'補貨', '促銷',
}
if question.startswith('/') or cmd in KNOWN:
handle_cmd(cmd, arg, chat_id, msg_id)
else:
txt, kb = openclaw_answer(question, chat_id=chat_id)
send_message(chat_id, txt, msg_id, kb)
sys_log.info(f"[OpenClawBot] → replied chat={chat_id}")
except Exception as e:
sys_log.error(f"[OpenClawBot] webhook error: {e}", exc_info=True)
return jsonify({'ok': True})
# ── 管理端點 ──────────────────────────────────────────────────
@openclaw_bot_bp.route('/bot/telegram/set_webhook', methods=['POST'])
def set_webhook():
webhook_url = f"{MOMO_BASE_URL}/bot/telegram/webhook"
result = _tg('setWebhook', {
'url': webhook_url,
'allowed_updates': ['message', 'callback_query'],
'drop_pending_updates': True,
})
register_commands()
sys_log.info(f"[OpenClawBot] setWebhook → {result}")
return jsonify({'ok': result.get('ok'), 'webhook_url': webhook_url})
@openclaw_bot_bp.route('/bot/telegram/webhook_info')
def webhook_info():
try:
r = requests.get(f"{BOT_API_URL}/getWebhookInfo", timeout=10)
return jsonify(r.json())
except Exception as e:
return jsonify({'ok': False, 'error': str(e)}), 500
@openclaw_bot_bp.record_once
def _on_register(_state):
"""Blueprint 被 app.register_blueprint 時自動啟動排程"""
start_scheduler()
sys_log.info("[OpenClawBot] Blueprint registered — scheduler boot triggered")