#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ services/mcp_collector_service.py MCP 外部情報收集層 透過 MCP omnisearch / Ollama-first fallback / Gemini final fallback 收集外部市場情報,供 OpenClaw 戰略分析使用: - 台灣電商市場趨勢 - 節日 / 促銷行事曆 - 季節性消費洞察 - 競品動態(蝦皮/PChome/momo/Yahoo) - 消費者情緒與熱銷品類 結果快取至 ai_insights(type='mcp_cache'),24h TTL 避免重複呼叫。 """ import json import logging import os import time import requests from datetime import datetime, timedelta from typing import Any, Dict, List, Optional from database.manager import get_session from sqlalchemy import text from services.gemini_guard import ( gemini_disabled_message, get_gemini_api_key, is_gemini_fallback_enabled, ) logger = logging.getLogger(__name__) MCP_CACHE_TTL_HOURS = int(os.getenv("MCP_CACHE_TTL_HOURS", "24")) # MCP router 是即時情報主路徑;router 不可用時先走 GCP Ollama 做離線洞察。 # 市場洞察屬非即時必需批次工作,不把長分析轉嫁到 111 fallback。 # Gemini Grounding 僅作最後備援,避免再次回到 Gemini-first。 MCP_MODEL = os.getenv("MCP_GEMINI_MODEL", "gemini-2.0-flash") MCP_FALLBACK_MODEL = "gemini-1.5-flash" MCP_OLLAMA_TIMEOUT = int(os.getenv("MCP_OLLAMA_TIMEOUT", "25")) MCP_OLLAMA_NUM_PREDICT = int(os.getenv("MCP_OLLAMA_NUM_PREDICT", "500")) try: from services.ollama_service import OllamaService _OLLAMA_AVAILABLE = True except ImportError: _OLLAMA_AVAILABLE = False def _fast_static_fallback_enabled() -> bool: return os.getenv("MCP_FAST_STATIC_FALLBACK", "").strip().lower() in {"1", "true", "yes", "on"} # ── 查詢主題定義 ──────────────────────────────────────────────────────────── _SEARCH_TOPICS = { "market_trends": ( "台灣電商 momo購物網 2026年熱銷商品趨勢 消費者行為 美妝保養 家電 生活用品" ), "holiday_calendar": ( "2026年台灣重要節日促銷行事曆 母親節 618購物節 雙11 雙12 中秋 跨年 電商大促" ), "seasonal_insights": ( "台灣電商季節性銷售趨勢 換季商品 夏季防曬 冬季保暖 Q3 Q4 消費高峰" ), "competitor_intel": ( "momo購物網 PChome 蝦皮 Yahoo購物 2026年競爭策略 促銷活動 物流比較" ), "consumer_sentiment": ( "台灣消費者 2026 購物偏好 低價高CP 品牌忠誠度 直播購物 社群電商 KOL影響" ), "pricing_strategy": ( "台灣電商定價策略 動態定價 競品比價 心理定價 促銷折扣最佳時機" ), } class MCPCollectorService: """ 外部情報收集服務(MCP 節點) 先用 MCP / Ollama;Gemini Search Grounding 僅作顯式開啟後的緊急備援。 """ def __init__(self): self._initialized = False self._genai = None def _ensure_init(self) -> bool: if self._initialized: return True if not is_gemini_fallback_enabled("mcp_collector"): logger.info("[MCP] %s", gemini_disabled_message("mcp_collector")) return False gemini_api_key = get_gemini_api_key("mcp_collector") if not gemini_api_key: logger.warning("[MCP] GEMINI_API_KEY 未設定,跳過外部情報收集") return False try: import google.generativeai as genai genai.configure(api_key=gemini_api_key) self._genai = genai self._initialized = True return True except ImportError: logger.error("[MCP] google-generativeai 未安裝") return False except Exception as e: logger.error("[MCP] Gemini 初始化失敗: %s", e) return False # ── 快取讀寫 ──────────────────────────────────────────────────────────── def _read_cache(self, topic: str) -> Optional[str]: session = get_session() try: row = session.execute( text(f""" SELECT content FROM ai_insights WHERE insight_type = 'mcp_cache' AND created_by = 'mcp_collector' AND metadata_json::jsonb ->> 'topic' = :topic AND created_at >= NOW() - INTERVAL '{MCP_CACHE_TTL_HOURS} hours' ORDER BY created_at DESC LIMIT 1 """), {"topic": topic}, ).fetchone() if row: logger.debug("[MCP] 快取命中: %s", topic) content = row[0] if self._looks_unreliable(content): logger.warning("[MCP] 快取內容含占位文字,略過 topic=%s", topic) return None return content return None except Exception: return None finally: session.close() def _write_cache(self, topic: str, content: str) -> None: session = get_session() try: row = session.execute(text(""" INSERT INTO ai_insights (insight_type, content, confidence, created_by, status, metadata_json) VALUES ('mcp_cache', :content, 0.9, 'mcp_collector', 'active', :meta) RETURNING id """), { "content": content[:4000], "meta": json.dumps({"topic": topic, "model": MCP_MODEL, "cached_at": datetime.now().isoformat()}) }).fetchone() session.commit() if row: try: from services.openclaw_learning_service import enqueue_insight_embedding enqueue_insight_embedding(row[0], "mcp_cache", content[:4000]) except Exception as embed_err: logger.warning("[MCP] embedding queue enqueue failed: %s", embed_err) except Exception as e: logger.warning("[MCP] 快取寫入失敗: %s", e) session.rollback() finally: session.close() # ── 單主題搜尋 ────────────────────────────────────────────────────────── def _search_topic(self, topic: str, query: str) -> str: cached = self._read_cache(topic) if cached: return cached if _fast_static_fallback_enabled(): return self._fallback_topic_content(topic, "定期簡報快速補跑:外部模型暫停,使用穩定行銷情報。") # ─── Phase 10.5(2026-05-04):MCP omnisearch L0 路徑 ─── # MCP_ROUTER_ENABLED=true 且 docker-compose.mcp.yml 已 deploy 時, # 優先走 self-hosted Tavily/Exa(取代 Gemini Grounding 主路徑)。 # 失敗先 fallback 到 Ollama 三主機離線摘要;Gemini Grounding 僅作最後備援。 try: from services.mcp_router import mcp_router, is_mcp_router_enabled if is_mcp_router_enabled(): mcp_result = mcp_router.call( server='omnisearch', tool='tavily_search', args={'query': query, 'max_results': 5}, caller='mcp_collector', ) if mcp_result.success and mcp_result.data: # tavily 回傳格式:{'results': [{'title', 'content', 'url'}, ...]} results = mcp_result.data.get('results', []) if results: content_lines = [] for r in results[:5]: title = (r.get('title') or '').strip() text_ = (r.get('content') or r.get('text') or '').strip()[:300] if title and text_: content_lines.append(f"【{title}】{text_}") if content_lines: content = "\n\n".join(content_lines) if not self._looks_unreliable(content): self._write_cache(topic, content) logger.info("[MCP] omnisearch tavily 命中 topic=%s 取代 Gemini Grounding", topic) return content # omnisearch 失敗 / 結果太少 → 嘗試 exa 備援 exa_result = mcp_router.call( server='omnisearch', tool='exa_search', args={'query': query, 'num_results': 5}, caller='mcp_collector', ) if exa_result.success and exa_result.data: results = exa_result.data.get('results', []) if results: content_lines = [ f"【{r.get('title','')}】{(r.get('text') or '')[:300]}" for r in results[:5] if r.get('title') ] if content_lines: content = "\n\n".join(content_lines) if not self._looks_unreliable(content): self._write_cache(topic, content) logger.info("[MCP] omnisearch exa 命中 topic=%s(tavily 失敗備援)", topic) return content logger.info("[MCP] omnisearch 全失敗,fallback Ollama static insight") except Exception as router_err: logger.debug("[MCP] mcp_router 不可用 (預期 deploy 前): %s", router_err) # ─── Phase 10.5 end,下方先走 Ollama;Gemini Grounding 僅最後備援 ─── ollama_content = self._ollama_topic_fallback(topic, query) if ollama_content: return ollama_content if not self._ensure_init(): reason = ( gemini_disabled_message("mcp_collector") if not is_gemini_fallback_enabled("mcp_collector") else "GEMINI_API_KEY 未設定,使用本地行銷情報。" ) return self._fallback_topic_content(topic, reason) try: prompt = f"請用繁體中文整理以下主題的最新資訊,提供具體數據與洞察,500字以內:\n{query}" response = None last_error = None for tools in (["google_search"], ["google_search_retrieval"], None): try: kwargs = {"model_name": MCP_MODEL} if tools: kwargs["tools"] = tools model = self._genai.GenerativeModel(**kwargs) response = model.generate_content(prompt) break except Exception as tool_err: last_error = tool_err continue if response is None: raise last_error or RuntimeError("Gemini response empty") content = response.text or "" if self._looks_unreliable(content): return self._fallback_topic_content(topic, "即時搜尋內容含占位數字或待更新文字,已改用本地行銷情報。") if content: self._write_cache(topic, content) return content return self._fallback_topic_content(topic, "Gemini 回傳空內容,使用本地行銷情報。") except Exception as e: logger.warning("[MCP] Gemini 2.0 Grounding failed topic=%s: %s, trying 1.5 Flash", topic, e) # 級別 2:嘗試 1.5 Flash (通常配額較穩) try: model = self._genai.GenerativeModel(model_name=MCP_FALLBACK_MODEL, tools=["google_search"]) response = model.generate_content(prompt) content = response.text or "" if content and not self._looks_unreliable(content): self._write_cache(topic, content) return content except Exception as e2: logger.warning("[MCP] Gemini 1.5 Flash also failed: %s", e2) return self._fallback_topic_content(topic, f"即時外部搜尋暫不可用:{type(e).__name__}") def _ollama_topic_fallback(self, topic: str, query: str) -> Optional[str]: """MCP/搜尋不可用時先走 GCP Ollama;111 不承接市場洞察長任務。""" if not _OLLAMA_AVAILABLE: return None try: logger.info("[MCP] Using GCP-A/GCP-B Ollama for market insight fallback topic=%s", topic) ollama_model = os.getenv('OPENCLAW_OLLAMA_MODEL', 'qwen2.5-coder:7b') ollama_prompt = ( f"你是一位精通台灣電商市場的分析師。目前無法取得即時搜尋結果," f"請根據你的知識儲備,針對以下主題提供 2026 年可能的市場動態或洞察(繁體中文,300字以內):\n" f"主題:{query}\n\n" "請註明:『(此為基於歷史趨勢的預測性洞察)』" ) resp = OllamaService(model=ollama_model).generate( prompt=ollama_prompt, model=ollama_model, temperature=0.4, timeout=MCP_OLLAMA_TIMEOUT, options={'num_predict': MCP_OLLAMA_NUM_PREDICT}, allow_111_fallback=False, ) content = (resp.content or '').strip() if resp.success else '' if content and not self._looks_unreliable(content): # 不進快取,因為這是預測性內容。 return content if not resp.success: logger.warning("[MCP] GCP Ollama fallback failed: %s", resp.error) except Exception as exc: logger.warning("[MCP] Ollama fallback failed: %s", exc) return None @staticmethod def _looks_unreliable(content: str) -> bool: """避免將模型產生的占位數字或待補文字當成真實情報。""" if not content: return False markers = ( "XX", "請自行更新", "待補", "資料待查", "自行查詢", "示例數據", "範例數據", ) return any(marker in content for marker in markers) def _fallback_topic_content(self, topic: str, reason: str = "") -> str: """外部搜尋失敗時的穩定回覆,避免 Telegram 按鈕空白或像壞掉。""" holiday = self.get_holiday_context() seasonal = self.get_seasonal_context() fallback_map = { "market_trends": [ "台灣電商營運觀察:美妝保養、保健食品、母嬰與個人清潔仍適合用週期性促銷與組合包拉升轉換。", "建議優先檢查近期高業績品類、毛利率與庫存週轉,將活動資源集中在高轉換商品。", ], "competitor_intel": [ "競品情報 fallback:請優先比較 momo / PChome / 蝦皮同款商品的售價、庫存、到貨速度與組合優惠。", "若 PChome 價格優勢明顯,可強化文案中的即時到貨、價格透明與組合折扣。", ], "consumer_sentiment": [ "消費者聲量 fallback:高 CP 值、到貨速度、真實評價與成分/規格透明度通常會影響購買意願。", "建議把負評來源拆成價格、物流、規格不符、售後服務四類追蹤。", ], "pricing_strategy": [ "定價策略 fallback:先鎖定高流量高轉換商品做競品價差監控,再用加價購、滿額折與組合包保護毛利。", ], "holiday_calendar": [holiday], "seasonal_insights": [seasonal], } lines = fallback_map.get(topic, [holiday, seasonal]) if reason: lines.append(f"資料狀態:{reason}") return "\n".join(line for line in lines if line) # ── 公開介面 ──────────────────────────────────────────────────────────── def collect_all(self) -> Dict[str, str]: """ 收集所有外部情報主題,回傳 {topic: content} 字典。 各主題獨立失敗不影響整體。 """ results = {} for topic, query in _SEARCH_TOPICS.items(): try: results[topic] = self._search_topic(topic, query) time.sleep(0.5) # 避免 Gemini rate limit except Exception as e: logger.error("[MCP] topic=%s 收集失敗: %s", topic, e) results[topic] = "" logger.info("[MCP] 收集完成,有效主題=%d/%d", sum(1 for v in results.values() if v), len(results)) return results def collect_topic(self, topic: str) -> str: """收集單一主題""" query = _SEARCH_TOPICS.get(topic, topic) return self._search_topic(topic, query) def get_holiday_context(self) -> str: """取得節日行事曆(供 Prompt 注入)""" now = datetime.now() month = now.month day = now.day # 靜態台灣電商節日知識庫 static_calendar = { 1: "元旦促銷、農曆新年備貨期", 2: "農曆新年、情人節(2/14)", 3: "婦女節(3/8)、開學季", 4: "清明連假(4月初)、春季大促、換季服飾高峰", 5: "母親節(5月第2週,年度大促)、520情人節、勞動節(5/1)", 6: "618購物節(年中最大促銷)、端午節", 7: "暑假開端、父親節前哨站", 8: "父親節(8/8)、七夕情人節", 9: "中秋節、開學季、百貨週年慶預熱", 10: "雙10國慶、百貨週年慶高峰期", 11: "雙11光棍節(全年最強電商季)、黑五大促", 12: "雙12年終慶、聖誕節、跨年備貨", } current_focus = static_calendar.get(month, "") next_month = (month % 12) + 1 next_focus = static_calendar.get(next_month, "") # 月底優化:若超過 20 號,自動將焦點轉向「下月預告」,避免產生如「4月底還在過清明節」的幻覺 if day > 20: header = f"當前日期:{now.strftime('%Y/%m/%d')} (月底轉場期)" body = f"本月即將結束,目前重點已轉向:{next_focus}" footer = f"下月詳細預告:{next_focus}" else: header = f"當前日期:{now.strftime('%Y/%m/%d')}" body = f"本月電商重點:{current_focus}" footer = f"下月預告:{next_focus}" return f"{header}\n{body}\n{footer}" def get_seasonal_context(self) -> str: """季節性消費情境""" month = datetime.now().month seasons = { (3, 4, 5): "春季:換季保養、外出服飾、春遊裝備", (6, 7, 8): "夏季:防曬/美白、涼感寢具、戶外運動、冷氣清潔", (9, 10, 11): "秋季:保濕修護、秋冬服飾、保健養生、熱飲週邊", (12, 1, 2): "冬季:保暖寢具、暖身家電、年節禮品、養生補品", } for months, desc in seasons.items(): if month in months: return desc return "" # 模組單例 mcp_collector = MCPCollectorService()