""" 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) # Format headlines for the prompt 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) # Extract the results array from various possible wrapper keys 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: # Try first list value in the dict for v in raw.values(): if isinstance(v, list) and len(v) > 0: results = v break elif isinstance(raw, list): results = raw # Normalize each result to ensure expected keys exist 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", "")), }) # Pad if model returned fewer than expected 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", }