| """ |
| Sentiment Analyzer — Batched target-level financial sentiment via OpenAI gpt-4o-mini. |
| Processes headlines in a single API call for maximum token efficiency. |
| """ |
| import json |
| import logging |
| import os |
|
|
| from openai import OpenAI |
|
|
| logger = logging.getLogger(__name__) |
|
|
| SENTIMENT_SYSTEM_PROMPT = """You are a financial sentiment classifier. You will receive numbered headlines. |
| Return a JSON object with key "results" containing an array. Each element must have these exact keys: |
| - "idx": the headline number (integer) |
| - "overall_sentiment": "positive", "negative", or "neutral" |
| - "confidence": float 0.0 to 1.0 |
| - "targets": array of {"name": string, "sentiment": string} for specific companies mentioned |
| - "impact_summary": one sentence, max 20 words, explaining financial impact |
| |
| Distinguish target-level sentiment. Example: |
| "Netflix dips despite Apple's surge" → Netflix=negative, Apple=positive |
| |
| You MUST return exactly one result per headline, in order.""" |
|
|
| SENTIMENT_USER_TEMPLATE = """Analyze these {count} financial headlines: |
| |
| {headlines} |
| |
| Return: {{"results": [{{"idx": 0, "overall_sentiment": "positive", "confidence": 0.8, "targets": [{{"name": "AAPL", "sentiment": "positive"}}], "impact_summary": "Apple gains on strong earnings"}}]}}""" |
|
|
|
|
| def analyze_sentiment_batch(headlines: list[dict]) -> list[dict]: |
| """ |
| Analyze sentiment for a batch of news items using OpenAI. |
| Each item should have at least 'title' and optionally 'summary'. |
| Returns list of sentiment results in same order as input. |
| """ |
| if not headlines: |
| return [] |
|
|
| api_key = os.getenv("OPENAI_API_KEY", "") |
| if not api_key: |
| logger.warning("No OPENAI_API_KEY set, returning neutral sentiment for all") |
| return [_neutral("No API key") for _ in headlines] |
|
|
| model = os.getenv("OPENAI_MODEL", "gpt-4o-mini") |
| client = OpenAI(api_key=api_key) |
|
|
| |
| formatted = "\n".join( |
| f"[{i}] {item['title']}" |
| + (f" — {item.get('summary', '')[:80]}" if item.get("summary") else "") |
| for i, item in enumerate(headlines) |
| ) |
|
|
| try: |
| response = client.chat.completions.create( |
| model=model, |
| messages=[ |
| {"role": "system", "content": SENTIMENT_SYSTEM_PROMPT}, |
| {"role": "user", "content": SENTIMENT_USER_TEMPLATE.format( |
| count=len(headlines), headlines=formatted |
| )}, |
| ], |
| temperature=0.1, |
| max_tokens=max(1500, len(headlines) * 80), |
| response_format={"type": "json_object"}, |
| ) |
|
|
| content = response.choices[0].message.content or "{}" |
| logger.debug(f"Raw sentiment response: {content[:500]}") |
|
|
| raw = json.loads(content) |
|
|
| |
| results = [] |
| if isinstance(raw, dict): |
| for key in ("results", "headlines", "analysis", "data", "sentiments"): |
| if key in raw and isinstance(raw[key], list): |
| results = raw[key] |
| break |
| if not results: |
| |
| for v in raw.values(): |
| if isinstance(v, list) and len(v) > 0: |
| results = v |
| break |
| elif isinstance(raw, list): |
| results = raw |
|
|
| |
| normalized = [] |
| for r in results: |
| if not isinstance(r, dict): |
| continue |
| normalized.append({ |
| "overall_sentiment": r.get("overall_sentiment", r.get("sentiment", "neutral")), |
| "confidence": float(r.get("confidence", 0.5)), |
| "targets": r.get("targets", []), |
| "impact_summary": r.get("impact_summary", r.get("summary", "")), |
| }) |
|
|
| |
| while len(normalized) < len(headlines): |
| normalized.append(_neutral("Model returned fewer results")) |
|
|
| tokens = response.usage.total_tokens if response.usage else "?" |
| logger.info(f"Sentiment analyzed: {len(headlines)} headlines, " |
| f"{len(results)} results parsed (tokens: {tokens})") |
| return normalized[:len(headlines)] |
|
|
| except Exception as e: |
| logger.error(f"Sentiment analysis error: {e}") |
| return [_neutral(f"Error: {str(e)[:50]}") for _ in headlines] |
|
|
|
|
| def _neutral(reason: str = "") -> dict: |
| return { |
| "overall_sentiment": "neutral", |
| "confidence": 0.3, |
| "targets": [], |
| "impact_summary": reason or "Unable to analyze", |
| } |
|
|