import asyncio import logging import os from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import UTC, datetime from enum import Enum from typing import Any import httpx from app.services.api_key_manager import APIKeyManager logger = logging.getLogger(__name__) _MAX_RETRIES = 3 _RETRYABLE_STATUS = {429, 500, 502, 503} class EngineType(str, Enum): CHATGPT = "chatgpt" PERPLEXITY = "perplexity" KIMI = "kimi" WENXIN = "wenxin" DOUBAO = "doubao" DEEPSEEK = "deepseek" QWEN = "qwen" GEMINI = "gemini" YUANBAO = "yuanbao" @dataclass class CitationInfo: source_url: str | None source_title: str | None citation_context: str confidence: float position: int @dataclass class AIQueryResult: engine_type: EngineType query: str raw_response: str citations: list[CitationInfo] has_brand_citation: bool has_competitor_citation: bool brand_context: str | None competitor_contexts: list[str] response_time_ms: int timestamp: datetime metadata: dict[str, Any] = field(default_factory=dict) input_tokens: int = 0 output_tokens: int = 0 @property def total_tokens(self) -> int: return self.input_tokens + self.output_tokens class AIEngineAdapter(ABC): def __init__( self, api_key: str | None = None, rate_limiter=None, proxy: str | None = None, key_manager: APIKeyManager | None = None, user_id: str | None = None, ): self._key_manager = key_manager self._user_id = user_id self.api_key = self._resolve_api_key(api_key, key_manager, user_id) self.rate_limiter = rate_limiter self.proxy = proxy or self._load_proxy() self._client: httpx.AsyncClient | None = None def _load_proxy(self) -> str | None: return os.getenv("HTTPS_PROXY") or os.getenv("https_proxy") def _resolve_api_key( self, direct_key: str | None, key_manager: APIKeyManager | None, user_id: str | None, ) -> str: if direct_key and direct_key.strip(): return direct_key if key_manager: key = key_manager.get_key(self.get_engine_type().value, user_id=user_id) if key: return key return self._get_env_key() or "" @abstractmethod def _get_env_key(self) -> str | None: pass @abstractmethod async def query( self, query: str, brand_name: str, competitor_names: list[str] | None = None, ) -> AIQueryResult: pass @abstractmethod def get_engine_type(self) -> EngineType: pass async def _get_client(self) -> httpx.AsyncClient: if self._client is None or self._client.is_closed: self._client = httpx.AsyncClient(**self._client_kwargs()) return self._client def _client_kwargs(self) -> dict[str, Any]: kwargs: dict[str, Any] = { "timeout": httpx.Timeout(connect=10.0, read=120.0, write=10.0, pool=10.0), } if self.proxy: kwargs["proxy"] = self.proxy return kwargs def _detect_citations( self, response: str, brand_name: str, competitor_names: list[str] | None, ) -> tuple[bool, bool, str | None, list[str]]: has_brand = brand_name.lower() in response.lower() brand_context = None if has_brand: idx = response.lower().find(brand_name.lower()) start = max(0, idx - 100) end = min(len(response), idx + len(brand_name) + 100) brand_context = response[start:end] has_competitor = False competitor_contexts = [] if competitor_names: for name in competitor_names: if name.lower() in response.lower(): has_competitor = True idx = response.lower().find(name.lower()) start = max(0, idx - 100) end = min(len(response), idx + len(name) + 100) competitor_contexts.append(response[start:end]) return has_brand, has_competitor, brand_context, competitor_contexts async def _request_with_retry(self, payload: dict) -> dict: if self.rate_limiter: await self.rate_limiter.acquire() client = await self._get_client() engine_name = self.get_engine_type().value last_error: Exception | None = None for attempt in range(_MAX_RETRIES): try: response = await client.post(self._endpoint, json=payload) if response.status_code == 200: return response.json() if response.status_code in _RETRYABLE_STATUS: retry_after = response.headers.get("retry-after") wait = float(retry_after) if retry_after else 2**attempt logger.warning( f"[{engine_name}] HTTP {response.status_code}, " f"retry {attempt + 1}/{_MAX_RETRIES} in {wait:.1f}s" ) last_error = Exception( f"HTTP {response.status_code}: {response.text[:300]}" ) await asyncio.sleep(wait) continue raise Exception( f"HTTP {response.status_code}: {response.text[:300]}" ) except httpx.TransportError as exc: logger.warning( f"[{engine_name}] Transport error: {exc}, " f"retry {attempt + 1}/{_MAX_RETRIES}" ) last_error = Exception(f"Network error: {exc}") await asyncio.sleep(2**attempt) continue raise last_error or Exception("Max retries exceeded") async def close(self) -> None: if self._client: await self._client.aclose() async def __aenter__(self) -> "AIEngineAdapter": return self async def __aexit__(self, *exc) -> None: await self.close()