File size: 5,461 Bytes
ef78361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
"""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)