#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Ollama LLM 服務模組 負責與 Ollama API 互動,提供文案生成、關鍵字提取等功能 """ import os import requests import json import logging from typing import Optional, Dict, Any, List from dataclasses import dataclass logger = logging.getLogger(__name__) # Ollama 設定 - 支援環境變數覆蓋 # 預設使用外網 URL (透過 Nginx 反向代理),本地開發可透過環境變數指定內網 # 注意:外網訪問時 API 路徑在 /ollama/ 下 OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'https://ollama.wooo.work/ollama') 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 分鐘 @dataclass class OllamaResponse: """Ollama 回應結構""" success: bool content: str model: str error: Optional[str] = None total_duration: Optional[float] = None class OllamaService: """Ollama LLM 服務""" # V-Opt: 連線狀態快取,避免重複檢查 _connection_cache = {'status': None, 'timestamp': 0} _CACHE_TTL = 60 # 快取 60 秒 def __init__(self, host: str = OLLAMA_HOST, model: str = DEFAULT_MODEL): self.host = host self.model = model self.available_models = [] def check_connection(self) -> bool: """檢查 Ollama 服務是否可用(含快取)""" import time # V-Opt: 使用快取避免頻繁檢查 now = time.time() if (OllamaService._connection_cache['status'] is not None and now - OllamaService._connection_cache['timestamp'] < OllamaService._CACHE_TTL): return OllamaService._connection_cache['status'] try: # V-Opt: 縮短超時時間從 10 秒改為 3 秒 response = requests.get(f"{self.host}/api/tags", timeout=3) if response.status_code == 200: data = response.json() self.available_models = [m['name'] for m in data.get('models', [])] logger.info(f"Ollama 連線成功,可用模型: {self.available_models}") OllamaService._connection_cache = {'status': True, 'timestamp': now} return True OllamaService._connection_cache = {'status': False, 'timestamp': now} return False except Exception as e: logger.error(f"Ollama 連線失敗: {e}") OllamaService._connection_cache = {'status': False, 'timestamp': now} return False def list_models(self) -> List[str]: """列出可用模型""" if not self.available_models: self.check_connection() return self.available_models def generate(self, prompt: str, model: str = None, system_prompt: str = None, temperature: float = 0.7, timeout: int = None) -> OllamaResponse: """ 生成文字 Args: prompt: 使用者提示 model: 模型名稱(預設使用 self.model) system_prompt: 系統提示 temperature: 創意度 (0-1) timeout: 自訂超時時間(秒),預設使用 TIMEOUT Returns: OllamaResponse """ model = model or self.model request_timeout = timeout or TIMEOUT try: payload = { "model": model, "prompt": prompt, "stream": False, "options": { "temperature": temperature } } if system_prompt: payload["system"] = system_prompt logger.info(f"[Ollama] 開始生成,模型: {model},超時: {request_timeout}秒") response = requests.post( f"{self.host}/api/generate", json=payload, timeout=request_timeout ) if response.status_code == 200: data = response.json() return OllamaResponse( success=True, content=data.get('response', ''), model=model, total_duration=data.get('total_duration', 0) / 1e9 # 轉換為秒 ) else: return OllamaResponse( success=False, content='', model=model, error=f"HTTP {response.status_code}: {response.text}" ) except requests.Timeout: return OllamaResponse( success=False, content='', model=model, error="請求超時" ) except Exception as e: logger.error(f"Ollama 生成錯誤: {e}") return OllamaResponse( success=False, content='', model=model, error=str(e) ) def generate_sales_copy(self, product_name: str, trend_keywords: List[str] = None, style: str = "吸睛", upcoming_holidays: List[Dict] = None, bestseller_products: List[Dict] = None) -> OllamaResponse: """ 生成銷售文案 Args: product_name: 商品名稱 trend_keywords: 相關趨勢關鍵字 style: 文案風格 (吸睛/專業/溫馨/急迫) upcoming_holidays: 即將到來的假期 [{"name": "春節", "date": "2026-01-29", "days_until": 8}] bestseller_products: 競品熱銷商品 [{"name": "xxx", "price": 999}] Returns: OllamaResponse """ style_prompts = { "吸睛": "使用吸引眼球的標題和表情符號", "專業": "使用專業術語,強調成分和功效", "溫馨": "使用溫暖的語氣,強調呵護和關愛", "急迫": "使用限時優惠的語氣,創造緊迫感" } # 趨勢關鍵字 trend_context = "" if trend_keywords: trend_context = f"\n目前的熱門趨勢關鍵字:{', '.join(trend_keywords)}。請嘗試將這些趨勢融入文案中。" # 即將到來的假期 holiday_context = "" if upcoming_holidays: holidays_text = [] for h in upcoming_holidays[:3]: # 最多取 3 個 name = h.get('name', '') days = h.get('days_until', 0) if days == 0: holidays_text.append(f"{name}(今天)") elif days == 1: holidays_text.append(f"{name}(明天)") else: holidays_text.append(f"{name}({days}天後)") if holidays_text: holiday_context = f"\n即將到來的假期:{', '.join(holidays_text)}。可以考慮結合節慶氛圍或送禮情境。" # 競品熱銷參考 bestseller_context = "" if bestseller_products: products_text = [f"{p.get('name', '')}(${p.get('price', '')})" for p in bestseller_products[:3]] if products_text: bestseller_context = f"\n市場熱銷參考:{', '.join(products_text)}。可參考熱銷趨勢但要突出自家商品特色。" system_prompt = """你是一位專業的電商銷售文案寫手和行銷策略專家,專門為台灣電商平台撰寫商品文案。 你的文案特點: - 使用繁體中文 - 善用表情符號增加吸引力 - 強調商品賣點和消費者利益 - 適時使用行動呼籲 (CTA) - 若有即將到來的節日,可適度融入節慶元素 - 提供完整的行銷建議""" prompt = f"""請為以下商品撰寫完整的銷售文案套組: 商品名稱:{product_name} 文案風格:{style_prompts.get(style, style_prompts['吸睛'])} {trend_context}{holiday_context}{bestseller_context} 請按照以下格式生成完整的銷售文案套組: 【大標題】 (15字以內的主打標語,吸引眼球,適合用於廣告Banner) 【中標題】 (30字以內的副標題,補充說明賣點) 【小標題】 (20字以內的精簡標語,適合用於社群貼文) 【詳細文案】 (100-150字的完整銷售文案,包含商品特色、使用情境、行動呼籲) 【推廣建議】 • 社群推廣:(Facebook/Instagram/LINE 等社群平台的建議策略) • 影音內容:(短影音/直播/開箱影片等建議) • 其他建議:(EDM、部落格、KOL合作等專業建議) 請確保所有內容使用繁體中文,風格一致,並突出商品價值:""" # 文案生成使用更長的超時時間 return self.generate(prompt, system_prompt=system_prompt, temperature=0.8, timeout=COPY_TIMEOUT) def extract_keywords(self, text: str, max_keywords: int = 10) -> OllamaResponse: """ 從文字中提取關鍵字 Args: text: 要分析的文字 max_keywords: 最大關鍵字數量 Returns: OllamaResponse(content 為逗號分隔的關鍵字) """ system_prompt = "你是一位關鍵字提取專家。請從給定的文字中提取最重要的關鍵字。" prompt = f"""請從以下文字中提取最多 {max_keywords} 個關鍵字,這些關鍵字應該能代表文章的主題和重點。 文字內容: {text} 請只輸出關鍵字,用逗號分隔,不要輸出其他內容:""" return self.generate(prompt, system_prompt=system_prompt, temperature=0.3) def match_products_to_trend(self, trend_topic: str, trend_description: str, products: List[Dict[str, Any]]) -> OllamaResponse: """ 根據趨勢話題匹配適合的商品 Args: trend_topic: 趨勢話題 trend_description: 趨勢描述 products: 商品列表 [{"name": "...", "category": "...", "description": "..."}, ...] Returns: OllamaResponse(content 為 JSON 格式的推薦結果) """ # 只取前 50 個商品避免 prompt 過長 products_text = "\n".join([ f"- {p.get('name', '')} (分類: {p.get('category', '未分類')})" for p in products[:50] ]) system_prompt = """你是一位電商行銷專家,擅長將熱門話題與商品進行關聯。 你的任務是從商品列表中找出最適合搭配當前趨勢話題進行行銷的商品。""" prompt = f"""當前熱門話題:{trend_topic} 話題描述:{trend_description} 商品列表: {products_text} 請從上述商品中選出最適合搭配這個話題進行行銷的前 5 個商品。 對於每個推薦的商品,請說明: 1. 為什麼這個商品適合這個話題 2. 建議的行銷角度 請用以下 JSON 格式回覆: {{ "recommendations": [ {{"product_name": "商品名稱", "reason": "推薦原因", "marketing_angle": "行銷角度"}}, ... ] }}""" return self.generate(prompt, system_prompt=system_prompt, temperature=0.5) def analyze_trend_relevance(self, trend_info: str, product_categories: List[str]) -> OllamaResponse: """ 分析趨勢與商品分類的相關性 Args: trend_info: 趨勢資訊 product_categories: 商品分類列表 Returns: OllamaResponse """ categories_text = ", ".join(product_categories) system_prompt = "你是一位市場分析師,擅長分析消費趨勢與商品之間的關聯。" prompt = f"""趨勢資訊: {trend_info} 可用的商品分類: {categories_text} 請分析這個趨勢與哪些商品分類最相關,並給出相關性評分(1-10分)。 請用 JSON 格式回覆: {{ "analysis": "簡短的分析說明", "relevant_categories": [ {{"category": "分類名稱", "score": 8, "reason": "相關原因"}}, ... ] }}""" return self.generate(prompt, system_prompt=system_prompt, temperature=0.4) def web_search(self, query: str, num_results: int = 5, search_type: str = "general") -> OllamaResponse: """ 使用 Ollama 進行網路搜尋並整理結果 注意:這個功能需要 Ollama 支援工具調用 (tool calling) 或使用支援搜尋的模型 (如 llama3.2 with tools) Args: query: 搜尋關鍵字 num_results: 返回結果數量 search_type: 搜尋類型 (general/news/shopping/trends) Returns: OllamaResponse """ search_prompts = { "general": "請搜尋並整理關於此主題的最新資訊", "news": "請搜尋並整理此主題的最新新聞和報導", "shopping": "請搜尋並整理此商品的市場資訊、價格和評價", "trends": "請搜尋並分析此主題的市場趨勢和熱門程度" } system_prompt = """你是一位專業的市場研究分析師。 你的任務是根據使用者的搜尋需求,整理出結構化的資訊。 請用以下 JSON 格式回覆: { "query": "原始搜尋關鍵字", "summary": "搜尋結果摘要(50字以內)", "results": [ { "title": "結果標題", "description": "簡短描述", "relevance": "與搜尋的相關性說明", "keywords": ["相關關鍵字1", "關鍵字2"] } ], "insights": ["洞察1", "洞察2"], "recommended_actions": ["建議行動1", "建議行動2"] }""" search_context = search_prompts.get(search_type, search_prompts["general"]) prompt = f"""搜尋需求:{query} 搜尋類型:{search_type} 期望結果數:{num_results} {search_context} 請根據你對這個主題的了解,提供結構化的分析結果。 包含主要的市場趨勢、相關關鍵字、以及對電商銷售的建議。""" return self.generate(prompt, system_prompt=system_prompt, temperature=0.5, timeout=120) def search_product_insights(self, product_name: str, include_competitors: bool = True, include_trends: bool = True, web_context: str = "") -> OllamaResponse: """ 搜尋商品相關的市場洞察 Args: product_name: 商品名稱 include_competitors: 是否包含競品分析 include_trends: 是否包含趨勢分析 web_context: 網路搜尋結果(用於提供即時市場資訊) Returns: OllamaResponse """ system_prompt = """你是一位資深的電商市場分析師,專精於台灣市場。 你擅長分析商品的市場定位、競爭對手、以及銷售趨勢。 請提供全面但簡潔的市場洞察,使用繁體中文。 若有提供網路搜尋結果,請優先參考這些最新資訊進行分析。""" analysis_parts = ["市場定位分析"] if include_competitors: analysis_parts.append("主要競爭對手分析") if include_trends: analysis_parts.append("市場趨勢分析") # 建構動態 JSON 區塊(避免 f-string 中使用 backslash) competitors_json = '"competitors": [{"name": "競品名稱", "strength": "優勢", "weakness": "劣勢"}],' if include_competitors else "" trends_json = '"trends": {"current": "當前趨勢", "forecast": "趨勢預測", "seasonality": "季節性因素"},' if include_trends else "" analysis_list = chr(10).join([f'{i+1}. {part}' for i, part in enumerate(analysis_parts)]) # 加入網路搜尋結果(如果有) web_context_section = "" if web_context and web_context.strip(): web_context_section = f""" 【參考資料 - 網路搜尋最新結果】 {web_context.strip()} 請根據以上網路搜尋結果,結合你的知識,提供更精準的市場分析。 """ prompt = f"""請為以下商品提供市場洞察分析: 商品名稱:{product_name} {web_context_section} 請分析以下面向: {analysis_list} 請用以下 JSON 格式回覆(務必輸出有效的 JSON): {{ "product_name": "{product_name}", "market_position": {{ "target_audience": "目標客群描述", "price_range": "價格區間建議", "positioning": "市場定位建議" }}, {competitors_json} {trends_json} "recommendations": ["銷售建議1", "銷售建議2", "銷售建議3"], "keywords": ["行銷關鍵字1", "關鍵字2", "關鍵字3"] }}""" return self.generate(prompt, system_prompt=system_prompt, temperature=0.6, timeout=180) def search_trend_keywords(self, category: str, time_range: str = "week") -> OllamaResponse: """ 搜尋特定分類的熱門關鍵字和趨勢 Args: category: 商品分類 time_range: 時間範圍 (day/week/month) Returns: OllamaResponse """ time_desc = { "day": "今天", "week": "本週", "month": "本月" } system_prompt = """你是一位社群媒體和搜尋趨勢分析專家,專注於台灣電商市場。 你熟悉各大平台的熱門話題、關鍵字趨勢、以及消費者行為。""" prompt = f"""請分析「{category}」這個商品分類在{time_desc.get(time_range, '近期')}的熱門關鍵字和趨勢。 請提供: 1. 熱門搜尋關鍵字(5-10個) 2. 社群討論熱點(3-5個話題) 3. 消費者關注點 4. 行銷建議 請用以下 JSON 格式回覆: {{ "category": "{category}", "time_range": "{time_range}", "hot_keywords": [ {{"keyword": "關鍵字", "trend": "上升/穩定/下降", "relevance": "高/中/低"}} ], "social_topics": [ {{"topic": "話題", "platform": "平台", "engagement": "互動度描述"}} ], "consumer_concerns": ["關注點1", "關注點2"], "marketing_suggestions": ["建議1", "建議2"] }}""" 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() if __name__ == "__main__": # 測試程式碼 logging.basicConfig(level=logging.INFO) service = OllamaService() # 測試連線 print("測試 Ollama 連線...") if service.check_connection(): print(f"連線成功!可用模型: {service.available_models}") # 測試文案生成 print("\n測試文案生成...") result = service.generate_sales_copy( "玻尿酸保濕面膜", trend_keywords=["換季保養", "敏感肌"], style="吸睛" ) if result.success: print(f"生成結果: {result.content}") print(f"耗時: {result.total_duration:.2f} 秒") else: print(f"生成失敗: {result.error}") else: print("連線失敗")