#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ AI 提供者抽象層 統一 Ollama 和 Gemini 的介面;通用生成一律 Ollama-first,Gemini 僅作備援 """ import os import logging from typing import Optional, Dict, Any, List, Union from dataclasses import dataclass from datetime import date, datetime logger = logging.getLogger(__name__) # AI 提供者設定:Gemini 不可作為預設,只能在 Ollama 失敗後備援 AI_PROVIDER = os.getenv('AI_PROVIDER', 'ollama') # 引入服務 from .ollama_service import OllamaService, OllamaResponse from .gemini_service import GeminiService, GeminiResponse, AVAILABLE_GEMINI_MODELS from .elephant_service import ElephantService, ElephantResponse from .gemini_guard import gemini_disabled_message, is_gemini_fallback_enabled @dataclass class AIResponse: """統一的 AI 回應結構""" success: bool content: str model: str provider: str # 'ollama', 'gemini' 或 'elephant' error: Optional[str] = None total_duration: Optional[float] = None input_tokens: int = 0 output_tokens: int = 0 total_tokens: int = 0 input_cost: float = 0.0 output_cost: float = 0.0 total_cost: float = 0.0 def to_dict(self) -> Dict[str, Any]: """轉換為字典格式""" return { 'success': self.success, 'content': self.content, 'model': self.model, 'provider': self.provider, 'error': self.error, 'total_duration': self.total_duration, 'input_tokens': self.input_tokens, 'output_tokens': self.output_tokens, 'total_tokens': self.total_tokens, 'input_cost': self.input_cost, 'output_cost': self.output_cost, 'total_cost': self.total_cost, } class AIProviderService: """ AI 提供者服務 - 統一的 AI 介面 通用文案 / 關鍵字 / 洞察生成一律先走 Ollama 三主機級聯。 Gemini 只在 Ollama 主路徑失敗時作為 fallback,不允許被設為預設主路徑。 """ def __init__(self, default_provider: str = None): """ 初始化 AI 提供者服務 Args: default_provider: 預設提供者(Gemini 會被降回 Ollama) """ self._default_provider = self._sanitize_default_provider(default_provider or AI_PROVIDER) self._ollama = OllamaService() self._gemini = GeminiService() self._elephant = ElephantService() # 狀態快取 self._status_cache = {'timestamp': 0, 'data': None} self._CACHE_TTL = 60 # 60 秒 @property def default_provider(self) -> str: """取得預設提供者""" return self._default_provider @default_provider.setter def default_provider(self, value: str): """設定預設提供者""" self._default_provider = self._sanitize_default_provider(value) logger.info(f"AI 預設提供者已切換至: {value}") @staticmethod def _sanitize_default_provider(value: str) -> str: """Gemini 是 fallback-only;任何預設 Gemini 設定都強制回 Ollama。""" normalized = (value or 'ollama').strip().lower() if normalized == 'gemini': logger.warning("AI_PROVIDER=gemini 已被拒絕;Gemini 僅作 Ollama 失敗備援") return 'ollama' if normalized not in ('ollama', 'elephant'): raise ValueError("Provider must be 'ollama' or 'elephant'; gemini is fallback-only") return normalized def get_status(self, force_refresh: bool = False) -> Dict[str, Any]: """ 取得所有 AI 服務的狀態 Args: force_refresh: 是否強制刷新快取 Returns: dict: 包含 Ollama 和 Gemini 狀態的字典 """ import time now = time.time() # 使用快取 if not force_refresh and self._status_cache['data'] is not None: if now - self._status_cache['timestamp'] < self._CACHE_TTL: return self._status_cache['data'] # 檢查各服務狀態 ollama_connected = self._ollama.check_connection() gemini_connected = self._gemini.check_connection() elephant_connected = self._elephant.check_connection() status = { 'default_provider': self._default_provider, 'ollama': { 'connected': ollama_connected, 'model': self._ollama.model if ollama_connected else None, 'available_models': self._ollama.available_models if ollama_connected else [], 'type': 'local', 'cost': 'free' }, 'gemini': { 'connected': gemini_connected, 'model': self._gemini.model if gemini_connected else None, 'available_models': AVAILABLE_GEMINI_MODELS, 'type': 'cloud', 'cost': 'paid' }, 'elephant': { 'connected': elephant_connected, 'model': self._elephant.model if elephant_connected else None, 'available_models': [{'id': 'nvidia/llama-3.1-nemotron-ultra-253b-v1', 'name': 'Nemotron Ultra 253B'}], 'type': 'cloud', 'cost': 'efficient' }, 'recommended_provider': self._get_recommended_provider(ollama_connected, gemini_connected, elephant_connected), 'timestamp': datetime.now().isoformat() } # 更新快取 self._status_cache = {'timestamp': now, 'data': status} return status def _get_recommended_provider(self, ollama_ok: bool, gemini_ok: bool, elephant_ok: bool) -> str: """根據可用性推薦主提供者;Gemini 僅能備援,不可被推薦為主路徑。""" if ollama_ok: return 'ollama' if self._default_provider == 'elephant' and elephant_ok: return 'elephant' if elephant_ok: return 'elephant' return 'none' def _convert_response(self, response: Union[OllamaResponse, GeminiResponse], provider: str) -> AIResponse: """將服務回應轉換為統一格式""" if isinstance(response, OllamaResponse): return AIResponse( success=response.success, content=response.content, model=response.model, provider='ollama', error=response.error, total_duration=response.total_duration, input_tokens=0, output_tokens=0, total_tokens=0, input_cost=0.0, output_cost=0.0, total_cost=0.0 # Ollama 是免費的 ) elif isinstance(response, GeminiResponse): return AIResponse( success=response.success, content=response.content, model=response.model, provider='gemini', error=response.error, total_duration=response.total_duration, input_tokens=response.input_tokens, output_tokens=response.output_tokens, total_tokens=response.total_tokens, input_cost=response.input_cost, output_cost=response.output_cost, total_cost=response.total_cost ) elif isinstance(response, ElephantResponse): return AIResponse( success=response.success, content=response.content, model=response.model, provider='elephant', error=response.error, total_duration=response.total_duration, input_tokens=response.input_tokens, output_tokens=response.output_tokens, total_tokens=response.total_tokens, input_cost=response.input_cost, output_cost=response.output_cost, total_cost=response.total_cost ) else: return AIResponse( success=False, content='', model='unknown', provider=provider, error='Unknown response type' ) def _gemini_fallback(self, ollama_result: AIResponse, fallback_call) -> AIResponse: """Ollama 失敗時才呼叫 Gemini,讓通用 AI 入口符合 Ollama-first。""" if ollama_result.success: return ollama_result if not is_gemini_fallback_enabled("ai_provider"): disabled = gemini_disabled_message("ai_provider") logger.warning("Ollama 主路徑失敗,但 %s", disabled) ollama_result.error = ( f"{ollama_result.error}; {disabled}" if ollama_result.error else disabled ) return ollama_result logger.warning("Ollama 主路徑失敗,啟用 Gemini 備援:%s", ollama_result.error) try: gemini_response = fallback_call() gemini_result = self._convert_response(gemini_response, 'gemini') if not gemini_result.success and ollama_result.error: gemini_result.error = f"Ollama 失敗:{ollama_result.error};Gemini 備援失敗:{gemini_result.error}" return gemini_result except Exception as exc: ollama_result.error = f"{ollama_result.error}; Gemini 備援例外:{exc}" return ollama_result def generate(self, prompt: str, provider: str = None, model: str = None, system_prompt: str = None, temperature: float = 0.7, timeout: int = None) -> AIResponse: """ 生成文字(統一介面) Args: prompt: 使用者提示 provider: 指定提供者;Gemini 只會在 Ollama 失敗後備援 model: 指定模型 system_prompt: 系統提示 temperature: 創意度 timeout: 超時時間(秒) Returns: AIResponse """ provider = (provider or self._default_provider or 'ollama').strip().lower() if provider == 'elephant': response = self._elephant.generate( prompt=prompt, model=model, system_prompt=system_prompt, temperature=temperature, timeout=timeout ) return self._convert_response(response, provider) ollama_response = self._ollama.generate( prompt=prompt, model=model, system_prompt=system_prompt, temperature=temperature, timeout=timeout ) ollama_result = self._convert_response(ollama_response, 'ollama') return self._gemini_fallback( ollama_result, lambda: self._gemini.generate( prompt=prompt, model=model, system_prompt=system_prompt, temperature=temperature, timeout=timeout ), ) def generate_sales_copy(self, product_name: str, provider: str = None, model: str = None, trend_keywords: List[str] = None, style: str = "吸睛", upcoming_holidays: List[Dict] = None, bestseller_products: List[Dict] = None) -> AIResponse: """ 生成銷售文案(統一介面) Args: product_name: 商品名稱 provider: 指定提供者 model: 指定模型 trend_keywords: 趨勢關鍵字 style: 文案風格 upcoming_holidays: 即將到來的假期 bestseller_products: 競品熱銷商品 Returns: AIResponse """ provider = (provider or self._default_provider or 'ollama').strip().lower() ollama_response = self._ollama.generate_sales_copy( product_name=product_name, trend_keywords=trend_keywords, style=style, upcoming_holidays=upcoming_holidays, bestseller_products=bestseller_products ) ollama_result = self._convert_response(ollama_response, 'ollama') return self._gemini_fallback( ollama_result, lambda: self._gemini.generate_sales_copy( product_name=product_name, trend_keywords=trend_keywords, style=style, upcoming_holidays=upcoming_holidays, bestseller_products=bestseller_products, model=model ), ) def extract_keywords(self, text: str, provider: str = None, model: str = None, max_keywords: int = 10) -> AIResponse: """ 提取關鍵字(統一介面) Args: text: 要分析的文字 provider: 指定提供者 model: 指定模型 max_keywords: 最大關鍵字數量 Returns: AIResponse """ ollama_response = self._ollama.extract_keywords(text, max_keywords) ollama_result = self._convert_response(ollama_response, 'ollama') return self._gemini_fallback( ollama_result, lambda: self._gemini.extract_keywords(text, max_keywords, model), ) def search_product_insights(self, product_name: str, provider: str = None, model: str = None, include_competitors: bool = True, include_trends: bool = True, web_context: str = "") -> AIResponse: """ 搜尋商品市場洞察(統一介面) Args: product_name: 商品名稱 provider: 指定提供者 model: 指定模型 include_competitors: 是否包含競品分析 include_trends: 是否包含趨勢分析 web_context: 網路搜尋結果(用於 Ollama) Returns: AIResponse """ ollama_response = self._ollama.search_product_insights( product_name=product_name, include_competitors=include_competitors, include_trends=include_trends, web_context=web_context ) ollama_result = self._convert_response(ollama_response, 'ollama') return self._gemini_fallback( ollama_result, lambda: self._gemini.search_product_insights( product_name=product_name, include_competitors=include_competitors, include_trends=include_trends, model=model ), ) def web_search(self, query: str, provider: str = None, model: str = None, num_results: int = 5, search_type: str = "general") -> AIResponse: """ 網路搜尋(統一介面)- 僅 Ollama 支援 Args: query: 搜尋關鍵字 provider: 指定提供者(會忽略 gemini) model: 指定模型 num_results: 返回結果數量 search_type: 搜尋類型 Returns: AIResponse """ # 網路搜尋目前僅 Ollama 支援 response = self._ollama.web_search(query, num_results, search_type) return self._convert_response(response, 'ollama') def search_trend_keywords(self, category: str, provider: str = None, model: str = None, time_range: str = "week") -> AIResponse: """ 搜尋趨勢關鍵字(統一介面)- 僅 Ollama 支援 Args: category: 商品分類 provider: 指定提供者(會忽略 gemini) model: 指定模型 time_range: 時間範圍 Returns: AIResponse """ # 趨勢關鍵字目前僅 Ollama 支援 response = self._ollama.search_trend_keywords(category, time_range) return self._convert_response(response, 'ollama') # 建立全域服務實例 ai_provider_service = AIProviderService() # 便捷函數 def get_ai_status(force_refresh: bool = False) -> Dict[str, Any]: """取得 AI 服務狀態""" return ai_provider_service.get_status(force_refresh) def set_ai_provider(provider: str) -> bool: """設定預設 AI 提供者""" try: ai_provider_service.default_provider = provider return True except ValueError: return False def generate_copy(product_name: str, provider: str = None, model: str = None, **kwargs) -> AIResponse: """生成文案(便捷函數)""" return ai_provider_service.generate_sales_copy( product_name=product_name, provider=provider, model=model, **kwargs ) if __name__ == "__main__": # 測試程式碼 logging.basicConfig(level=logging.INFO) service = AIProviderService() # 檢查狀態 print("檢查 AI 服務狀態...") status = service.get_status() print(f"Ollama: {'✅ 已連線' if status['ollama']['connected'] else '❌ 未連線'}") print(f"Gemini: {'✅ 已連線' if status['gemini']['connected'] else '❌ 未連線'}") print(f"預設提供者: {status['default_provider']}") print(f"推薦提供者: {status['recommended_provider']}") # 測試文案生成 if status['recommended_provider'] != 'none': print(f"\n使用 {status['recommended_provider']} 測試文案生成...") result = service.generate_sales_copy( product_name="玻尿酸保濕面膜", provider=status['recommended_provider'], trend_keywords=["換季保養", "敏感肌"], style="吸睛" ) if result.success: print(f"\n生成結果:\n{result.content}") print(f"\n提供者: {result.provider}") print(f"模型: {result.model}") print(f"耗時: {result.total_duration:.2f} 秒") if result.total_cost > 0: print(f"費用: ${result.total_cost:.6f} USD") else: print(f"生成失敗: {result.error}")