File size: 7,344 Bytes
5d2f635
 
1dece25
856dc82
1dece25
5d2f635
856dc82
5d2f635
a0c219b
1dece25
5d2f635
1dece25
856dc82
5d2f635
856dc82
708f080
 
1dece25
 
 
856dc82
 
5d2f635
856dc82
a0a8d76
5d2f635
856dc82
 
5d2f635
856dc82
708f080
856dc82
5d2f635
 
 
a0c219b
5d2f635
 
 
 
 
 
 
 
 
 
 
1dece25
 
5d2f635
 
1dece25
856dc82
4257404
5d2f635
 
 
 
856dc82
5d2f635
4257404
5d2f635
 
 
856dc82
 
5d2f635
 
 
 
fb0849f
5d2f635
 
 
 
 
 
4257404
5d2f635
6474a95
5d2f635
 
708f080
5d2f635
 
 
 
 
 
856dc82
5d2f635
 
 
 
 
 
856dc82
 
5d2f635
 
 
 
856dc82
5d2f635
 
856dc82
5d2f635
 
856dc82
5d2f635
3a209c3
 
856dc82
5d2f635
 
 
 
 
3a209c3
4257404
5d2f635
 
 
708f080
 
5d2f635
1dece25
54b93be
856dc82
1dece25
5d2f635
 
856dc82
1dece25
856dc82
5d2f635
856dc82
5d2f635
856dc82
5d2f635
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
708f080
1dece25
5d2f635
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# tools/sentiment_tool.py

import os
import json
import requests
from typing import Type, List, Any, Dict, Optional

from pydantic import BaseModel, Field
from crewai.tools import BaseTool
from openai import OpenAI


# -----------------------------
# Environment
# -----------------------------
SERPER_API_KEY = os.getenv("SERPER_API_KEY")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")

client = OpenAI(api_key=OPENAI_API_KEY)


# -----------------------------
# Input Schema
# -----------------------------
class SentimentInput(BaseModel):
    """Input schema for sentiment analysis tool."""
    query: str = Field(
        default="bitcoin",
        description="Cryptocurrency or asset to evaluate sentiment for.",
    )


# ===================================================================
#                  SENTIMENT TOOL (NEWS-ONLY VERSION)
# ===================================================================
class SentimentTool(BaseTool):
    """
    Fetches recent crypto news via Serper and produces aggregated sentiment
    using GPT-4.1 with:
        - sentiment: bullish / bearish / neutral
        - sentiment_strength: float [-1, 1]
        - confidence: float [0, 1]
        - themes: emergent topics
        - reasoning: summary explanation
        - news_headlines: titles used
    """

    name: str = "get_crypto_sentiment"
    description: str = (
        "Fetches crypto news via Serper and classifies sentiment with strength, "
        "confidence, themes, and explanation. News-only version."
    )
    args_schema: Type[BaseModel] = SentimentInput

    # -----------------------------------------------------
    # Fetch news (Serper)
    # -----------------------------------------------------
    def _fetch_news(self, query: str, max_results: int = 12) -> (List[str], Optional[str]):
        if not SERPER_API_KEY:
            return [], "SERPER_API_KEY missing"

        url = "https://google.serper.dev/news"
        headers = {"X-API-KEY": SERPER_API_KEY, "Content-Type": "application/json"}
        payload = {"q": f"{query} cryptocurrency", "num": max_results}

        try:
            resp = requests.post(url, headers=headers, json=payload, timeout=10)
            resp.raise_for_status()
            news_items = resp.json().get("news", []) or []
            titles = [n.get("title", "").strip() for n in news_items if n.get("title")]

            # Deduplicate while preserving order
            seen, unique = set(), []
            for t in titles:
                if t not in seen:
                    seen.add(t)
                    unique.append(t)

            return unique, None

        except Exception as e:
            return [], f"Serper error: {str(e)}"

    # -----------------------------------------------------
    # LLM Sentiment Aggregation
    # -----------------------------------------------------
    def _analyze_with_llm(self, coin: str, headlines: List[str]) -> Dict[str, Any]:

        if not headlines:
            return {
                "sentiment": "neutral",
                "sentiment_strength": 0.0,
                "confidence": 0.0,
                "reasoning": "No news available; defaulting to neutral.",
                "news_headlines": [],
                "themes": []
            }

        headlines_block = "\n".join(f"{i+1}. {h}" for i, h in enumerate(headlines))

        prompt = f"""
You are a professional crypto macro-sentiment analyst.

Analyze the following recent news headlines about "{coin}" and determine
aggregate sentiment.

Headlines:
{headlines_block}

Return STRICT JSON ONLY in this format:

{{
  "sentiment": "bullish" | "bearish" | "neutral",
  "sentiment_strength": number,   // -1.0 to +1.0
  "confidence": number,           // 0.0 to 1.0
  "reasoning": "short explanation",
  "news_headlines": [...],
  "themes": [...]
}}

Rules:
- Consider macro context, price action, regulatory tone, adoption, and risk sentiment.
- No extra text. JSON only.
"""

        try:
            completion = client.chat.completions.create(
                model="gpt-4.1",
                temperature=0.2,
                messages=[
                    {"role": "system", "content": "Return ONLY valid JSON. You are precise."},
                    {"role": "user", "content": prompt}
                ]
            )

            raw = completion.choices[0].message.content.strip()

            # Attempt direct JSON load
            try:
                parsed = json.loads(raw)
            except:
                # Try to extract JSON substring
                start, end = raw.find("{"), raw.rfind("}")
                if start == -1 or end == -1:
                    raise ValueError("No JSON found in model output.")
                parsed = json.loads(raw[start:end+1])

            # Validate sentiment
            sentiment = parsed.get("sentiment", "neutral").lower()
            if sentiment not in {"bullish", "bearish", "neutral"}:
                sentiment = "neutral"

            # Clip strength + confidence
            def clip(val, lo, hi, default):
                try:
                    v = float(val)
                    return max(lo, min(hi, v))
                except:
                    return default

            strength = clip(parsed.get("sentiment_strength"), -1.0, 1.0, 0.0)
            confidence = clip(parsed.get("confidence"), 0.0, 1.0, 0.0)

            themes = parsed.get("themes", [])
            if not isinstance(themes, list):
                themes = []

            used = parsed.get("news_headlines", headlines)
            if not isinstance(used, list) or not used:
                used = headlines

            return {
                "sentiment": sentiment,
                "sentiment_strength": strength,
                "confidence": confidence,
                "reasoning": parsed.get("reasoning", ""),
                "news_headlines": used,
                "themes": themes
            }

        except Exception as e:
            return {
                "sentiment": "neutral",
                "sentiment_strength": 0.0,
                "confidence": 0.0,
                "reasoning": f"LLM sentiment failure: {str(e)}",
                "news_headlines": headlines,
                "themes": []
            }

    # -----------------------------------------------------
    # Main Entrypoint
    # -----------------------------------------------------
    def _run(self, query: str = "bitcoin") -> Dict[str, Any]:

        if not OPENAI_API_KEY:
            return {
                "sentiment": "neutral",
                "sentiment_strength": 0.0,
                "confidence": 0.0,
                "reasoning": "OPENAI_API_KEY missing; neutral fallback.",
                "news_headlines": [],
                "themes": []
            }

        # Fetch news
        headlines, news_error = self._fetch_news(query)

        if news_error and not headlines:
            return {
                "sentiment": "neutral",
                "sentiment_strength": 0.0,
                "confidence": 0.0,
                "reasoning": f"No news available: {news_error}",
                "news_headlines": [],
                "themes": []
            }

        # Analyze
        sentiment = self._analyze_with_llm(query, headlines)

        sentiment["news_error"] = news_error
        return sentiment