File size: 6,255 Bytes
e49c006
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a427438
 
 
 
e49c006
 
 
 
 
 
 
 
a427438
e49c006
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a427438
 
 
 
 
 
 
 
 
 
 
 
 
e49c006
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a427438
 
e49c006
a427438
e49c006
 
 
 
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
"""
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

# Tried in order; a model Google has retired falls through to the next one.
_MODELS = [m for m in (os.getenv("DREAMS_GEMINI_MODEL"), "gemini-2.5-flash-lite", "gemini-2.5-flash") if m]
_MODEL_GONE = ("not found", "no longer available", "deprecated", "is not supported", "404")


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("Dream analysis is not configured: set DREAMS_GEMINI_API_KEY or COMMEND_GEMINI_API_KEY")

    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 = None
        last_error: Optional[Exception] = None
        for model in _MODELS:
            try:
                response = client.models.generate_content(model=model, contents=prompt)
                break
            except Exception as e:  # noqa: BLE001 - classified below
                last_error = e
                if not any(marker in str(e).lower() for marker in _MODEL_GONE):
                    raise
                logger.warning(f"Gemini model {model} unavailable, trying the next one: {e}")
        if response is None:
            raise last_error or RuntimeError("no Gemini model answered")

        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 "not configured" in err:
            return None, "Dream analysis is not configured"
        if "api key" in err or "api_key" in err or "apikey" in err:
            logger.error("Gemini refused the configured API key; replace DREAMS_GEMINI_API_KEY / COMMEND_GEMINI_API_KEY")
            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)}"