import os import json import re from typing import Dict, Any try: from openai import OpenAI except ImportError: OpenAI = None try: from google import genai except ImportError: genai = None class NarrativeService: def __init__(self, model_name: str = "gpt-5-mini-2025-08-07"): self.model_name = model_name self.provider = self._detect_provider() self.client = self._init_client() def _detect_provider(self) -> str: openai_key = os.getenv("OPENAI_API_KEY") or os.getenv("OPENAI_APIKEY") gemini_key = os.getenv("GEMINI_API_KEY") if self.model_name.startswith("gpt") and openai_key and OpenAI: return "openai" elif gemini_key and genai: return "gemini" elif openai_key and OpenAI: return "openai" elif gemini_key and genai: return "gemini" return "none" def _init_client(self): if self.provider == "openai": api_key = os.getenv("OPENAI_API_KEY") or os.getenv("OPENAI_APIKEY") return OpenAI(api_key=api_key) elif self.provider == "gemini": api_key = os.getenv("GEMINI_API_KEY") return genai.Client(api_key=api_key) return None def generate_full_narrative(self, stats_json: Dict[str, Any]) -> Dict[str, Any]: if not self.client: print("WARNING: No LLM client available") return self._get_fallback_narrative() clean_stats = self._shape_payload(stats_json) prompt = self._build_prompt(clean_stats) try: if self.provider == "openai": return self._call_openai(prompt) elif self.provider == "gemini": return self._call_gemini(prompt) except Exception as e: print(f"LLM Generation Error: {e}") return self._get_fallback_narrative() return self._get_fallback_narrative() def generate_playlist_theme(self, listening_data: Dict[str, Any]) -> Dict[str, Any]: """Generate playlist theme based on daily listening patterns.""" if not self.client: return self._get_fallback_theme() prompt = self._build_theme_prompt(listening_data) try: if self.provider == "openai": return self._call_openai_for_theme(prompt) elif self.provider == "gemini": return self._call_gemini_for_theme(prompt) except Exception as e: print(f"Theme generation error: {e}") return self._get_fallback_theme() return self._get_fallback_theme() def _call_openai_for_theme(self, prompt: str) -> Dict[str, Any]: response = self.client.chat.completions.create( model=self.model_name, messages=[ { "role": "system", "content": "You are a specialized music curator. Output only valid JSON.", }, {"role": "user", "content": prompt}, ], response_format={"type": "json_object"}, ) return self._clean_and_parse_json(response.choices[0].message.content) def _call_gemini_for_theme(self, prompt: str) -> Dict[str, Any]: response = self.client.models.generate_content( model=self.model_name, contents=prompt, config=genai.types.GenerateContentConfig( response_mime_type="application/json" ), ) return self._clean_and_parse_json(response.text) def _build_theme_prompt(self, data: Dict[str, Any]) -> str: return f"""Analyze this listening data from the last 6 hours and curate a specific "themed" playlist. **DATA:** - Peak hour: {data.get("peak_hour")} - Avg energy: {data.get("avg_energy"):.2f} - Avg valence: {data.get("avg_valence"):.2f} - Top artists: {", ".join([a["name"] for a in data.get("top_artists", [])])} - Total plays: {data.get("total_plays")} **RULES:** 1. Create a "theme_name" (e.g. "Morning Coffee Jazz", "Midnight Deep Work"). 2. Provide a "description" (2-3 sentences explaining why). 3. Identify 10-15 "curated_tracks" (song names only) that fit this vibe and the artists listed. 4. Return ONLY valid JSON. **REQUIRED JSON:** {{ "theme_name": "String", "description": "String", "curated_tracks": ["Track 1", "Track 2", ...] }}""" def _get_fallback_theme(self) -> Dict[str, Any]: return { "theme_name": "Daily Mix", "description": "A curated mix of your recent favorites.", "curated_tracks": [], } def _call_openai(self, prompt: str) -> Dict[str, Any]: response = self.client.chat.completions.create( model=self.model_name, messages=[ { "role": "system", "content": "You are a witty music critic. Output only valid JSON.", }, {"role": "user", "content": prompt}, ], response_format={"type": "json_object"}, max_completion_tokens=4000, ) return self._clean_and_parse_json(response.choices[0].message.content) def _call_gemini(self, prompt: str) -> Dict[str, Any]: response = self.client.models.generate_content( model=self.model_name, contents=prompt, config=genai.types.GenerateContentConfig( response_mime_type="application/json" ), ) return self._clean_and_parse_json(response.text) def _build_prompt(self, clean_stats: Dict[str, Any]) -> str: volume = clean_stats.get("volume", {}) concentration = volume.get("concentration", {}) time_habits = clean_stats.get("time_habits", {}) vibe = clean_stats.get("vibe", {}) peak_hour = time_habits.get("peak_hour") if isinstance(peak_hour, int): peak_listening = f"{peak_hour}:00" else: peak_listening = peak_hour or "N/A" concentration_score = ( round(concentration.get("hhi", 0), 3) if concentration and concentration.get("hhi") is not None else "N/A" ) playlist_diversity = ( round(1 - concentration.get("hhi", 0), 3) if concentration and concentration.get("hhi") is not None else "N/A" ) avg_energy = vibe.get("avg_energy", 0) avg_valence = vibe.get("avg_valence", 0) top_artists = volume.get("top_artists", []) top_artists_str = ", ".join(top_artists) if top_artists else "N/A" era_label = clean_stats.get("era", {}).get("musical_age", "N/A") return f"""Analyze this Spotify listening data and generate a personalized report. **RULES:** 1. NO mental health diagnoses. Use behavioral descriptors only. 2. Be specific - reference actual metrics from the data. 3. Be playful but not cruel. 4. Return ONLY valid JSON. **LISTENING HIGHLIGHTS:** - Peak listening: {peak_listening} - Concentration score: {concentration_score} - Playlist diversity: {playlist_diversity} - Average energy: {avg_energy:.2f} - Average valence: {avg_valence:.2f} - Top artists: {top_artists_str} **DATA:** {json.dumps(clean_stats, indent=2)} **REQUIRED JSON:** {{ "vibe_check_short": "1-2 sentence hook for the hero banner.", "vibe_check": "2-3 paragraphs describing their overall listening personality.", "patterns": ["Observation 1", "Observation 2", "Observation 3"], "persona": "A creative label (e.g., 'The Genre Chameleon').", "era_insight": "Comment on Musical Age ({era_label}).", "roast": "1-2 sentence playful roast.", "comparison": "Compare to previous period if data exists." }}""" def _shape_payload(self, stats: Dict[str, Any]) -> Dict[str, Any]: s = stats.copy() if "volume" in s: volume_copy = { k: v for k, v in s["volume"].items() if k not in ["top_tracks", "top_artists", "top_albums", "top_genres"] } volume_copy["top_tracks"] = [ t["name"] for t in stats["volume"].get("top_tracks", [])[:5] ] volume_copy["top_artists"] = [ a["name"] for a in stats["volume"].get("top_artists", [])[:5] ] volume_copy["top_genres"] = [ g["name"] for g in stats["volume"].get("top_genres", [])[:5] ] s["volume"] = volume_copy if "time_habits" in s: s["time_habits"] = { k: v for k, v in s["time_habits"].items() if k != "heatmap" } if "sessions" in s: s["sessions"] = { k: v for k, v in s["sessions"].items() if k != "session_list" } return s def _clean_and_parse_json(self, raw_text: str) -> Dict[str, Any]: try: return json.loads(raw_text) except json.JSONDecodeError: pass try: match = re.search(r"\{.*\}", raw_text, re.DOTALL) if match: return json.loads(match.group(0)) except: pass return self._get_fallback_narrative() def _get_fallback_narrative(self) -> Dict[str, Any]: return { "vibe_check_short": "Your taste is... interesting.", "vibe_check": "Data processing error. You're too mysterious to analyze right now.", "patterns": [], "persona": "The Enigma", "era_insight": "Time is a flat circle.", "roast": "You broke the machine. Congratulations.", "comparison": "N/A", }