""" Dream Analysis Service — real Gemini-powered analysis of a user-submitted dream description: detected emotions, recurring symbols, likely themes, and a genuine interpretive summary. Mirrors the established Gemini client pattern from app/routes/commend/gemini_service.py (same lazy client, same env var naming convention, same error classification) rather than inventing a second way to talk to the same API. """ from __future__ import annotations import json import os import re from typing import Any, Dict, List, Optional, Tuple from google import genai from .logging_config import get_logger logger = get_logger(__name__) _client = None _MAX_DREAM_CHARS = 4000 _MIN_DREAM_CHARS = 10 def _get_client(): global _client if _client is not None: return _client api_key = os.getenv("DREAMS_GEMINI_API_KEY") or os.getenv("COMMEND_GEMINI_API_KEY") if not api_key: raise ValueError("DREAMS_GEMINI_API_KEY (or COMMEND_GEMINI_API_KEY) required") try: _client = genai.Client(api_key=api_key) logger.info("Gemini initialized for dream analysis") return _client except Exception as e: logger.error(f"Gemini init failed: {e}") raise ValueError(f"Gemini failed: {e}") _ALLOWED_EMOTIONS = { "joy", "fear", "anxiety", "sadness", "anger", "confusion", "peace", "excitement", "nostalgia", "wonder", "shame", "love", } def _extract_json(text: str) -> Optional[Dict[str, Any]]: """Gemini sometimes wraps JSON in a ```json fence despite instructions not to — strip that before parsing rather than fail the whole request.""" cleaned = text.strip() fence_match = re.match(r"^```(?:json)?\s*(.*?)\s*```$", cleaned, re.DOTALL) if fence_match: cleaned = fence_match.group(1) try: return json.loads(cleaned) except json.JSONDecodeError: return None def analyze_dream( dream_text: str, language: str = "English", ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: """ Analyze a dream description and return structured findings: { "emotions": ["fear", "wonder"], "themes": ["falling", "being chased"], "symbols": ["water", "flying"], "interpretation": "...", "lucidityIndicator": bool } Returns (result, None) on success or (None, error_message) on failure. """ text = dream_text.strip() if len(text) < _MIN_DREAM_CHARS: return None, "Dream description is too short to analyze." if len(text) > _MAX_DREAM_CHARS: text = text[:_MAX_DREAM_CHARS] try: client = _get_client() prompt = f"""You are a dream analyst. Analyze this dream description and respond with ONLY a raw JSON object (no markdown fence, no commentary) with this exact shape: {{"emotions": string[] (2-4 items from: joy, fear, anxiety, sadness, anger, confusion, peace, excitement, nostalgia, wonder, shame, love), "themes": string[] (1-3 short phrases describing narrative themes, e.g. "being chased", "falling", "losing teeth"), "symbols": string[] (1-4 concrete recurring symbols/objects mentioned or implied, e.g. "water", "flying", "a locked door"), "interpretation": string (2-3 sentences, a grounded psychological reading, not mystical fortune-telling, in {language}), "lucidityIndicator": boolean (true only if the dreamer describes realizing they were dreaming)}} Dream description: {text} JSON:""" response = client.models.generate_content( model="gemini-2.5-flash-lite", contents=prompt, ) raw = response.text.strip() if response.text else "" parsed = _extract_json(raw) if parsed is None: logger.error(f"Dream analysis returned non-JSON: {raw[:200]}") return None, "Analysis produced an unexpected response format." emotions = [e for e in parsed.get("emotions", []) if e in _ALLOWED_EMOTIONS][:4] themes = [str(t)[:60] for t in parsed.get("themes", [])][:3] symbols = [str(s)[:40] for s in parsed.get("symbols", [])][:4] interpretation = str(parsed.get("interpretation", ""))[:800] lucidity_indicator = bool(parsed.get("lucidityIndicator", False)) if not emotions or not interpretation: return None, "Analysis was incomplete. Please try again." return { "emotions": emotions, "themes": themes, "symbols": symbols, "interpretation": interpretation, "lucidityIndicator": lucidity_indicator, }, None except Exception as e: logger.error(f"Dream analysis error: {e}") err = str(e).lower() # Match "api key", "api_key", and "apikey" — the local ValueError # from _get_client() reads "..._api_key required" (underscore), and # Gemini's own errors tend to read "API key not valid" (space), so # a plain substring check on either form alone misses the other. if "api key" in err or "api_key" in err or "apikey" in err: return None, "Invalid API key" if "quota" in err or "limit" in err or "resource" in err: return None, "API quota exceeded" return None, f"Analysis failed: {str(e)}"