diff --git a/database/autoheal_models.py b/database/autoheal_models.py index cd3716c..862006b 100644 --- a/database/autoheal_models.py +++ b/database/autoheal_models.py @@ -28,22 +28,34 @@ class AgentContext(Base): class ActionPlan(Base): """ - 行動計畫表(NemoTron 輸出,等待審核與執行追蹤)。 + 行動計畫表 — 統一 schema(超集)。 + Group A(01-init.sql / CodeReview / OpenClaw): + action_type, description, priority, metadata_json + Group B(migration 017 / watcher_agent / ai_orchestrator): + session_id, plan_type, sku, payload, created_by, approved_by, executed_at + migration 019 已在 DB 補齊所有欄位。 """ __tablename__ = 'action_plans' id = Column(Integer, primary_key=True, autoincrement=True) + # Group A columns + action_type = Column(String(100), nullable=True) # code_review_fix / openclaw_recommendation + description = Column(Text) # 人類可讀的行動說明 + status = Column(String(50), default='pending') # pending/auto_pending/pending_review/executed + priority = Column(Integer, default=3) # 1=critical 2=high 3=medium 4=low + metadata_json = Column(Text) # JSON: pipeline_id/commit_sha/findings + created_at = Column(DateTime, default=datetime.now) + # Group B columns (ADR-012 / NemoTron) session_id = Column(String(64), nullable=True) plan_type = Column(String(50), nullable=True) # price_adjust / restock / campaign - sku = Column(String(100), nullable=True, index=True) + sku = Column(String(100), nullable=True) payload = Column(Text) # JSON 行動內容 - status = Column(String(20), default='pending') # pending/approved/rejected/executed created_by = Column(String(50)) # nemotron / openclaw approved_by = Column(String(100), nullable=True) # Telegram user_id - created_at = Column(DateTime, default=datetime.now) executed_at = Column(DateTime, nullable=True) __table_args__ = ( + Index('idx_action_plans_type', 'action_type'), Index('idx_action_plan_sku_status', 'sku', 'status'), Index('idx_action_plan_created', 'created_at'), {'extend_existing': True}, diff --git a/migrations/019_fix_action_plans_schema.sql b/migrations/019_fix_action_plans_schema.sql new file mode 100644 index 0000000..48f2e0e --- /dev/null +++ b/migrations/019_fix_action_plans_schema.sql @@ -0,0 +1,199 @@ +-- Migration 019: Fix action_plans schema — add ALL columns needed by all callers +-- 建立日期:2026-04-25 +-- 問題根源: +-- (A) code_review_pipeline_service.py / openclaw_strategist_service.py 寫入 +-- action_type / description / priority / metadata_json(來自 01-init.sql 定義) +-- (B) watcher_agent.py / ai_orchestrator.py 寫入 +-- session_id / plan_type / sku / payload / created_by / approved_by(來自 migration 017) +-- 兩組 callers 使用不同 schema,造成寫入失敗。 +-- 解決方案:讓 action_plans 成為兩組欄位的超集(superset)。 +-- 修復方式:逐欄 ADD COLUMN IF NOT EXISTS(冪等,不鎖表) +-- 回滾路徑(若需還原): +-- ALTER TABLE action_plans DROP COLUMN IF EXISTS action_type; +-- ALTER TABLE action_plans DROP COLUMN IF EXISTS description; +-- ALTER TABLE action_plans DROP COLUMN IF EXISTS priority; +-- ALTER TABLE action_plans DROP COLUMN IF EXISTS metadata_json; +-- ALTER TABLE action_plans DROP COLUMN IF EXISTS session_id; +-- ALTER TABLE action_plans DROP COLUMN IF EXISTS plan_type; +-- ALTER TABLE action_plans DROP COLUMN IF EXISTS sku; +-- ALTER TABLE action_plans DROP COLUMN IF EXISTS payload; +-- ALTER TABLE action_plans DROP COLUMN IF EXISTS created_by; +-- ALTER TABLE action_plans DROP COLUMN IF EXISTS approved_by; +-- ALTER TABLE action_plans DROP COLUMN IF EXISTS executed_at; + +-- ── Group A: 01-init.sql / CodeReview / OpenClaw ────────────────────────────── + +-- action_type — 'code_review_fix' | 'openclaw_recommendation' +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'action_plans' AND column_name = 'action_type' + ) THEN + ALTER TABLE action_plans ADD COLUMN action_type VARCHAR(100); + RAISE NOTICE 'action_plans.action_type column added'; + ELSE + RAISE NOTICE 'action_plans.action_type already exists, skipped'; + END IF; +END $$; + +-- description — 人類可讀行動說明 +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'action_plans' AND column_name = 'description' + ) THEN + ALTER TABLE action_plans ADD COLUMN description TEXT; + RAISE NOTICE 'action_plans.description column added'; + ELSE + RAISE NOTICE 'action_plans.description already exists, skipped'; + END IF; +END $$; + +-- priority — 1=critical 2=high 3=medium 4=low +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'action_plans' AND column_name = 'priority' + ) THEN + ALTER TABLE action_plans ADD COLUMN priority INTEGER DEFAULT 3; + RAISE NOTICE 'action_plans.priority column added'; + ELSE + RAISE NOTICE 'action_plans.priority already exists, skipped'; + END IF; +END $$; + +-- metadata_json — pipeline_id / commit_sha / findings JSON 擴展 +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'action_plans' AND column_name = 'metadata_json' + ) THEN + ALTER TABLE action_plans ADD COLUMN metadata_json TEXT; + RAISE NOTICE 'action_plans.metadata_json column added'; + ELSE + RAISE NOTICE 'action_plans.metadata_json already exists, skipped'; + END IF; +END $$; + +-- ── Group B: Migration 017 / watcher_agent / ai_orchestrator ───────────────── + +-- session_id — Agent session identifier +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'action_plans' AND column_name = 'session_id' + ) THEN + ALTER TABLE action_plans ADD COLUMN session_id VARCHAR(64); + RAISE NOTICE 'action_plans.session_id column added'; + ELSE + RAISE NOTICE 'action_plans.session_id already exists, skipped'; + END IF; +END $$; + +-- plan_type — 'price_adjust' | 'restock' | 'campaign' +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'action_plans' AND column_name = 'plan_type' + ) THEN + ALTER TABLE action_plans ADD COLUMN plan_type VARCHAR(50); + RAISE NOTICE 'action_plans.plan_type column added'; + ELSE + RAISE NOTICE 'action_plans.plan_type already exists, skipped'; + END IF; +END $$; + +-- sku — 商品 SKU +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'action_plans' AND column_name = 'sku' + ) THEN + ALTER TABLE action_plans ADD COLUMN sku VARCHAR(100); + RAISE NOTICE 'action_plans.sku column added'; + ELSE + RAISE NOTICE 'action_plans.sku already exists, skipped'; + END IF; +END $$; + +-- payload — JSON 行動內容(NemoTron 輸出) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'action_plans' AND column_name = 'payload' + ) THEN + ALTER TABLE action_plans ADD COLUMN payload TEXT; + RAISE NOTICE 'action_plans.payload column added'; + ELSE + RAISE NOTICE 'action_plans.payload already exists, skipped'; + END IF; +END $$; + +-- created_by — 'nemotron' | 'openclaw' | 'code_review_pipeline' +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'action_plans' AND column_name = 'created_by' + ) THEN + ALTER TABLE action_plans ADD COLUMN created_by VARCHAR(50); + RAISE NOTICE 'action_plans.created_by column added'; + ELSE + RAISE NOTICE 'action_plans.created_by already exists, skipped'; + END IF; +END $$; + +-- approved_by — Telegram user_id +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'action_plans' AND column_name = 'approved_by' + ) THEN + ALTER TABLE action_plans ADD COLUMN approved_by VARCHAR(100); + RAISE NOTICE 'action_plans.approved_by column added'; + ELSE + RAISE NOTICE 'action_plans.approved_by already exists, skipped'; + END IF; +END $$; + +-- executed_at — 執行時間戳 +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'action_plans' AND column_name = 'executed_at' + ) THEN + ALTER TABLE action_plans ADD COLUMN executed_at TIMESTAMP; + RAISE NOTICE 'action_plans.executed_at column added'; + ELSE + RAISE NOTICE 'action_plans.executed_at already exists, skipped'; + END IF; +END $$; + +-- ── 索引補充 ────────────────────────────────────────────────────────────────── + +CREATE INDEX IF NOT EXISTS idx_action_plans_type ON action_plans(action_type); +CREATE INDEX IF NOT EXISTS idx_action_plan_sku_status ON action_plans(sku, status); +CREATE INDEX IF NOT EXISTS idx_action_plan_created ON action_plans(created_at); + +-- ── 修正 action_type NOT NULL 殘留問題 ────────────────────────────────────── +-- 根因:action_type 在 01-init.sql 原始定義中為 NOT NULL, +-- migration 019 的 IF NOT EXISTS 跳過了該欄位,NOT NULL 約束未被修正。 +-- watcher_agent.py / ai_orchestrator.py 插入時不填 action_type → 違反約束。 +-- 修正:DROP NOT NULL + 設定 DEFAULT 'auto'(冪等,不鎖表) +ALTER TABLE action_plans ALTER COLUMN action_type DROP NOT NULL; +ALTER TABLE action_plans ALTER COLUMN action_type SET DEFAULT 'auto'; + +DO $$ +BEGIN + RAISE NOTICE 'Migration 019 done: action_plans unified schema (Group A + Group B columns) + action_type NOT NULL fix'; +END $$; diff --git a/routes/ai_routes.py b/routes/ai_routes.py index f5d8018..4a4cbbe 100644 --- a/routes/ai_routes.py +++ b/routes/ai_routes.py @@ -1637,7 +1637,7 @@ def api_icaim_trigger(): if result.threats: hermes_stats = { - 'model': 'hermes3:latest', + 'model': 'qwen2.5:7b-instruct', 'duration_sec': hermes_duration, 'tokens': result.hermes_tokens, } diff --git a/routes/cicd_routes.py b/routes/cicd_routes.py index 552d636..a491e0e 100644 --- a/routes/cicd_routes.py +++ b/routes/cicd_routes.py @@ -558,7 +558,12 @@ def run_diagnosis(env): # ============================================================================= TELEGRAM_BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN', '') -TELEGRAM_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '') +_chat_ids_raw = os.environ.get('TELEGRAM_CHAT_IDS', '[]') +try: + _chat_ids_list = json.loads(_chat_ids_raw) + TELEGRAM_CHAT_ID = str(_chat_ids_list[0]) if _chat_ids_list else os.environ.get('TELEGRAM_CHAT_ID', '') +except Exception: + TELEGRAM_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '') if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID: import logging diff --git a/services/ai_provider.py b/services/ai_provider.py index fb94941..fb47805 100644 --- a/services/ai_provider.py +++ b/services/ai_provider.py @@ -134,7 +134,7 @@ class AIProviderService: 'elephant': { 'connected': elephant_connected, 'model': self._elephant.model if elephant_connected else None, - 'available_models': [{'id': 'openrouter/elephant-alpha', 'name': 'Elephant Alpha'}], + 'available_models': [{'id': 'nvidia/llama-3.1-nemotron-ultra-253b-v1', 'name': 'Nemotron Ultra 253B'}], 'type': 'cloud', 'cost': 'efficient' }, diff --git a/services/aider_heal_executor.py b/services/aider_heal_executor.py index 3456263..a2f7a19 100644 --- a/services/aider_heal_executor.py +++ b/services/aider_heal_executor.py @@ -13,6 +13,7 @@ ADR-014: Autonomous Code Heal Pipeline L5 - Telegram 通知每次修復結果(成功/失敗/回滾) """ +import json import os import re import time @@ -41,14 +42,19 @@ HEALTH_CHECK_URL: str = ( os.getenv("MOMO_BASE_URL", "https://mo.wooo.work").rstrip("/") + "/health" ) -OLLAMA_API_BASE: str = os.getenv("OLLAMA_API_BASE", "http://192.168.0.111:11434") +OLLAMA_API_BASE: str = os.getenv("OLLAMA_API_BASE", "http://192.168.0.188:11434") AIDER_MODEL: str = os.getenv("AIDER_MODEL", "ollama/qwen3-coder-next") MAX_DIFF_LINES: int = int(os.getenv("AIDER_MAX_DIFF_LINES", "50")) MAX_HOURLY_FIX: int = int(os.getenv("AIDER_MAX_HOURLY_FIX", "5")) TELEGRAM_BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "") -TELEGRAM_CHAT_ID: str = os.getenv("TELEGRAM_CHAT_ID", "") +_chat_ids_raw = os.getenv("TELEGRAM_CHAT_IDS", "[]") +try: + _chat_ids_list = json.loads(_chat_ids_raw) + TELEGRAM_CHAT_ID: str = str(_chat_ids_list[0]) if _chat_ids_list else os.getenv("TELEGRAM_CHAT_ID", "") +except Exception: + TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") # 允許 Aider 修改的路徑(正規表示式) ALLOWED_FILE_PATTERN = re.compile( diff --git a/services/code_review_pipeline_service.py b/services/code_review_pipeline_service.py index d5fb9f0..52ac340 100644 --- a/services/code_review_pipeline_service.py +++ b/services/code_review_pipeline_service.py @@ -29,6 +29,7 @@ from typing import Any, Dict, List, Optional from database.manager import get_session from sqlalchemy import text +from services.hermes_analyst_service import HERMES_URL as _HERMES_URL, HERMES_MODEL as _HERMES_MODEL logger = logging.getLogger(__name__) @@ -189,7 +190,7 @@ class CodeReviewPipeline: # ── Step 2:Hermes 掃描 ─────────────────────────────────────────────────── def _hermes_scan(self, files: Dict[str, str]) -> List[Dict]: - """直呼內網 Ollama(http://192.168.0.111:11434),免認證""" + """直呼內網 Ollama(http://192.168.0.188:11434),免認證""" try: import requests as _req @@ -213,8 +214,8 @@ class CodeReviewPipeline: 只輸出 JSON 陣列,不含其他文字。無問題時輸出 []""" resp = _req.post( - "http://192.168.0.111:11434/api/generate", - json={"model": "hermes3:latest", "prompt": prompt, + f"{_HERMES_URL}/api/generate", + json={"model": _HERMES_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.1}}, timeout=120, ) diff --git a/services/elephant_alpha_orchestrator.py b/services/elephant_alpha_orchestrator.py index ad73abc..81f9c70 100644 --- a/services/elephant_alpha_orchestrator.py +++ b/services/elephant_alpha_orchestrator.py @@ -69,7 +69,7 @@ class ElephantAlphaOrchestrator: self.agents = { "hermes": AgentCapability( name="Hermes Analyst", - model="hermes3:latest", + model="qwen2.5:7b-instruct", strengths=["price_competition_analysis", "threat_detection", "market_intelligence"], limitations=["context_window", "real_time_data"], cost_per_token=0.0, @@ -112,7 +112,7 @@ CURRENT ARCHITECTURE: - Your role: Autonomous decision-making and agent orchestration AGENT CAPABILITIES: -1. HERMES (hermes3:latest) +1. HERMES (qwen2.5:7b-instruct) - Strengths: Price competition analysis, threat detection, market intelligence - Limitations: Limited context window, no real-time data access - Best for: Analyzing large datasets, identifying patterns, threat assessment diff --git a/services/elephant_service.py b/services/elephant_service.py index b9746a1..6c6f079 100644 --- a/services/elephant_service.py +++ b/services/elephant_service.py @@ -15,15 +15,15 @@ from dataclasses import dataclass logger = logging.getLogger(__name__) -# Elephant Alpha 設定 -OPENROUTER_API_KEY = os.getenv('OPENROUTER_API_KEY', '') -ELEPHANT_ALPHA_URL = "https://openrouter.ai/api/v1/chat/completions" -DEFAULT_ELEPHANT_MODEL = "openrouter/elephant-alpha" +# Elephant Alpha 設定(NVIDIA NIM API) +NVIDIA_API_KEY = os.getenv('NVIDIA_API_KEY', '') +ELEPHANT_ALPHA_URL = "https://integrate.api.nvidia.com/v1/chat/completions" +DEFAULT_ELEPHANT_MODEL = "nvidia/llama-3.1-nemotron-ultra-253b-v1" ELEPHANT_TIMEOUT = int(os.getenv('ELEPHANT_TIMEOUT', '120')) # 預設 2 分鐘 -# Elephant Alpha 定價 (USD per 1M tokens) - 預估效能型定價 +# Elephant Alpha 定價 (USD per 1M tokens) - NVIDIA NIM 定價 ELEPHANT_PRICING = { - 'openrouter/elephant-alpha': {'input': 0.10, 'output': 0.40}, + 'nvidia/llama-3.1-nemotron-ultra-253b-v1': {'input': 0.10, 'output': 0.40}, } @dataclass @@ -48,7 +48,7 @@ class ElephantService: """ 初始化 Elephant 服務 """ - self.api_key = api_key or OPENROUTER_API_KEY + self.api_key = api_key or NVIDIA_API_KEY self.model = model or DEFAULT_ELEPHANT_MODEL # W3-A: 護欄 2 — 斷線降級 cache (300s TTL,不每次 ping OpenRouter) @@ -79,7 +79,7 @@ class ElephantService: @staticmethod def calculate_cost(model: str, input_tokens: int, output_tokens: int) -> Dict[str, float]: """計算費用""" - pricing = ELEPHANT_PRICING.get(model, ELEPHANT_PRICING['openrouter/elephant-alpha']) + pricing = ELEPHANT_PRICING.get(model, ELEPHANT_PRICING['nvidia/llama-3.1-nemotron-ultra-253b-v1']) input_cost = (input_tokens / 1_000_000) * pricing['input'] output_cost = (output_tokens / 1_000_000) * pricing['output'] return { diff --git a/services/hermes_analyst_service.py b/services/hermes_analyst_service.py index 1f4015b..d4f4f22 100644 --- a/services/hermes_analyst_service.py +++ b/services/hermes_analyst_service.py @@ -4,7 +4,7 @@ Hermes 3 競價情報分析服務 (Module 2) 角色:分析師 (Analyst) -模型:hermes3:latest @ 192.168.0.111:11434 +模型:qwen2.5:7b-instruct @ 192.168.0.188:11434 輸入:SQL 漏斗篩選後的候選商品(~300筆) 輸出:Top N 威脅清單(結構化 JSON)→ 交給 NemoTron dispatcher @@ -25,8 +25,8 @@ from sqlalchemy import text logger = logging.getLogger(__name__) -HERMES_MODEL = "hermes3:latest" -HERMES_URL = "http://192.168.0.111:11434" +HERMES_MODEL = "qwen2.5:7b-instruct" +HERMES_URL = "http://192.168.0.188:11434" HERMES_TIMEOUT = 120 # 秒,批量 300 筆最長預估 ~90s TOP_N = 20 # 輸出前 N 個威脅,控制 NemoTron 每次消耗配額 @@ -118,7 +118,11 @@ class HermesAnalystService: None, self._call_hermes_intent, message ) except Exception as e: - logger.warning(f"[Hermes.handle_l1] LLM 意圖分析失敗(降級規則引擎): {e}") + logger.error( + f"[Hermes.handle_l1] run_in_executor 例外,降級規則引擎" + f"({type(e).__name__}: {e})", + exc_info=True, + ) if llm_result: return llm_result @@ -167,7 +171,10 @@ class HermesAnalystService: "metadata": {"source": "hermes_llm"}, } except Exception as e: - logger.info(f"[Hermes.intent] 降級規則引擎({type(e).__name__}: {e})") + logger.warning( + f"[Hermes.intent] Ollama 連線失敗,降級規則引擎" + f"(host={HERMES_URL} model={HERMES_MODEL} error={type(e).__name__}: {e})" + ) return None def _rule_based_intent(self, message: str) -> dict: @@ -527,7 +534,7 @@ if __name__ == "__main__": ] fake_pchome = {"A001": 280, "A003": 980, "A005": 760, "A007": 590, "A009": 350} - print("=== Hermes 3 競價分析 CLI 測試 ===\n") + print("=== AI分析師 競價分析 CLI 測試 ===\n") raw = service._batch_analyze(fake_candidates, fake_pchome) for t in raw: diff --git a/services/nemoton_dispatcher_service.py b/services/nemoton_dispatcher_service.py index 126f641..af96f71 100644 --- a/services/nemoton_dispatcher_service.py +++ b/services/nemoton_dispatcher_service.py @@ -283,8 +283,8 @@ def _build_footprint_json(hermes_stats: Optional[dict], nim_stats: Optional[dict result = {} if hermes_stats: result["analyst"] = { - "model": "hermes3:latest", - "host": "192.168.0.111", + "model": "qwen2.5:7b-instruct", + "host": "192.168.0.188", "duration_sec": hermes_stats.get("duration_sec", 0), "tokens": hermes_stats.get("tokens", 0), "cost_usd": 0, @@ -317,11 +317,11 @@ def _build_footprint_block(hermes_stats: Optional[dict], nim_stats: Optional[dic dur = hermes_stats.get("duration_sec", 0) tok = hermes_stats.get("tokens", "?") lines.append( - f"• 🔍 分析: Hermes 3 8B (本地 111) | " + f"• 🔍 分析: Qwen2.5 7B (本地 188) | " f"耗時: {dur:.1f}s | Tokens: {tok} | $0 成本" ) else: - lines.append("• 🔍 分析: Hermes 3 8B (本地 111) | $0 成本") + lines.append("• 🔍 分析: Qwen2.5 7B (本地 188) | $0 成本") if nim_stats: tok = nim_stats.get("total_tokens", "?") diff --git a/services/telegram_bot_service.py b/services/telegram_bot_service.py index a535586..4c24f11 100644 --- a/services/telegram_bot_service.py +++ b/services/telegram_bot_service.py @@ -442,112 +442,185 @@ class TrendTelegramBot: await query.answer() data = query.data - # ===== 主選單按鈕 ===== - if data == "menu_main" or data == "menu:main": - await query.edit_message_text( - "👋 *MOMO 趨勢助手 Bot* — 請選擇功能類別:", - parse_mode='Markdown', - reply_markup=self._get_main_menu_keyboard() - ) + try: + # ===== 主選單按鈕 ===== + if data == "menu_main" or data == "menu:main": + await query.edit_message_text( + "👋 *MOMO 趨勢助手 Bot* — 請選擇功能類別:", + parse_mode='Markdown', + reply_markup=self._get_main_menu_keyboard() + ) - # ===== 新的完整菜單系統 ===== - elif data.startswith("menu:"): - await self._handle_main_menu_callback(query, data) + # ===== 新的完整菜單系統 ===== + elif data.startswith("menu:"): + await self._handle_main_menu_callback(query, data) - # ===== 舊的簡單菜單(向下相容) ===== - elif data == "menu_trend": - await query.edit_message_text( - "📊 *熱門趨勢*\n\n請選擇分類:", - parse_mode='Markdown', - reply_markup=self._get_category_keyboard("trend") - ) + # ===== 舊的簡單菜單(向下相容) ===== + elif data == "menu_trend": + await query.edit_message_text( + "📊 *熱門趨勢*\n\n請選擇分類:", + parse_mode='Markdown', + reply_markup=self._get_category_keyboard("trend") + ) - elif data == "menu_search": - context.user_data['waiting_for'] = 'search_query' - await query.edit_message_text( - "🔍 *AI 搜尋*\n\n請輸入要搜尋的關鍵字:\n\n" - "例如:夏季防曬推薦、母親節禮物", - parse_mode='Markdown', - reply_markup=InlineKeyboardMarkup([[ - InlineKeyboardButton("🔙 返回主選單", callback_data="menu:main") - ]]) - ) + elif data == "menu_search": + context.user_data['waiting_for'] = 'search_query' + await query.edit_message_text( + "🔍 *AI 搜尋*\n\n請輸入要搜尋的關鍵字:\n\n" + "例如:夏季防曬推薦、母親節禮物", + parse_mode='Markdown', + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("🔙 返回主選單", callback_data="menu:main") + ]]) + ) - elif data == "menu_copy": - context.user_data['waiting_for'] = 'copy_product' - await query.edit_message_text( - "✍️ *生成文案*\n\n請輸入商品名稱:\n\n" - "例如:防曬乳、保濕面膜", - parse_mode='Markdown', - reply_markup=InlineKeyboardMarkup([[ - InlineKeyboardButton("🔙 返回主選單", callback_data="menu:main") - ]]) - ) + elif data == "menu_copy": + context.user_data['waiting_for'] = 'copy_product' + await query.edit_message_text( + "✍️ *生成文案*\n\n請輸入商品名稱:\n\n" + "例如:防曬乳、保濕面膜", + parse_mode='Markdown', + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("🔙 返回主選單", callback_data="menu:main") + ]]) + ) - elif data == "menu_keywords": - await query.edit_message_text( - "🏷️ *熱門關鍵字*\n\n請選擇分類:", - parse_mode='Markdown', - reply_markup=self._get_category_keyboard("keywords") - ) + elif data == "menu_keywords": + await query.edit_message_text( + "🏷️ *熱門關鍵字*\n\n請選擇分類:", + parse_mode='Markdown', + reply_markup=self._get_category_keyboard("keywords") + ) - elif data == "menu_daily": - await query.edit_message_text("📰 正在載入今日趨勢摘要...") - await self._show_daily_summary(query) + elif data == "menu_daily": + await query.edit_message_text("📰 正在載入今日趨勢摘要...") + await self._show_daily_summary(query) - elif data == "menu_settings": - await self._show_settings(query) + elif data == "menu_settings": + await self._show_settings(query) - # ===== 趨勢分類按鈕 ===== - elif data.startswith("trend_"): - category = data[6:] - await query.edit_message_text(f"🔄 正在查詢 {category} 趨勢...") - await self._show_trend_by_category(query, category) + # ===== 趨勢分類按鈕 ===== + elif data.startswith("trend_"): + category = data[6:] + await query.edit_message_text(f"🔄 正在查詢 {category} 趨勢...") + await self._show_trend_by_category(query, category) - # ===== 關鍵字分類按鈕 ===== - elif data.startswith("keywords_"): - category = data[9:] - await query.edit_message_text(f"🔄 正在查詢 {category} 熱門關鍵字...") - await self._show_keywords_by_category(query, category) + # ===== 關鍵字分類按鈕 ===== + elif data.startswith("keywords_"): + category = data[9:] + await query.edit_message_text(f"🔄 正在查詢 {category} 熱門關鍵字...") + await self._show_keywords_by_category(query, category) - # ===== 設定按鈕 ===== - elif data.startswith("settings_"): - await self._handle_settings_callback(query, data) + # ===== 設定按鈕 ===== + elif data.startswith("settings_"): + await self._handle_settings_callback(query, data) - # ===== 降價決策按鈕(僅支援 momo:pa:xxx / momo:pr:xxx 格式)===== - elif data.startswith("momo:pa:"): - await self._handle_price_approve(query, data.split(":")[-1]) + # ===== 降價決策按鈕(僅支援 momo:pa:xxx / momo:pr:xxx 格式)===== + elif data.startswith("momo:pa:"): + await self._handle_price_approve(query, data.split(":")[-1]) - elif data.startswith("momo:pr:"): - await self._handle_price_reject(query, data.split(":")[-1]) + elif data.startswith("momo:pr:"): + await self._handle_price_reject(query, data.split(":")[-1]) - # ===== L3 運維決策按鈕(momo:ops::)===== - elif data.startswith("momo:ops:"): - await self._handle_ops_callback(query, data) + # ===== L3 運維決策按鈕(momo:ops::)===== + elif data.startswith("momo:ops:"): + await self._handle_ops_callback(query, data) - # ===== 批次定價決策(momo:bpa / bpr:)===== - elif data.startswith("momo:bpa:"): - await self._handle_batch_price_decision(query, data.split(":", 2)[-1], "approve") + # ===== 批次定價決策(momo:bpa / bpr:)===== + elif data.startswith("momo:bpa:"): + await self._handle_batch_price_decision(query, data.split(":", 2)[-1], "approve") - elif data.startswith("momo:bpr:"): - await self._handle_batch_price_decision(query, data.split(":", 2)[-1], "reject") + elif data.startswith("momo:bpr:"): + await self._handle_batch_price_decision(query, data.split(":", 2)[-1], "reject") - # ===== 事件忽略(momo:eig:)===== - elif data.startswith("momo:eig:"): - await self._handle_event_ignore(query, data.split(":", 2)[-1]) + # ===== 事件忽略(momo:eig:)===== + elif data.startswith("momo:eig:"): + await self._handle_event_ignore(query, data.split(":", 2)[-1]) - # ===== OpenClaw 指令按鈕(cmd::)===== - elif data.startswith("cmd:"): - parts = data[4:].split(":", 1) - cmd = parts[0] - arg = parts[1] if len(parts) > 1 else "" - chat_id = query.message.chat_id - import threading as _t - _t.Thread( - target=self._forward_cmd_to_openclaw, - args=(cmd, arg, chat_id), - daemon=True, - ).start() + # ===== AI 回應建議按鈕 ===== + elif data == "momo:menu:main": + await query.edit_message_text( + "👋 *MOMO 趨勢助手 Bot* — 請選擇功能類別:", + parse_mode='Markdown', + reply_markup=self._get_main_menu_keyboard() + ) + + elif data.startswith("momo:cmd:suggestion:"): + suggestion_text = data.split(":", 3)[-1].replace("_", " ") + await query.edit_message_text(f"⏳ 正在查詢:{suggestion_text}...") + chat_id = query.message.chat_id + import threading as _t + _t.Thread( + target=self._forward_cmd_to_openclaw, + args=("suggestion", suggestion_text, chat_id), + daemon=True, + ).start() + + # ===== 待輸入狀態按鈕(await:xxx)===== + elif data.startswith("await:"): + await self._handle_await_callback(query, data[6:], context) + + # ===== OpenClaw 指令按鈕(cmd::)===== + elif data.startswith("cmd:"): + parts = data[4:].split(":", 1) + cmd = parts[0] + arg = parts[1] if len(parts) > 1 else "" + chat_id = query.message.chat_id + try: + await query.edit_message_text("⏳ 正在處理,請稍候...") + except Exception: + pass + import threading as _t + _t.Thread( + target=self._forward_cmd_to_openclaw, + args=(cmd, arg, chat_id), + daemon=True, + ).start() + + except Exception as _e: + logger.error(f"[handle_callback] error processing '{data}': {_e}", exc_info=True) + try: + await query.edit_message_text( + "❌ 系統處理異常,請重試或返回主選單", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("🔙 返回主選單", callback_data="menu:main") + ]]) + ) + except Exception: + pass + + async def _handle_await_callback(self, query, await_type: str, context): + """處理所有 await: 類型的按鈕,設定 waiting_for 狀態並提示用戶輸入""" + prompts = { + 'date_range_sales': ('📅 業績查詢 — 指定日期區間', '請輸入日期區間\n格式:YYYY/MM/DD-YYYY/MM/DD\n例如:2026/04/01-2026/04/07'), + 'date_top': ('📅 商品排行 — 指定日期', '請輸入日期\n格式:YYYY/MM/DD\n例如:2026/04/25'), + 'date_competitor': ('📅 競品報告 — 指定日期', '請輸入日期\n格式:YYYY/MM/DD\n例如:2026/04/25'), + 'date_ppt_daily': ('📅 日報簡報 — 指定日期', '請輸入日期\n格式:YYYY/MM/DD\n例如:2026/04/25'), + 'date_ppt_monthly': ('📅 月報簡報 — 指定月份', '請輸入月份\n格式:YYYY/MM\n例如:2026/04'), + 'date_ppt_vendor': ('📅 廠商簡報 — 指定月份', '請輸入月份\n格式:YYYY/MM\n例如:2026/04'), + 'date_trend_month': ('📅 月份趨勢 — 指定月份', '請輸入月份\n格式:YYYY/MM\n例如:2026/04'), + 'date_trend_year': ('📅 年度趨勢 — 指定年份', '請輸入年份\n格式:YYYY\n例如:2026'), + 'date_trend_quarter':('📅 季度趨勢 — 指定季度', '請輸入季度\n格式:YYYY/Q(Q=1~4)\n例如:2026/2'), + 'goal_daily': ('🎯 日目標設定', '請輸入日目標金額(元,純數字)\n例如:500000'), + 'goal_monthly': ('🎯 月目標設定', '請輸入月目標金額(元,純數字)\n例如:15000000'), + 'goal_quarterly': ('🎯 季目標設定', '請輸入季目標金額(元,純數字)\n例如:45000000'), + 'goal_half': ('🎯 半年目標設定', '請輸入半年目標金額(元,純數字)\n例如:90000000'), + 'goal_yearly': ('🎯 年目標設定', '請輸入年目標金額(元,純數字)\n例如:180000000'), + 'promo_range': ('🎉 促銷效益追蹤', '請輸入促銷日期區間\n格式:YYYY/MM/DD-YYYY/MM/DD\n例如:2026/04/01-2026/04/07'), + 'search_compare': ('🔍 競品關鍵字比價', '請輸入商品關鍵字\n例如:防曬乳 SPF50'), + } + if await_type not in prompts: + await query.answer(f"未知類型:{await_type}", show_alert=True) + return + title, hint = prompts[await_type] + context.user_data['waiting_for'] = await_type + await query.edit_message_text( + f"*{title}*\n\n{hint}\n\n請直接在對話框輸入:", + parse_mode='Markdown', + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("❌ 取消", callback_data="menu:main") + ]]) + ) def _forward_cmd_to_openclaw(self, cmd: str, arg: str, chat_id: int): """轉發 cmd:* 指令到 OpenClaw Flask 內部 API""" @@ -1290,6 +1363,11 @@ class TrendTelegramBot: await self._process_copy(update, text) return + if waiting_for and waiting_for.startswith(('date_', 'goal_', 'promo_', 'search_compare')): + context.user_data['waiting_for'] = None + await self._process_await_input(update, context, waiting_for, text) + return + # Enhanced natural language processing with AI integration try: # Import AI integration @@ -1312,12 +1390,12 @@ class TrendTelegramBot: await self._handle_simple_ai_response(update, ai_result) else: # Fallback to enhanced keyword matching - await self._enhanced_keyword_matching(update, text) - + await self._enhanced_keyword_matching(update, context, text) + except Exception as e: logger.error(f"[handle_message] AI processing error: {e}", exc_info=True) # Fallback to enhanced keyword matching - await self._enhanced_keyword_matching(update, text) + await self._enhanced_keyword_matching(update, context, text) async def _handle_complex_ai_response(self, update: Update, ai_result: dict): """Handle complex AI responses""" @@ -1354,11 +1432,11 @@ class TrendTelegramBot: keyboard.append([]) keyboard[-1].append({ 'text': suggestion, - 'callback_data': f'cmd:suggestion:{suggestion.lower().replace(" ", "_")}' + 'callback_data': f'momo:cmd:suggestion:{suggestion.lower().replace(" ", "_")}' }) - + # Add main menu button - keyboard.append([{'text': 'Main Menu', 'callback_data': 'menu:main'}]) + keyboard.append([{'text': '主選單', 'callback_data': 'momo:menu:main'}]) await update.message.reply_text( response_text, @@ -1371,7 +1449,7 @@ class TrendTelegramBot: reply_markup=self._get_main_menu_keyboard() ) - async def _enhanced_keyword_matching(self, update: Update, text: str): + async def _enhanced_keyword_matching(self, update: Update, context, text: str): """Enhanced keyword matching as fallback with Traditional Chinese responses""" import re from datetime import datetime, timedelta @@ -1493,6 +1571,69 @@ class TrendTelegramBot: "Product analysis", "Market intelligence" ]) + + async def _process_await_input(self, update, context, await_type: str, text: str): + """處理所有 await: 狀態的用戶輸入,路由到對應 cmd""" + import re + chat_id = update.effective_chat.id + + date_cmd_map = { + 'date_range_sales': ('sales', text.strip()), + 'date_top': ('top', text.strip()), + 'date_competitor': ('ppt', 'competitor ' + text.strip()), + 'date_ppt_daily': ('ppt', 'daily ' + text.strip()), + 'date_ppt_monthly': ('ppt', 'monthly ' + text.strip()), + 'date_ppt_vendor': ('ppt', 'vendor ' + text.strip()), + 'date_trend_month': ('trend', text.strip()), + 'date_trend_year': ('trend', text.strip()), + 'date_trend_quarter':('trend', text.strip()), + 'promo_range': ('promo', text.strip()), + 'search_compare': ('competitor', text.strip()), + } + goal_types = { + 'goal_daily': 'daily', + 'goal_monthly': 'monthly', + 'goal_quarterly': 'quarterly', + 'goal_half': 'half', + 'goal_yearly': 'yearly', + } + + if await_type in date_cmd_map: + cmd, arg = date_cmd_map[await_type] + await update.message.reply_text(f"⏳ 正在處理 `{text.strip()}`...", parse_mode='Markdown') + import threading as _t + _t.Thread( + target=self._forward_cmd_to_openclaw, + args=(cmd, arg, chat_id), + daemon=True, + ).start() + elif await_type in goal_types: + period = goal_types[await_type] + amount_str = re.sub(r'[^\d]', '', text) + if not amount_str: + await update.message.reply_text( + "⚠️ 格式錯誤,請輸入純數字(例如:500000)", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("🔙 返回目標管理", callback_data="menu:goals") + ]]) + ) + return + await update.message.reply_text( + f"⏳ 正在設定{period}目標 {int(amount_str):,} 元...", + parse_mode='Markdown' + ) + import threading as _t + _t.Thread( + target=self._forward_cmd_to_openclaw, + args=('goal', f'set:{period}:{amount_str}', chat_id), + daemon=True, + ).start() + else: + await update.message.reply_text( + "⚠️ 無法識別輸入類型,請重新選擇功能", + reply_markup=self._get_main_menu_keyboard() + ) + async def _process_search(self, update: Update, query: str): """處理搜尋請求""" await update.message.reply_text(f"🔍 正在搜尋「{query}」...") diff --git a/telegram_ai_integration.py b/telegram_ai_integration.py index 17f97b0..f88d940 100644 --- a/telegram_ai_integration.py +++ b/telegram_ai_integration.py @@ -50,6 +50,11 @@ class TelegramAIIntegration: # L1: Semantic understanding (Hermes) l1_result = await self.orchestrator.handle_l1(event, session_id) + if not l1_result or l1_result.get("metadata", {}).get("source") != "hermes_llm": + logger.warning( + f"[TelegramAIIntegration] Hermes LLM 未回應,走規則引擎降級" + f"(session={session_id} source={( l1_result or {}).get('metadata', {}).get('source', 'none')})" + ) # Check if this is a complex query requiring L2 processing if self._is_complex_query(user_message, l1_result): @@ -76,7 +81,11 @@ class TelegramAIIntegration: }, ) except Exception as e: - logger.warning(f"[TelegramAIIntegration] OpenClaw 策略師呼叫失敗: {e}") + logger.error( + f"[TelegramAIIntegration] OpenClaw 策略師呼叫失敗" + f"({type(e).__name__}: {e})", + exc_info=True, + ) strategist_text = "" response = self._format_complex_response(l1_result, l2_result, user_message)