"""Gemma 4 client via Google AI Studio (free tier). Set GOOGLE_API_KEY environment variable (get a key at https://aistudio.google.com). Free-tier quota covers hackathon-grade demo usage. """ import os import json import re from typing import Optional import google.generativeai as genai from PIL import Image from prompts import TRIAGE_SYSTEM, PLANNER_SYSTEM, PHOTO_JOURNAL_SYSTEM # --- Configuration ---------------------------------------------------------- _API_KEY = os.environ.get("GOOGLE_API_KEY") if not _API_KEY: raise RuntimeError( "GOOGLE_API_KEY is not set. Get a key at https://aistudio.google.com " "and set the env var (or put it in a .env file)." ) genai.configure(api_key=_API_KEY) # Gemma model selection. Rather than hard-coding handles (which rename across API # versions), we ASK the API which models the current key can use and pick the best # Gemma variant available. Hard-coded candidate list remains as a fallback in case # list_models() errors. _MODEL_CANDIDATES_FALLBACK = [ "gemma-3-27b-it", "gemma-3-12b-it", "gemma-3-4b-it", "gemma-3-1b-it", ] _model = None _model_name = None def _score_gemma(name: str) -> int: """Higher score = more preferred. Prefers Gemma 4, then 3; instruction-tuned; larger variants; multimodal handles.""" n = name.lower() if "gemma" not in n: return -1 score = 0 if "gemma-4" in n: score += 1000 elif "gemma-3" in n: score += 500 elif "gemma-2" in n: score += 200 if "-it" in n: score += 50 # instruction-tuned (chat) if "vision" in n: score += 30 # multimodal preferred # Prefer larger param count when available (rough heuristic via the number after 'gemma-N-') import re m = re.search(r"gemma-\d+-(\d+)b", n) if m: score += int(m.group(1)) # e.g. 27b adds 27 return score def _discover_model_name() -> Optional[str]: """Ask the Google AI Studio API which Gemma models this key can use.""" try: usable = [] for m in genai.list_models(): methods = getattr(m, "supported_generation_methods", []) or [] if "generateContent" not in methods: continue name = m.name.replace("models/", "") score = _score_gemma(name) if score > 0: usable.append((score, name)) if not usable: return None usable.sort(reverse=True) return usable[0][1] except Exception as e: print(f"[gemma_client] discovery failed: {type(e).__name__}: {e}") return None def _get_model(): global _model, _model_name if _model is not None: return _model # 1) Try API-side discovery discovered = _discover_model_name() if discovered: _model = genai.GenerativeModel(discovered) _model_name = discovered print(f"[gemma_client] discovered model: {discovered}") return _model # 2) Fall back to hard-coded candidates last_err = None for name in _MODEL_CANDIDATES_FALLBACK: try: m = genai.GenerativeModel(name) # Probe with a trivial call to confirm the handle works _ = m.generate_content("ok", generation_config=genai.GenerationConfig(max_output_tokens=1)) _model = m _model_name = name print(f"[gemma_client] fallback model: {name}") return m except Exception as e: last_err = e print(f"[gemma_client] fallback {name} failed: {type(e).__name__}") continue raise RuntimeError( f"No usable Gemma model found on your Google AI Studio account. " f"Last error: {last_err}. Run: " f"python -c \"import google.generativeai as g; g.configure(api_key='YOUR_KEY'); " f"[print(m.name) for m in g.list_models() if 'gemma' in m.name.lower()]\"" ) def model_name() -> str: _get_model() return _model_name or "(unloaded)" # --- JSON extraction -------------------------------------------------------- def _extract_json(raw: str) -> Optional[dict]: """Pull the first balanced {...} JSON object out of the model's reply.""" if not raw: return None raw = raw.strip() # Try markdown-fenced JSON first m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw, re.DOTALL) if m: cand = m.group(1) else: s, e = raw.find("{"), raw.rfind("}") if s == -1 or e <= s: return None cand = raw[s : e + 1] try: obj = json.loads(cand) return obj if isinstance(obj, dict) else None except json.JSONDecodeError: return None # --- Core call wrappers ----------------------------------------------------- def _generate(parts: list, system: str, max_tokens: int = 512) -> str: """Run one inference. `parts` is a list of strings or PIL images.""" model = _get_model() # Gemma via Google AI Studio doesn't support a separate system role in all paths, # so we prepend the system prompt to the user content. user_text = parts[0] if isinstance(parts[0], str) else "" other_parts = parts[1:] if isinstance(parts[0], str) else parts full = f"{system}\n\n[USER]\n{user_text}" contents = [full] + list(other_parts) response = model.generate_content( contents, generation_config=genai.GenerationConfig( temperature=0.0, max_output_tokens=max_tokens ), ) return response.text or "" def triage(user_text: str, history: Optional[list] = None) -> dict: """Phase-1 triage. Returns the parsed JSON dict (or a safe default on parse failure).""" history_blob = "" if history: history_blob = "\n[CONVERSATION SO FAR]\n" + "\n".join(history) + "\n[CURRENT TURN]\n" raw = _generate([f"{history_blob}{user_text}"], TRIAGE_SYSTEM, max_tokens=400) parsed = _extract_json(raw) if parsed is None: # Fail-safe: assume non-crisis, ask user to rephrase. Never crash the UI. return { "acknowledgment": "I'm here. Could you tell me a little more about what's going on?", "detected_signals": [], "likely_category": "unclear", "severity_signal": "low", "follow_up_question": "What feels most pressing right now?", "goal_hint": None, "crisis_flag": False, "_raw": raw, } return parsed def plan(triage_json: dict, conversation_summary: str) -> dict: user = ( f"TRIAGE={json.dumps(triage_json)}\n" f"CONVERSATION:\n{conversation_summary}" ) raw = _generate([user], PLANNER_SYSTEM, max_tokens=600) parsed = _extract_json(raw) or {"reasoning": "(parse fail)", "tool_calls": []} parsed.setdefault("tool_calls", []) return parsed def photo_journal(image: Image.Image, caption: str = "") -> dict: user_text = caption.strip() or "(no caption — describe what's meaningful in this image for wellbeing journaling)" raw = _generate([user_text, image], PHOTO_JOURNAL_SYSTEM, max_tokens=400) parsed = _extract_json(raw) if parsed is None: return { "summary": "Could not parse the model's response. The image may be unsupported.", "detected_text": "", "mood_score": 5, "key_themes": [], "connected_goal_hint": None, "crisis_flag": False, "_raw": raw, } return parsed