"""ChainShift Gen3 API Client - Nudge Detection Focus. Domain methods are in mixin files: - api_client_sentiment.py: SentimentApiMixin (Gen3, Verification, Keyword) - api_client_reports.py: ReportsApiMixin (Reports, Action Items) - api_client_hierarchy.py: HierarchyApiMixin (Analysis Jobs, Hierarchy) """ import os from typing import Any from urllib.parse import urlparse import requests from dotenv import load_dotenv from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from core.api_client_sentiment import SentimentApiMixin from core.api_client_reports import ReportsApiMixin from core.api_client_hierarchy import HierarchyApiMixin load_dotenv() BASE_URL = os.getenv( "CHAINSHIFT_API_URL", "https://chainshift-service-api.vercel.app" ) API_KEY = os.getenv("CHAINSHIFT_API_KEY", "") def _create_session() -> requests.Session: """Create requests session with retry for transient errors.""" session = requests.Session() retry = Retry( total=3, backoff_factor=0.5, status_forcelist=[502, 503, 504], ) session.mount("https://", HTTPAdapter(max_retries=retry)) session.mount("http://", HTTPAdapter(max_retries=retry)) return session class ChainShiftClient(SentimentApiMixin, ReportsApiMixin, HierarchyApiMixin): """Gen3 API client for ChainShift Nudge Detection.""" def __init__( self, api_key: str | None = None, access_token: str | None = None, base_url: str | None = None, ): self.api_key = api_key or API_KEY self.base_url = base_url or BASE_URL self.headers = {"X-API-Key": self.api_key} if self.api_key else {} self._session = _create_session() def set_api_key(self, api_key: str): """Set or update API key.""" self.api_key = api_key self.headers = {"X-API-Key": self.api_key} def _get(self, endpoint: str, params: dict | None = None) -> dict[str, Any]: """Make GET request to API.""" url = f"{self.base_url}{endpoint}" response = self._session.get(url, headers=self.headers, params=params, timeout=120) response.raise_for_status() return response.json() def _post(self, endpoint: str, data: dict | None = None) -> dict[str, Any]: """Make POST request to API.""" url = f"{self.base_url}{endpoint}" response = self._session.post(url, headers=self.headers, json=data, timeout=300) response.raise_for_status() return response.json() def _patch(self, endpoint: str, data: dict | None = None) -> dict[str, Any]: """Make PATCH request to API.""" url = f"{self.base_url}{endpoint}" response = self._session.patch(url, headers=self.headers, json=data, timeout=120) response.raise_for_status() return response.json() def _delete(self, endpoint: str) -> dict[str, Any]: """Make DELETE request to API.""" url = f"{self.base_url}{endpoint}" response = self._session.delete(url, headers=self.headers, timeout=120) response.raise_for_status() return response.json() # ======================================================================== # Campaign APIs # ======================================================================== def get_campaigns(self, page: int = 1, page_size: int = 100) -> dict: """Get list of campaigns.""" return self._get("/api/v1/campaigns", {"page": page, "page_size": page_size}) def get_campaign(self, campaign_id: int) -> dict: """Get campaign details.""" return self._get(f"/api/v1/campaigns/{campaign_id}") def get_campaign_brands(self, campaign_id: int) -> list[dict]: """Get brands for a campaign.""" resp = self._get(f"/api/v1/campaigns/{campaign_id}/brands") return (resp or {}).get("data") or [] # ======================================================================== # Utility Methods # ======================================================================== @staticmethod def extract_domain(url: str) -> str: """Extract domain from URL.""" try: parsed = urlparse(url) return parsed.netloc or url except Exception: return url @staticmethod def aggregate_citation_domains(candidates: list[dict]) -> dict[str, int]: """Aggregate citation URLs by domain. Returns: {domain: count} """ domain_counts: dict[str, int] = {} for candidate in candidates: urls = candidate.get("citation_urls", []) or [] for url in urls: domain = ChainShiftClient.extract_domain(url) if domain: domain_counts[domain] = domain_counts.get(domain, 0) + 1 return dict(sorted(domain_counts.items(), key=lambda x: x[1], reverse=True)) @staticmethod def calculate_risk_score(tier_stats: dict) -> float: """Calculate risk score (0-100) based on confidence tiers. Formula: (HIGH * 1.0 + MEDIUM * 0.5 + LOW * 0.2) / total * 100 """ high = tier_stats.get("HIGH", 0) medium = tier_stats.get("MEDIUM", 0) low = tier_stats.get("LOW", 0) total = high + medium + low if total == 0: return 0.0 weighted = high * 1.0 + medium * 0.5 + low * 0.2 return min(100.0, (weighted / total) * 100)