From 4349db201565db8b8cf3fa1f3bd9790925461c93 Mon Sep 17 00:00:00 2001 From: OoO Date: Tue, 28 Apr 2026 11:41:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AiderHeal=20=E6=94=AF=E6=8F=B4=20ssh=20?= =?UTF-8?q?=E8=88=87=20Ollama=20=E8=A8=AD=E7=82=BA=E9=A6=96=E9=81=B8=20AI?= =?UTF-8?q?=20=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 6 +- routes/openclaw_bot_routes.py | 810 +++++++--------------------------- services/ollama_service.py | 33 +- 3 files changed, 168 insertions(+), 681 deletions(-) diff --git a/Dockerfile b/Dockerfile index a79d295..d2ba1a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,6 @@ RUN apt-get update && apt-get install -y \ g++ \ curl \ libpq-dev \ - postgresql-client \ # Chrome/Selenium 依賴 wget \ gnupg \ @@ -31,6 +30,7 @@ RUN apt-get update && apt-get install -y \ libxrandr2 \ xdg-utils \ fonts-liberation \ + openssh-client \ libappindicator3-1 || true \ && rm -rf /var/lib/apt/lists/* @@ -55,7 +55,7 @@ COPY . . RUN mkdir -p data logs backups # 確保 components symlink 正確(根目錄頁面需要此路徑) -RUN rm -rf /app/components && ln -sf /app/web/templates/components /app/components +RUN rm -rf /app/components && ln -sf /app/templates/components /app/components # 設定環境變數 ENV PYTHONUNBUFFERED=1 @@ -65,4 +65,4 @@ ENV FLASK_APP=app.py EXPOSE 5000 # 啟動應用 -CMD ["gunicorn", "--bind", "0.0.0.0:80", "--workers", "4", "--timeout", "300", "--access-logfile", "-", "--error-logfile", "-", "app:app"] +CMD ["python", "app.py"] diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 2edae5d..a500b4e 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -24,8 +24,7 @@ import os import re import threading import requests -from collections import deque -from datetime import datetime as _dt, timezone as _tz, timedelta as _td +from datetime import datetime, timezone, timedelta from flask import Blueprint, request, jsonify from sqlalchemy import text @@ -66,17 +65,14 @@ GEMINI_API_KEY = os.getenv('GEMINI_API_KEY', '') GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models' GEMINI_MODEL = 'gemini-2.0-flash' -TAIPEI_TZ = _tz(_td(hours=8)) +TAIPEI_TZ = timezone(timedelta(hours=8)) sys_log = SystemLogger("OpenClawBot").get_logger() openclaw_bot_bp = Blueprint('openclaw_bot', __name__) # ── Telegram retry 去重 (update_id 快取,最多保留 500 筆) ───── -# H4 修補:原本單一 set + set.pop() 隨機刪,可能誤刪剛加入的 update_id。 -# 改為 deque(FIFO 插入序) + set(O(1) lookup) 雙結構;滿時從 deque 頭淘汰最舊。 -_SEEN_MAX = 500 -_seen_update_order: deque = deque(maxlen=_SEEN_MAX) _seen_update_ids: set = set() +_SEEN_MAX = 500 BOT_TOKEN = os.getenv('OPENCLAW_BOT_TOKEN', '8610496165:AAFOlcWV4oRUSC2TI-fYux7JV97fjNzsYR8') BOT_API_URL = f"https://api.telegram.org/bot{BOT_TOKEN}" @@ -97,25 +93,6 @@ ALLOWED_USERS: set = ( if _allowed_users_raw.strip() else set() ) -# ── fail-closed 統一授權檢查 ─────────────────────────────────── -# 規則(任一滿足即通過,否則一律拒絕): -# 1. group/supergroup 且 chat_id == ALLOWED_GROUP -# 2. private 且 user_id ∈ ALLOWED_USERS(env 未設 → 空 set → 全拒) -# channel / 未知 chat_type / 缺欄位 → 拒絕 -# 修補 C3:callback 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': - return uid is not None and uid in ALLOWED_USERS - return False - # ── 速率限制(每用戶每分鐘最多 30 次 AI 呼叫)────────────────── import time as _time_mod _rate_tracker: dict = {} # {user_id: [timestamp, ...]} @@ -307,7 +284,7 @@ def generate_daily_pdf(date_str: str) -> str: products = query_top_products(date_str, 20) vendors = query_top_vendors(date_str, 10) weekly = query_weekly_trend() - now_str = _dt.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M') + now_str = datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M') safe_date = date_str.replace('/', '-') # ── openpyxl Excel(主要,支援中文,秒產生)───────────────── @@ -657,11 +634,11 @@ def query_category_monthly(year: int, month: int, lim: int = 10) -> list: def query_comparison(date_str): """今日 vs 上週同日 vs 上月同日""" - from datetime import datetime as dt, timedelta as td + from datetime import datetime as dt try: d = dt.strptime(normalize_date(date_str).replace('/', '-'), '%Y-%m-%d').date() - lw_str = (d - td(days=7)).strftime('%Y/%m/%d') - lm_str = (d - td(days=30)).strftime('%Y/%m/%d') + 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: @@ -888,169 +865,6 @@ def query_anomalies(date_str): return [] -def query_growth_data() -> dict: - """成長趨勢報告資料 — 複用 growth_analysis 路由的同款邏輯。 - 回傳 {chart_data: {labels, revenue, mom, yoy, aov, margin_rate, profit, orders}, - kpi: {ytd_revenue, ytd_growth, current_year, recent_aov, total_orders}} - """ - import pandas as pd - from datetime import timezone, timedelta - TAIPEI_TZ = _tz(_td(hours=8)) - try: - df = pd.read_sql( - text('SELECT "日期", "總業績", "訂單編號", "總成本" FROM realtime_sales_monthly'), - _db() - ) - if df.empty: - return {} - df['dt'] = pd.to_datetime(df['日期'], errors='coerce') - df = df.dropna(subset=['dt']) - df['amount'] = pd.to_numeric(df['總業績'], errors='coerce').fillna(0) - df['cost'] = pd.to_numeric(df['總成本'], errors='coerce').fillna(0) - df['profit'] = df['amount'] - df['cost'] - - monthly = df.set_index('dt').resample('MS').agg( - {'amount': 'sum', 'profit': 'sum', '訂單編號': 'nunique'} - ).rename(columns={'訂單編號': 'orders'}) - monthly['aov'] = monthly['amount'] / monthly['orders'].replace(0, 1) - monthly['margin_rate'] = (monthly['profit'] / monthly['amount'].replace(0, 1)) * 100 - monthly['mom'] = monthly['amount'].pct_change() * 100 - monthly['yoy'] = monthly['amount'].pct_change(periods=12) * 100 - monthly = monthly.fillna(0) - - labels = monthly.index.strftime('%Y-%m').tolist() - chart_data = { - 'labels': labels, - 'revenue': monthly['amount'].tolist(), - 'profit': monthly['profit'].tolist(), - 'orders': monthly['orders'].tolist(), - 'aov': monthly['aov'].round(0).tolist(), - 'mom': monthly['mom'].round(2).tolist(), - 'yoy': monthly['yoy'].round(2).tolist(), - 'margin_rate': monthly['margin_rate'].round(1).tolist(), - } - - curr_yr = df['dt'].max().year - ytd_mask = df['dt'].dt.year == curr_yr - ly_mask = (df['dt'].dt.year == curr_yr - 1) & \ - (df['dt'].dt.dayofyear <= df['dt'].max().dayofyear) - ytd_rev = float(df.loc[ytd_mask, 'amount'].sum()) - ly_ytd_rev = float(df.loc[ly_mask, 'amount'].sum()) - ytd_growth = (ytd_rev - ly_ytd_rev) / ly_ytd_rev * 100 if ly_ytd_rev else 0 - recent_mask = df['dt'] >= (df['dt'].max() - pd.Timedelta(days=30)) - recent_rev = float(df.loc[recent_mask, 'amount'].sum()) - recent_ord = int(df.loc[recent_mask, '訂單編號'].nunique()) - recent_aov = recent_rev / recent_ord if recent_ord else 0 - - return { - 'chart_data': chart_data, - 'kpi': { - 'ytd_revenue': ytd_rev, - 'ytd_growth': round(ytd_growth, 1), - 'current_year': curr_yr, - 'recent_aov': round(recent_aov, 0), - 'total_orders': int(monthly['orders'].sum()), - } - } - except Exception as e: - sys_log.error(f"[OpenClawBot] query_growth_data: {e}") - return {} - - -def query_vendor_bcg_data(yr: int = None, mo: int = None) -> dict: - """廠商業績 + BCG 矩陣資料(來自 monthly_summary_analysis 表)。 - 回傳 {vendor_ranking: [...], bcg_data: [...], division_dist: [...], kpis: {...}} - """ - try: - yr_filter = f"AND year = {int(yr)}" if yr else "" - mo_filter = f"AND month = {int(mo)}" if mo else "" - - with _db().connect() as c: - # 廠商排行(不限年度,做 2024/2025 對比) - vr = c.execute(text(f""" - SELECT vendor_name, - SUM(sales_amt_curr) AS sales, - SUM(CASE WHEN year=2024 THEN sales_amt_curr ELSE 0 END) AS s24, - SUM(CASE WHEN year=2025 THEN sales_amt_curr ELSE 0 END) AS s25, - SUM(profit_amt_curr) AS profit, - SUM(CASE WHEN year=2024 THEN profit_amt_curr ELSE 0 END) AS p24, - SUM(CASE WHEN year=2025 THEN profit_amt_curr ELSE 0 END) AS p25 - FROM monthly_summary_analysis - WHERE vendor_name IS NOT NULL AND vendor_name != '' - {mo_filter} - GROUP BY vendor_name - ORDER BY sales DESC LIMIT 20 - """)).fetchall() - - # BCG 矩陣(品牌 x 區域) - bq = c.execute(text(f""" - SELECT brand_name || '-' || area_name AS name, - SUM(sales_vol_curr) AS qty, - SUM(sales_amt_curr) AS sales, - SUM(profit_amt_curr) AS profit - FROM monthly_summary_analysis - WHERE sales_amt_curr > 0 - AND brand_name IS NOT NULL AND brand_name != '' - AND area_name IS NOT NULL AND area_name != '' - {yr_filter} {mo_filter} - GROUP BY brand_name, area_name - ORDER BY sales DESC LIMIT 100 - """)).fetchall() - - # 區域分佈 - dq = c.execute(text(f""" - SELECT area_name, - SUM(sales_amt_curr) AS sales, - SUM(CASE WHEN year=2024 THEN sales_amt_curr ELSE 0 END) AS s24, - SUM(CASE WHEN year=2025 THEN sales_amt_curr ELSE 0 END) AS s25 - FROM monthly_summary_analysis - WHERE area_name IS NOT NULL AND area_name != '' - {mo_filter} - GROUP BY area_name - ORDER BY sales DESC LIMIT 12 - """)).fetchall() - - # KPI 總覽 - kq = c.execute(text(f""" - SELECT SUM(sales_amt_curr) AS total_sales, - SUM(profit_amt_curr) AS total_profit - FROM monthly_summary_analysis - WHERE 1=1 {yr_filter} {mo_filter} - """)).fetchone() - - vendor_ranking = [ - {'name': r[0], 'sales': int(r[1] or 0), - 'sales_2024': int(r[2] or 0), 'sales_2025': int(r[3] or 0), - 'profit': int(r[4] or 0), - 'profit_2024': int(r[5] or 0), 'profit_2025': int(r[6] or 0), - 'margin': round(r[4] / r[1] * 100, 1) if r[1] and r[4] else 0} - for r in vr - ] - bcg_data = [ - {'name': r[0], 'qty': int(r[1] or 0), 'sales': int(r[2] or 0), - 'margin': round(r[3] / r[2] * 100, 1) if r[2] and r[3] else 0} - for r in bq - ] - division_dist = [ - {'name': r[0], 'sales': int(r[1] or 0), - 'sales_2024': int(r[2] or 0), 'sales_2025': int(r[3] or 0)} - for r in dq - ] - ts = float(kq[0] or 0) if kq else 0 - tp = float(kq[1] or 0) if kq else 0 - kpis = { - 'total_sales': ts, - 'total_profit': tp, - 'avg_margin': round(tp / ts * 100, 1) if ts else 0, - 'vendor_count': len(vendor_ranking), - } - return {'vendor_ranking': vendor_ranking, 'bcg_data': bcg_data, - 'division_dist': division_dist, 'kpis': kpis} - except Exception as e: - sys_log.error(f"[OpenClawBot] query_vendor_bcg_data: {e}") - return {} - - # ── 目標管理 ────────────────────────────────────────────────── def get_goal_status(date_str: str) -> dict: @@ -1243,7 +1057,7 @@ def gen_trend_chart(days=14, data_points=None, title=None) -> str: ax.set_xticklabels(tick_labels, fontsize=9) ax.set_ylabel('業績(萬元)', fontsize=12) - today_str = dt.now(TAIPEI_TZ).strftime('%Y/%m/%d') + 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') @@ -1302,7 +1116,7 @@ def gen_products_chart(date_str, n=10) -> str: # 取得上週同日資料做顏色比較 try: d = dt.strptime(date_str.replace('/', '-'), '%Y-%m-%d').date() - lw_str = (d - _td(days=7)).strftime('%Y/%m/%d') + 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: @@ -1387,10 +1201,10 @@ def analyze_product_strategy(date_str: str, top_n=10) -> list: if not products: return [] - from datetime import datetime as dt, timedelta as td + from datetime import datetime as dt try: d = dt.strptime(normalize_date(date_str).replace('/', '-'), '%Y-%m-%d').date() - lw_str = (d - td(days=7)).strftime('%Y/%m/%d') + 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: @@ -1430,15 +1244,14 @@ def analyze_product_strategy(date_str: str, top_n=10) -> list: def _analyze_strategy_range(start_str: str, end_str: str, products: list) -> list: """區間版策略分析(週/月/季/年用):比對前一等長區間的成長率""" - from datetime import datetime as dt, timedelta as td if not products: return [] try: - s = dt.strptime(start_str.replace('/', '-'), '%Y-%m-%d') - e = dt.strptime(end_str.replace('/', '-'), '%Y-%m-%d') + 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 - td(days=1)).strftime('%Y/%m/%d') - prev_start = (s - td(days=delta)).strftime('%Y/%m/%d') + 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: @@ -2134,7 +1947,7 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: import threading as _thr _thr.Thread( target=store_insight, - args=(_dt.now(TAIPEI_TZ).strftime('%Y-%m-%d'), + args=(datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'), report_type or 'analysis', result_text), daemon=True ).start() @@ -2151,7 +1964,7 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: import threading as _thr _thr.Thread( target=store_insight, - args=(_dt.now(TAIPEI_TZ).strftime('%Y-%m-%d'), + args=(datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'), report_type or 'analysis', result_text), daemon=True ).start() @@ -2168,7 +1981,6 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str) - generate_daily_ppt, generate_weekly_ppt, generate_monthly_ppt, generate_strategy_ppt, generate_competitor_ppt, generate_promo_ppt, - generate_growth_ppt, generate_vendor_ppt, generate_bcg_ppt, check_pptx_available ) except ImportError: @@ -2177,8 +1989,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str) - if not check_pptx_available(): raise RuntimeError("python-pptx 未安裝,請執行:pip install python-pptx") - from datetime import datetime as _dt, timedelta as _td - now = _dt.now(TAIPEI_TZ) + now = datetime.now(TAIPEI_TZ) # ── MCP 外部情報(所有報告共用)────────────────────────── try: @@ -2272,8 +2083,8 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str) - period_label = f'{date_str} 日策略' elif sub_arg in ('weekly', 'week', '週', '週報'): end_str = latest_date() or now.strftime('%Y/%m/%d') - start_str = (_dt.strptime(end_str.replace('/', '-'), '%Y-%m-%d') - - _td(days=6)).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 ( @@ -2291,20 +2102,20 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str) - 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 = (_dt.strptime(end_str.replace('/', '-'), '%Y-%m-%d') - - _td(days=89)).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 = (_dt.strptime(end_str.replace('/', '-'), '%Y-%m-%d') - - _td(days=179)).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 = (_dt.strptime(end_str.replace('/', '-'), '%Y-%m-%d') - - _td(days=364)).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: @@ -2362,39 +2173,39 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str) - # 決定日期範圍(與 strategy 相同邏輯) if sub_arg in ('weekly', 'week', '週'): - end_d = _dt.strptime( + end_d = datetime.strptime( (latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d') - start_d = end_d - _td(days=6) + start_d = end_d - timedelta(days=6) period_label = '週比較(近7日)' elif sub_arg in ('monthly', 'month', '月'): - end_d = _dt.strptime( + 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} 月比較' elif sub_arg in ('quarterly', 'quarter', 'q', '季'): - end_d = _dt.strptime( + end_d = datetime.strptime( (latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d') - start_d = end_d - _td(days=89) + start_d = end_d - timedelta(days=89) period_label = '季比較(近90日)' elif sub_arg in ('half', '半年'): - end_d = _dt.strptime( + end_d = datetime.strptime( (latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d') - start_d = end_d - _td(days=179) + start_d = end_d - timedelta(days=179) period_label = '半年比較(近180日)' elif sub_arg in ('yearly', 'year', '年'): - end_d = _dt.strptime( + end_d = datetime.strptime( (latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d') - start_d = end_d - _td(days=364) + start_d = end_d - timedelta(days=364) period_label = '年度比較(近365日)' 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 = _dt.strptime(d_str.replace('/', '-'), '%Y-%m-%d') + start_d = end_d = datetime.strptime(d_str.replace('/', '-'), '%Y-%m-%d') period_label = f'{d_str} 日比較' else: # 預設:昨日日報 - yd = (now - _td(days=1)).strftime('%Y/%m/%d') - start_d = end_d = _dt.strptime(yd.replace('/', '-'), '%Y-%m-%d') + 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 = start_d.strftime('%Y/%m/%d') @@ -2468,255 +2279,8 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str) - ai_text_p = _ppt_ai_analysis(data_summary_p, f'促銷效益分析({promo_label_p})') return generate_promo_ppt(promo_label_p, data_p, ai_text_p) - elif sub_type in ('growth', '成長', '趨勢'): - # Check for cached PPT report - from database.ppt_reports import PPTReport - from database.manager import get_session - - session = get_session() - try: - # Find today's generated growth trend report - today = _dt.now() - cached_report = session.query(PPTReport).filter( - PPTReport.report_type == 'growth', - PPTReport.generated_at >= today.replace(hour=0, minute=0, second=0, microsecond=0), - PPTReport.expires_at > today - ).first() - - if cached_report and cached_report.file_path and os.path.exists(cached_report.file_path): - sys_log.info(f"[OpenClawBot] 使用快取的成長趨勢 PPT: {cached_report.file_path}") - return cached_report.file_path - - except Exception as e: - sys_log.error(f"[OpenClawBot] 查詢 PPT 快取失敗: {e}") - finally: - session.close() - - # 沒有快取或已過期,重新生成 - gd = query_growth_data() - if not gd: - raise ValueError("無足夠月度資料生成成長趨勢報告") - kpi_g = gd.get('kpi', {}) - cd_g = gd.get('chart_data', {}) - labels = cd_g.get('labels', []) - rev_g = cd_g.get('revenue', []) - data_summary_g = ( - f"成長趨勢報告\n" - f"YTD 累計業績:NT$ {kpi_g.get('ytd_revenue',0):,.0f} " - f"年增率:{kpi_g.get('ytd_growth',0):+.1f}%\n" - f"近30日客單價:NT$ {kpi_g.get('recent_aov',0):,.0f}\n" - f"月度業績(近6月):" + " / ".join( - f"{labels[i]}(NT${float(rev_g[i])/10000:.1f}萬)" - for i in range(max(0, len(labels)-6), len(labels))) + "\n" - f"MoM 近3月:" + " / ".join( - f"{cd_g.get('mom',[])[i]:+.1f}%" - for i in range(max(0, len(cd_g.get('mom',[]))-3), len(cd_g.get('mom',[])))) - ) - ai_text_g = _ppt_ai_analysis(data_summary_g, '成長趨勢報告') - - # 生成 PPT 並快取 - ppt_path = generate_growth_ppt(gd, ai_text_g) - - # 儲存到資料庫 - session = get_session() - try: - # 設定 24 小時後過期 - expires_at = _dt.now() + _td(hours=24) - - # 刪除舊的快取記錄 - session.query(PPTReport).filter( - PPTReport.report_type == 'growth', - PPTReport.expires_at <= _dt.now() - ).delete() - - # 儲存新的記錄 - report_record = PPTReport( - report_type='growth', - parameters='{}', # 成長報告無特定參數 - file_path=ppt_path, - file_size=os.path.getsize(ppt_path) if os.path.exists(ppt_path) else 0, - cached_data=str(gd), # 快取查詢結果 - expires_at=expires_at - ) - session.add(report_record) - session.commit() - sys_log.info(f"[OpenClawBot] 成長趨勢 PPT 已快取: {ppt_path}") - - except Exception as e: - sys_log.error(f"[OpenClawBot] 儲存 PPT 快取失敗: {e}") - session.rollback() - finally: - session.close() - - return ppt_path - - elif sub_type in ('vendor', '廠商'): - # 檢查是否有快取的 PPT 報告 - from database.ppt_reports import PPTReport - from database.manager import get_session - - yr_v = now.year - mo_v = now.month - if sub_arg: - parts = sub_arg.replace('-', '/').split('/') - if len(parts) >= 2: - yr_v, mo_v = int(parts[0]), int(parts[1]) - - session = get_session() - try: - # 查找今天是否有生成的廠商報告 - today = now - cached_report = session.query(PPTReport).filter( - PPTReport.report_type == 'vendor', - PPTReport.parameters == f"{yr_v}/{mo_v:02d}", - PPTReport.generated_at >= today.replace(hour=0, minute=0, second=0, microsecond=0), - PPTReport.expires_at > today - ).first() - - if cached_report and cached_report.file_path and os.path.exists(cached_report.file_path): - sys_log.info(f"[OpenClawBot] 使用快取的廠商 PPT: {cached_report.file_path}") - return cached_report.file_path - - except Exception as e: - sys_log.error(f"[OpenClawBot] 查詢廠商 PPT 快取失敗: {e}") - finally: - session.close() - - # 沒有快取或已過期,重新生成 - vd = query_vendor_bcg_data(yr_v, mo_v) - if not vd or not vd.get('vendor_ranking'): - raise ValueError("無廠商業績資料(monthly_summary_analysis 表可能尚未匯入)") - vendors = vd['vendor_ranking'] - kpi_v = vd['kpis'] - period_v = f"{yr_v}/{mo_v:02d}" - vd['period_label'] = period_v - top5_v = " / ".join(f"{v['name'][:10]}(NT${v['sales']/10000:.1f}萬)" for v in vendors[:5]) - data_summary_v = ( - f"廠商業績報告 {period_v}\n" - f"廠商總數:{kpi_v.get('vendor_count',0)} 家\n" - f"合計業績:NT$ {kpi_v.get('total_sales',0):,.0f}\n" - f"平均毛利率:{kpi_v.get('avg_margin',0):.1f}%\n" - f"TOP5 廠商:{top5_v}" - ) - ai_text_v = _ppt_ai_analysis(data_summary_v, f'廠商業績報告({period_v})') - - # 生成 PPT 並快取 - ppt_path = generate_vendor_ppt(yr_v, mo_v, vd, ai_text_v) - - # 儲存到資料庫 - session = get_session() - try: - # 設定 24 小時後過期 - expires_at = _dt.now() + _td(hours=24) - - # 刪除舊的快取記錄 - session.query(PPTReport).filter( - PPTReport.report_type == 'vendor', - PPTReport.expires_at <= _dt.now() - ).delete() - - # 儲存新的記錄 - report_record = PPTReport( - report_type='vendor', - parameters=f"{yr_v}/{mo_v:02d}", - file_path=ppt_path, - file_size=os.path.getsize(ppt_path) if os.path.exists(ppt_path) else 0, - cached_data=str(vd), # 快取查詢結果 - expires_at=expires_at - ) - session.add(report_record) - session.commit() - sys_log.info(f"[OpenClawBot] 廠商 PPT 已快取: {ppt_path}") - - except Exception as e: - sys_log.error(f"[OpenClawBot] 儲存廠商 PPT 快取失敗: {e}") - session.rollback() - return ppt_path - elif sub_type in ('bcg', 'BCG', '品牌矩陣', '矩陣'): - # 檢查是否有快取的 PPT 報告 - from database.ppt_reports import PPTReport - from database.manager import get_session - - yr_b = now.year - mo_b = now.month - if sub_arg: - parts = sub_arg.replace('-', '/').split('/') - if len(parts) >= 2: - yr_b, mo_b = int(parts[0]), int(parts[1]) - - session = get_session() - try: - # 查找今天是否有生成的 BCG 報告 - today = _dt.now() - cached_report = session.query(PPTReport).filter( - PPTReport.report_type == 'bcg', - PPTReport.parameters == f"{yr_b}/{mo_b:02d}", - PPTReport.generated_at >= today.replace(hour=0, minute=0, second=0, microsecond=0), - PPTReport.expires_at > today - ).first() - - if cached_report and cached_report.file_path and os.path.exists(cached_report.file_path): - sys_log.info(f"[OpenClawBot] 使用快取的 BCG PPT: {cached_report.file_path}") - return cached_report.file_path - - except Exception as e: - sys_log.error(f"[OpenClawBot] 查詢 BCG PPT 快取失敗: {e}") - finally: - session.close() - - # 沒有快取或已過期,重新生成 - bd = query_vendor_bcg_data(yr_b, mo_b) - if not bd or not bd.get('bcg_data'): - raise ValueError("無 BCG 矩陣資料(monthly_summary_analysis 表可能尚未匯入)") - kpi_b = bd['kpis'] - period_b = f"{yr_b}/{mo_b:02d}" - bd['period_label'] = period_b - data_summary_b = ( - f"BCG 品牌矩陣報告 {period_b}\n" - f"分析組合數:{len(bd['bcg_data'])} 個(品牌×區域)\n" - f"總業績:NT$ {kpi_b.get('total_sales',0):,.0f}\n" - f"平均毛利率:{kpi_b.get('avg_margin',0):.1f}%\n" - ) - ai_text_b = _ppt_ai_analysis(data_summary_b, f'BCG 品牌策略報告({period_b})') - - # 生成 PPT 並快取 - ppt_path = generate_bcg_ppt(yr_b, mo_b, bd, ai_text_b) - - # 儲存到資料庫 - session = get_session() - try: - # 設定 24 小時後過期 - expires_at = _dt.now() + _td(hours=24) - - # 刪除舊的快取記錄 - session.query(PPTReport).filter( - PPTReport.report_type == 'bcg', - PPTReport.expires_at <= _dt.now() - ).delete() - - # 儲存新的記錄 - report_record = PPTReport( - report_type='bcg', - parameters=f"{yr_b}/{mo_b:02d}", - file_path=ppt_path, - file_size=os.path.getsize(ppt_path) if os.path.exists(ppt_path) else 0, - cached_data=str(bd), # 快取查詢結果 - expires_at=expires_at - ) - session.add(report_record) - session.commit() - sys_log.info(f"[OpenClawBot] BCG PPT 已快取: {ppt_path}") - - except Exception as e: - sys_log.error(f"[OpenClawBot] 儲存 BCG PPT 快取失敗: {e}") - session.rollback() - finally: - session.close() - - return ppt_path - else: - raise ValueError(f"不支援的簡報類型:{sub_type}(支援:daily/weekly/monthly/strategy/competitor/promo/growth/vendor/bcg)") + raise ValueError(f"不支援的簡報類型:{sub_type}(支援:daily / weekly / monthly / strategy / competitor / promo)") # ── Telegram Excel 匯入 ────────────────────────────────────────── @@ -2913,9 +2477,9 @@ def _handle_excel_import(doc: dict, chat_id: int, reply_to: int): def send_morning_report(): """每日 08:30 早報 — P10 升級版:TOP15 + PPT導引按鈕""" try: - now = _dt.now(TAIPEI_TZ) + now = datetime.now(TAIPEI_TZ) td = now.strftime('%Y/%m/%d') - yd = (now - _td(days=1)).strftime('%Y/%m/%d') + yd = (now - timedelta(days=1)).strftime('%Y/%m/%d') wdays = ['週一', '週二', '週三', '週四', '週五', '週六', '週日'] sales = query_sales(yd) top15 = query_top_products(yd, 15) @@ -3008,7 +2572,7 @@ def send_morning_report(): def send_evening_report(): """每日 21:00 晚報""" try: - now = _dt.now(TAIPEI_TZ) + now = datetime.now(TAIPEI_TZ) td = now.strftime('%Y/%m/%d') sales = query_sales(td) weekly = query_weekly_trend() @@ -3053,14 +2617,7 @@ def send_evening_report(): lines.append(f"{emoji} *vs 昨日* {arrow}`{abs(pct_d):.1f}%` (`NT$ {diff:+,.0f}`)") lines.append("") else: - # 檢查是否真的沒有資料,避免重複顯示 - today_str = _dt.now(TAIPEI_TZ).strftime('%Y/%m/%d') - if latest_date() == today_str: - # 如果最新日期就是今天,但查詢失敗,可能是資料庫問題 - lines.append("⚠️ *今日業績資料載入異常*") - else: - # 確實沒有今天的資料 - lines.append("⚠️ *今日業績資料尚未匯入*") + lines.append("⚠️ *今日業績資料尚未匯入*") lines.append("") top15_ev = query_top_products(td, 15) @@ -3110,7 +2667,7 @@ def send_evening_report(): def send_weekly_report(): """每週一 09:00 週報""" try: - now = _dt.now(TAIPEI_TZ) + now = datetime.now(TAIPEI_TZ) td = now.strftime('%Y/%m/%d') weekly = query_weekly_trend() WEEKDAYS_ZH = ['週一', '週二', '週三', '週四', '週五', '週六', '週日'] @@ -3179,7 +2736,7 @@ def send_weekly_report(): def check_anomalies(): """每日 9/12/15/18 點異常偵測(整體業績 + 商品級)""" try: - now = _dt.now(TAIPEI_TZ) + now = datetime.now(TAIPEI_TZ) td = now.strftime('%Y/%m/%d') ts = now.strftime('%H:%M') alerts = [] @@ -3260,7 +2817,7 @@ def send_competitor_report(): if not _PCHOME_AVAILABLE: return try: - yesterday = (_dt.now(TAIPEI_TZ).date() - _td(days=1)).strftime('%Y/%m/%d') + 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) @@ -3297,7 +2854,7 @@ def send_competitor_report(): def send_daily_excel(): """每日 08:45 自動發送昨日 Excel 業績報表""" try: - yesterday = (_dt.now(TAIPEI_TZ).date() - _td(days=1)).strftime('%Y/%m/%d') + 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: @@ -3318,32 +2875,9 @@ def send_daily_excel(): sys_log.error(f"[AutoExcel] {e}") -_sched_lock_fh = None # 持有鎖的 file handle,進程退出時自動釋放 - - def start_scheduler(): - """啟動排程(Flask app 啟動後呼叫) - 使用 fcntl exclusive lock 確保 Gunicorn 多 worker 環境下只有一個 worker 運行排程。 - """ - global _scheduler, _sched_lock_fh - import fcntl, atexit - - lock_path = '/tmp/openclaw_scheduler.lock' - try: - _sched_lock_fh = open(lock_path, 'w') - fcntl.flock(_sched_lock_fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - except OSError: - sys_log.info("[OpenClawBot] Scheduler lock busy — another worker owns it, skipping") - return - - @atexit.register - def _release_sched_lock(): - try: - fcntl.flock(_sched_lock_fh.fileno(), fcntl.LOCK_UN) - _sched_lock_fh.close() - except Exception: - pass - + """啟動排程(Flask app 啟動後呼叫)""" + global _scheduler try: from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger @@ -3356,7 +2890,7 @@ def start_scheduler(): _scheduler.add_job(send_weekly_report, CronTrigger(day_of_week='mon', hour=9, minute=0)) _scheduler.add_job(check_anomalies, CronTrigger(hour='9,12,15,18', minute=0)) _scheduler.start() - sys_log.info("[OpenClawBot] Scheduler started ✓ pid=%d (competitor/morning/excel/evening/weekly/anomaly)", os.getpid()) + sys_log.info("[OpenClawBot] Scheduler started ✓ (competitor/morning/excel/evening/weekly/anomaly)") except ImportError: sys_log.warning("[OpenClawBot] APScheduler 未安裝 — 排程功能停用") except Exception as e: @@ -3408,10 +2942,11 @@ def main_menu_keyboard(): def _submenu_sales(): ld = latest_date() or '' yesterday = '' - current_month = _dt.now(TAIPEI_TZ).strftime('%Y/%m') + current_month = datetime.now(TAIPEI_TZ).strftime('%Y/%m') if ld: try: - yesterday = (_dt.strptime(ld.replace('/', '-'), '%Y-%m-%d') - _td(days=1)).strftime('%Y/%m/%d') + from datetime import datetime as _dt + yesterday = (_dt.strptime(ld.replace('/', '-'), '%Y-%m-%d') - timedelta(days=1)).strftime('%Y/%m/%d') except Exception: pass d_label = ld[-5:] if ld else '-' @@ -3436,7 +2971,8 @@ def _submenu_products(): yesterday = '' if ld: try: - yesterday = (_dt.strptime(ld.replace('/', '-'), '%Y-%m-%d') - _td(days=1)).strftime('%Y/%m/%d') + from datetime import datetime as _dt + yesterday = (_dt.strptime(ld.replace('/', '-'), '%Y-%m-%d') - timedelta(days=1)).strftime('%Y/%m/%d') except Exception: pass d_label = ld[-5:] if ld else '-' @@ -3489,11 +3025,9 @@ def _submenu_category(): """分類業績鑽取 — 顯示 L1 固定分類按鈕""" ld = latest_date() or '' CATS = [ - ('美妝保養', '💄'), ('3C家電', '📱'), ('服飾配件', '👕'), - ('居家生活', '🏠'), ('母嬰用品', '🍼'), ('生鮮食品', '🥗'), - ('圖書文具', '📚'), ('戶外運動', '⚽'), ('餐券票券', '🎫'), - ('醫療保健', '💊'), ('美體保健', '💆'), ('寵物用品', '🐕'), - ('箱包精品', '👜'), ('車類百貨', '🚗'), ('情趣用品', '❤️'), + ('美妝保養', '💄'), ('保健食品/用品', '💊'), ('母嬰', '👶'), + ('食品飲料', '🍱'), ('家電', '🏠'), ('服裝內著', '👕'), + ('個人清潔', '🧴'), ('運動用品/器材', '🏃'), ('寵物', '🐾'), ('其他', '📦'), ] rows = [] for i in range(0, len(CATS), 2): @@ -3536,11 +3070,6 @@ def _submenu_reports(): # ── 促銷 & 競品 [{'text': '🎉 促銷效益簡報', 'callback_data': 'await:promo_range'}, {'text': '🔍 競品比較', 'callback_data': 'menu:competitor'}], - # ── 新增:成長趨勢 / 廠商 / BCG - [{'text': '📈 成長趨勢報告', 'callback_data': 'cmd:ppt:growth'}, - {'text': '🏭 廠商業績報告', 'callback_data': 'cmd:ppt:vendor'}], - [{'text': '🎯 BCG 品牌矩陣', 'callback_data': 'cmd:ppt:bcg'}, - {'text': '📅 指定月份廠商', 'callback_data': 'await:date_ppt_vendor'}], # ── 自訂 [{'text': '📅 指定日期日報', 'callback_data': 'await:date_ppt_daily'}, {'text': '📅 指定月份月報', 'callback_data': 'await:date_ppt_monthly'}], @@ -3565,8 +3094,8 @@ def _submenu_market(): def _submenu_competitor(): """競品日報第二層:所有選項直接產 PPT""" - today = _dt.now(TAIPEI_TZ).date() - yesterday = today - _td(days=1) + today = datetime.now(TAIPEI_TZ).date() + yesterday = today - timedelta(days=1) td_str = today.strftime('%Y/%m/%d') yd_str = yesterday.strftime('%Y/%m/%d') td_label = today.strftime('%m/%d') @@ -3616,9 +3145,8 @@ _AWAIT_PROMPTS = { ), 'date_top': ('📅 請輸入查詢日期\n格式:`2026/04/15`', '商品日期'), 'date_analysis': ('📅 請輸入分析日期\n格式:`2026/04/15`', '分析日期'), - 'date_ppt_daily': ('📅 請輸入日報日期\n格式:`2026/04/15`', 'PPT日期'), + 'date_ppt_daily': ('📅 請輸入日報日期\n格式:`2026/04/15`', 'PPT日期'), 'date_ppt_monthly':('📅 請輸入月報月份\n格式:`2026/03`', 'PPT月份'), - 'date_ppt_vendor': ('🏭 請輸入廠商報告月份\n格式:`2026/03`(或按送出跳過用最新月)', 'PPT廠商月份'), 'goal_daily': ('🎯 請輸入每日業績目標金額(NT$)\n例如:`150000`', '日目標'), 'goal_monthly': ('🎯 請輸入每月業績目標金額(NT$)\n例如:`3000000`', '月目標'), 'goal_quarterly': ('🎯 請輸入每季業績目標金額(NT$)\n例如:`9000000`', '季目標'), @@ -3635,13 +3163,12 @@ _AWAIT_PROMPTS = { def sales_quick_kb(date_str): try: - d = _dt.strptime(date_str.replace('/', '-'), '%Y-%m-%d').date() - yesterday = (d - _td(days=1)).strftime('%Y/%m/%d') + 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 [ [{'text': '⬅️ 昨日業績', 'callback_data': f'cmd:sales:{yesterday}'}, {'text': '🏆 熱銷商品', 'callback_data': f'cmd:top:{date_str}'}], - [{'text': '📊 產出日報 PPT', 'callback_data': f'cmd:ppt:daily {date_str}'}, - {'text': '📄 策略簡報', 'callback_data': f'cmd:ppt:strategy {date_str}'}], [{'text': '📋 完整報表', 'callback_data': f'cmd:report:{date_str}'}], ] except Exception: @@ -3898,14 +3425,14 @@ def query_top_products_range(start_str: str, end_str: str, lim: int = 10) -> lis def resolve_date(q: str) -> str: """從問題文字解析目標日期,回傳 YYYY/MM/DD""" import re - today = _dt.now(TAIPEI_TZ).date() + today = datetime.now(TAIPEI_TZ).date() if '昨天' in q or '昨日' in q: - return (today - _td(days=1)).strftime('%Y/%m/%d') + return (today - timedelta(days=1)).strftime('%Y/%m/%d') if '前天' in q: - return (today - _td(days=2)).strftime('%Y/%m/%d') + return (today - timedelta(days=2)).strftime('%Y/%m/%d') if '大前天' in q: - return (today - _td(days=3)).strftime('%Y/%m/%d') + 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) @@ -3925,13 +3452,13 @@ def resolve_query_intent(q: str) -> dict: (Function Calling 架構不需要此函數,僅 NIM 備援使用) """ import re - today = _dt.now(TAIPEI_TZ).date() + today = datetime.now(TAIPEI_TZ).date() # ── 月份查詢 ───────────────────────────────────────────── # 「上個月」「上月」 if any(kw in q for kw in ['上個月', '上月', '上月份']): first = today.replace(day=1) - last_m = (first - _td(days=1)) + last_m = (first - timedelta(days=1)) return {'type': 'month', 'year': last_m.year, 'month': last_m.month} # 「這個月」「本月」 @@ -3953,12 +3480,12 @@ def resolve_query_intent(q: str) -> dict: # ── 週查詢 ─────────────────────────────────────────────── if any(kw in q for kw in ['上週', '上个星期', '上星期', '上週']): - mon = today - _td(days=today.weekday() + 7) - sun = mon + _td(days=6) + 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 - _td(days=today.weekday()) + mon = today - timedelta(days=today.weekday()) return {'type': 'range', 'start': mon.strftime('%Y/%m/%d'), 'end': today.strftime('%Y/%m/%d'), 'label': '本週'} @@ -3966,7 +3493,7 @@ def resolve_query_intent(q: str) -> dict: m = re.search(r'近(\d+)[天日]', q) if m: n = int(m.group(1)) - start = (today - _td(days=n - 1)).strftime('%Y/%m/%d') + start = (today - timedelta(days=n - 1)).strftime('%Y/%m/%d') return {'type': 'range', 'start': start, 'end': today.strftime('%Y/%m/%d'), 'label': f'近{n}天'} @@ -4413,9 +3940,9 @@ _FC_TOOLS = [{ def _execute_tool(name: str, args: dict) -> dict: """執行 Gemini 指定的工具,回傳結構化結果""" - now = _dt.now(TAIPEI_TZ) + now = datetime.now(TAIPEI_TZ) today = now.strftime("%Y/%m/%d") - yd = (now - _td(days=1)).strftime("%Y/%m/%d") + yd = (now - timedelta(days=1)).strftime("%Y/%m/%d") # ── query_sales ─────────────────────────────────────────── if name == "query_sales": @@ -4444,7 +3971,7 @@ def _execute_tool(name: str, args: dict) -> dict: elif period == "last_month": first = now.replace(day=1) - lm = (first - _td(days=1)) + lm = (first - timedelta(days=1)) ms = query_monthly_summary(lm.year, lm.month) result = {"monthly": ms, "year": lm.year, "month": lm.month} @@ -4515,7 +4042,7 @@ def openclaw_answer(question: str): Function Calling 架構 — AI 自主決定查什麼、怎麼回答 不再靠 if/else 規則判斷意圖 """ - now = _dt.now(TAIPEI_TZ) + now = datetime.now(TAIPEI_TZ) today_str = now.strftime("%Y/%m/%d") # ── 功能說明直接導 help ─────────────────────────────────── @@ -4540,10 +4067,62 @@ def openclaw_answer(question: str): {"text": "🏆 商品廠商", "callback_data": "menu:products"}], ] - if not GEMINI_API_KEY and not NVIDIA_API_KEY: - return "(AI 引擎未設定,請確認 API Key)", None + from services.ollama_service import ollama_service - # ── Gemini Function Calling ─────────────────────────────── + # ── 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"【業績資料】{db_ctx}\n" if db_ctx else "") + + (f"【市場情報】{mcp_ctx[:400]}\n" if mcp_ctx else "") + + "請用繁體中文直接回答,不要開場白,300字以內。" + ) + + resp = ollama_service.generate(question, system_prompt=sys_prompt, timeout=30) + if resp.success and 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") + except Exception as e: + sys_log.warning(f"[Ollama] 例外發生: {e},fallback 到 Gemini") + + if not GEMINI_API_KEY and not NVIDIA_API_KEY: + return "(AI 引擎未設定,請確認 API Key 或啟動 Ollama 服務)", None + + # ── Gemini Function Calling (備援 1) ───────────────────────── if GEMINI_API_KEY: try: sys_msg = ( @@ -4645,7 +4224,7 @@ def openclaw_answer(question: str): try: intent = resolve_query_intent(question) today = today_str - yd = (now - _td(days=1)).strftime("%Y/%m/%d") + yd = (now - timedelta(days=1)).strftime("%Y/%m/%d") d = intent.get("date", yd) if intent["type"] == "day" else yd db_ctx = "" @@ -4699,7 +4278,7 @@ def openclaw_answer(question: str): # ── 指令處理 ────────────────────────────────────────────────── def handle_cmd(cmd, arg, chat_id, reply_to): - ld = latest_date() or _dt.now(TAIPEI_TZ).strftime('%Y/%m/%d') + ld = latest_date() or datetime.now(TAIPEI_TZ).strftime('%Y/%m/%d') target = normalize_date(arg) if arg else ld if cmd in ('sales', '業績'): @@ -4723,30 +4302,30 @@ def handle_cmd(cmd, arg, chat_id, reply_to): elif cmd in ('trend', '趨勢'): import calendar as _cal from datetime import date as _date - now_d = _dt.now(TAIPEI_TZ).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 = _dt.strptime(ld_str.replace('/', '-'), '%Y-%m-%d').date() - start_d = end_d - _td(days=6) + 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 - _td(days=29) + start_d = end_d - timedelta(days=29) period_label = '近30日' elif sub in ('quarter', 'q', '季', '近季', '近3月', '近三月'): end_d = now_d - start_d = end_d - _td(days=89) + start_d = end_d - timedelta(days=89) period_label = '近3個月' elif sub in ('half', '半年', '近半年', '6月', '六個月'): end_d = now_d - start_d = end_d - _td(days=179) + start_d = end_d - timedelta(days=179) period_label = '近半年' elif sub in ('year', 'yearly', '年', '本年', '近年', '近一年'): end_d = now_d - start_d = end_d - _td(days=364) + 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('/') @@ -4769,8 +4348,8 @@ def handle_cmd(cmd, arg, chat_id, reply_to): period_label = f'{yr}年Q{qn}' else: ld_str = latest_date() or now_d.strftime('%Y/%m/%d') - end_d = _dt.strptime(ld_str.replace('/', '-'), '%Y-%m-%d').date() - start_d = end_d - _td(days=6) + 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') @@ -4784,13 +4363,7 @@ def handle_cmd(cmd, arg, chat_id, reply_to): if days_count <= 35: # 週/月:逐日文字 + 折線圖 - _trend_kb_short = [ - [{'text': '📊 產出趨勢簡報', 'callback_data': f'cmd:ppt:strategy {start_str}'}, - {'text': '📅 產出日報 PPT', 'callback_data': f'cmd:ppt:daily {end_str}'}], - [{'text': '🔄 加載新趨勢', 'callback_data': f'cmd:trend:{sub}'}, - {'text': '← 返回業績查詢', 'callback_data': 'menu:sales'}], - ] - send_message(chat_id, fmt_trend(data, period_label), reply_to, _trend_kb_short) + 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: @@ -4802,21 +4375,7 @@ def handle_cmd(cmd, arg, chat_id, reply_to): else: # 季/半年/年:摘要 + 聚合柱狀圖(不洗版面) granularity = 'monthly' if days_count > 100 else 'weekly' - # 決定働用 strategy 簡報的期間標籤 - if sub in ('quarter', 'quarterly', '季'): - ppt_sub = 'quarterly' - elif sub in ('half', '半年'): - ppt_sub = 'half' - elif sub in ('year', 'yearly', '年'): - ppt_sub = 'yearly' - else: - ppt_sub = 'weekly' - _trend_kb_long = [ - [{'text': '📊 產出趨勢簡報', 'callback_data': f'cmd:ppt:strategy {ppt_sub}'}, - {'text': '📅 月報 PPT', 'callback_data': f'cmd:ppt:monthly {start_str[:7].replace("/","/")}'}], - [{'text': '← 返回業績查詢', 'callback_data': 'menu:sales'}], - ] - send_message(chat_id, fmt_trend_summary(data, period_label), reply_to, _trend_kb_long) + 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: @@ -5060,7 +4619,7 @@ def handle_cmd(cmd, arg, chat_id, reply_to): yesterday = normalize_date(keyword) date_label = yesterday else: - yesterday = (_dt.now(TAIPEI_TZ).date() - _td(days=1)).strftime('%Y/%m/%d') + 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 秒...', @@ -5283,9 +4842,6 @@ def handle_cmd(cmd, arg, chat_id, reply_to): 'monthly': '📅 月報', 'strategy': '🧬 策略簡報', 'competitor': '🔍 競品比較', 'compare': '🔍 競品比較', '競品': '🔍 競品比較', 'promo': '🎉 促銷效益報告', - 'growth': '📈 成長趨勢報告', '成長': '📈 成長趨勢報告', - 'vendor': '🏭 廠商業績報告', '廠商': '🏭 廠商業績報告', - 'bcg': '🎯 BCG 品牌矩陣', 'BCG': '🎯 BCG 品牌矩陣', } label = type_labels.get(_sub_type, '簡報') caption = f"{label} — 由 OpenClaw AI 自動生成\n💡 可用 PowerPoint / Keynote / Google Slides 開啟" @@ -5600,12 +5156,11 @@ def telegram_webhook(): if uid in _seen_update_ids: sys_log.debug(f"[OpenClawBot] duplicate update_id={uid}, skip") return jsonify({'ok': True}) - # 若 deque 已達 maxlen,append 會自動擠掉最舊 → 同步從 set 移除 - if len(_seen_update_order) >= _SEEN_MAX: - evicted = _seen_update_order[0] - _seen_update_ids.discard(evicted) - _seen_update_order.append(uid) _seen_update_ids.add(uid) + if len(_seen_update_ids) > _SEEN_MAX: + # 清掉最舊的 100 筆(set 無序,直接 pop 100 個) + for _ in range(100): + _seen_update_ids.pop() # ── Callback Query(按鈕)───────────────────────────── if 'callback_query' in update: @@ -5614,16 +5169,11 @@ def telegram_webhook(): 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') 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}' - ) + if chat_type in ('group', 'supergroup') and chat_id != ALLOWED_GROUP: answer_callback(cq_id) - return jsonify({'ok': False, 'error': 'forbidden'}), 403 + return jsonify({'ok': True}) answer_callback(cq_id) send_typing(chat_id) @@ -5676,21 +5226,20 @@ def telegram_webhook(): 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'}), 403 - if chat_type in ('group', 'supergroup'): + if chat_id != ALLOWED_GROUP: + return jsonify({'ok': True}) # 移除 @mention(不強制要求,但如有則移除) question = text_raw.replace(BOT_USERNAME, '').strip() - else: - # 已通過授權的 private chat + elif chat_type == 'private': + # 私訊存取控制 — 只允許白名單用戶 + _uid = msg.get('from', {}).get('id', 0) + if ALLOWED_USERS and _uid not in ALLOWED_USERS: + send_message(chat_id, "⚠️ 此 Bot 僅限授權用戶使用,請聯絡管理員。", msg_id) + return jsonify({'ok': True}) question = text_raw + else: + return jsonify({'ok': True}) # ── 圖片訊息:Gemini Vision 商品辨識 ───────────────────── if not question and msg.get('photo'): @@ -5882,8 +5431,6 @@ def telegram_webhook(): 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_ppt_vendor': - handle_cmd('ppt', f'vendor {date_val}', chat_id, msg_id) elif action == 'date_competitor': handle_cmd('ppt', f'competitor {date_val}', chat_id, msg_id) else: @@ -5912,12 +5459,9 @@ def telegram_webhook(): return jsonify({'ok': True}) # 解析指令(/xxx 或已知指令詞) - # 群組中 Telegram 會附加 @BotUsername:/menu@OpenClawAwoool_Bot - # 需在解析前去除 @mention,否則 cmd = 'menu@openclawawoool_bot' 無法匹配 KNOWN q = question.lstrip('/') parts = q.split(None, 1) - raw_cmd = parts[0].lower() if parts else '' - cmd = raw_cmd.split('@')[0] # 去除 @BotUsername suffix + cmd = parts[0].lower() if parts else '' arg = parts[1] if len(parts) > 1 else '' KNOWN = { @@ -5944,32 +5488,6 @@ def telegram_webhook(): return jsonify({'ok': True}) -# ── 內部 CMD 轉發端點(供 momo-telegram-bot 轉發 cmd:* 按鈕用)──────── -@openclaw_bot_bp.route('/bot/internal/cmd', methods=['POST']) -def internal_cmd(): - """接受 momo-telegram-bot 轉發的 cmd:* 按鈕指令並執行""" - try: - token = request.headers.get('X-Internal-Token', '') - _expected = os.getenv("INTERNAL_WEBHOOK_TOKEN", "") - if _expected and token != _expected: - return jsonify({'ok': False, 'error': 'Unauthorized'}), 401 - body = request.get_json(silent=True) or {} - chat_id = body.get('chat_id') - cmd = body.get('cmd', '').strip() - arg = body.get('arg', '').strip() - if not chat_id or not cmd: - return jsonify({'ok': False, 'error': 'missing chat_id or cmd'}), 400 - threading.Thread( - target=handle_cmd, - args=(cmd, arg, int(chat_id), None), - daemon=True, - ).start() - return jsonify({'ok': True}) - except Exception as e: - sys_log.error(f"[OpenClawBot] /bot/internal/cmd error: {e}", exc_info=True) - return jsonify({'ok': False, 'error': str(e)}), 500 - - # ── 管理端點 ────────────────────────────────────────────────── @openclaw_bot_bp.route('/bot/telegram/set_webhook', methods=['POST']) def set_webhook(): diff --git a/services/ollama_service.py b/services/ollama_service.py index 2bbfa1f..3aefb97 100644 --- a/services/ollama_service.py +++ b/services/ollama_service.py @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) # Ollama 設定 - 支援環境變數覆蓋 # 預設使用外網 URL (透過 Nginx 反向代理),本地開發可透過環境變數指定內網 # 注意:外網訪問時 API 路徑在 /ollama/ 下 -OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'https://ollama.wooo.work/ollama') +OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://192.168.0.111:11434') DEFAULT_MODEL = os.getenv('OLLAMA_MODEL', 'llama3.2:latest') # 較快速的模型 TIMEOUT = int(os.getenv('OLLAMA_TIMEOUT', '120')) # 秒 - 2 分鐘 COPY_TIMEOUT = int(os.getenv('OLLAMA_COPY_TIMEOUT', '180')) # 文案生成專用超時 - 3 分鐘 @@ -505,37 +505,6 @@ class OllamaService: return self.generate(prompt, system_prompt=system_prompt, temperature=0.5, timeout=120) - def generate_embedding(self, text: str, model: str = "bge-m3:latest", - host: str = None) -> List[float]: - """ - [ADR-007, Step 3] 呼叫 Ollama API 將文字轉換為向量 Embedding - - 2026-04-19 更新(ADR-003 對齊): - embedding 預設走 Hermes 主機 `EMBEDDING_HOST`(env: EMBEDDING_HOST - → fallback http://192.168.0.111:11434,內網免認證), - 避免 self.host 若指向公開 ollama.wooo.work 時回 401。 - 可透過 host 參數 override。 - """ - import os - target_host = host or os.getenv("EMBEDDING_HOST", "http://192.168.0.111:11434") - try: - payload = {"model": model, "prompt": text} - response = requests.post( - f"{target_host}/api/embeddings", - json=payload, - timeout=60, - ) - if response.status_code == 200: - data = response.json() - return data.get("embedding", []) - else: - logger.error( - f"Ollama Embed Error HTTP {response.status_code} @ {target_host}: {response.text[:200]}" - ) - return [] - except Exception as e: - logger.error(f"Ollama Embed Exception @ {target_host}: {e}") - return [] # 建立全域服務實例 ollama_service = OllamaService()