from __future__ import annotations import logging from datetime import datetime, timedelta from typing import Any from zoneinfo import ZoneInfo import aiosqlite import httpx from server.db import utc_now_iso from server.messages import pick from server.settings import settings from server.stats import compute_stats logger = logging.getLogger(__name__) MOTIVATION_SYSTEM = ( "Du schreibst Tages-Motivationssprüche für 'ADHD live, laugh, toaster bath motivational'. " "Stil: dark humor, Sarkasmus, passiv-aggressiv, kreativ-motivierend — nie gemein, nie medizinisch. " "Genau ein Satz auf Deutsch, max 25 Wörter. Keine Anführungszeichen um den Spruch." ) ROAST_SYSTEM = ( "Du bist ein sarkastischer, dark-humor Medikamenten-Coach für jemanden mit ADHS. " "Kurz (max 2 Sätze), deutsch, witzig aber nicht gemein. Keine medizinischen Ratschläge. " "Keine Anführungszeichen um den Spruch. Wiederhole keine Formulierungen oder Ideen aus der Historie." ) ROAST_HISTORY_DAYS = 14 async def _get_cache(db: aiosqlite.Connection, key: str) -> str | None: cur = await db.execute("SELECT content FROM ai_cache WHERE key = ?", (key,)) row = await cur.fetchone() await cur.close() return row[0] if row else None async def _set_cache(db: aiosqlite.Connection, key: str, content: str) -> None: await db.execute( "INSERT INTO ai_cache (key, content, created_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET content=excluded.content, created_at=excluded.created_at", (key, content, utc_now_iso()), ) await db.commit() async def _call_openrouter(prompt: str, *, system: str) -> str | None: if not settings.openrouter_api_key: logger.warning("OPENROUTER_API_KEY not set") return None try: async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post( "https://openrouter.ai/api/v1/chat/completions", headers={ "Authorization": f"Bearer {settings.openrouter_api_key}", "HTTP-Referer": "https://medis.schwenk.online", "X-Title": "TakeYourMeds", }, json={ "model": settings.openrouter_model, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": prompt}, ], "max_tokens": 512, }, ) resp.raise_for_status() data = resp.json() if data.get("error"): logger.error("OpenRouter API error (model=%s): %s", settings.openrouter_model, data["error"]) return None choices = data.get("choices") or [] if not choices: logger.error("OpenRouter returned no choices (model=%s): %s", settings.openrouter_model, data) return None message = choices[0].get("message") or {} content = message.get("content") if not content or not str(content).strip(): logger.error( "OpenRouter empty content (model=%s, finish_reason=%s): %s", settings.openrouter_model, choices[0].get("finish_reason"), data, ) return None return str(content).strip() except Exception: logger.exception("OpenRouter request failed (model=%s)", settings.openrouter_model) return None def _motivation_key(day: str) -> str: return f"motivation:v2:{day}" def _roast_key(day: str) -> str: return f"roast:{day}" async def _recent_roasts( db: aiosqlite.Connection, *, before_day: str, days: int = ROAST_HISTORY_DAYS, ) -> list[str]: end = datetime.strptime(before_day, "%Y-%m-%d").date() roasts: list[str] = [] for offset in range(1, days + 1): day = (end - timedelta(days=offset)).isoformat() text = await _get_cache(db, _roast_key(day)) if text: roasts.append(text) return roasts async def _cached_motivation(db: aiosqlite.Connection, day: str) -> str | None: cached = await _get_cache(db, _motivation_key(day)) if cached: return cached return await _get_cache(db, f"roast:{day}") async def generate_daily_motivation( db: aiosqlite.Connection, tz: ZoneInfo, day: str | None = None, *, force: bool = False, ) -> dict[str, Any]: day = day or datetime.now(tz).strftime("%Y-%m-%d") key = _motivation_key(day) if not force: cached = await _cached_motivation(db, day) if cached: return {"text": cached, "source": "cache", "day": day} stats = await compute_stats(db) prompt = ( "Schreibe den Motivationsspruch für heute. " f"Streak: {stats['streak']} Tage. " f"Compliance über {stats['compliance_window_days']} aktive Tage: {stats['compliance_percent']}%. " "Beispiel-Stil: Dein Gehirn ist nicht kaputt. Es läuft nur auf einem anderen Betriebssystem — ohne Support." ) text = await _call_openrouter(prompt, system=MOTIVATION_SYSTEM) if not text: text = pick("motivation_fallback") source = "fallback" else: source = "ai" await _set_cache(db, key, text) logger.info("Generated daily motivation for %s (%s)", day, source) return {"text": text, "source": source, "day": day} async def get_daily_motivation(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]: day = datetime.now(tz).strftime("%Y-%m-%d") cached = await _cached_motivation(db, day) if cached: return {"text": cached, "source": "cache", "day": day} return {"text": pick("motivation_fallback"), "source": "fallback", "day": day} async def generate_roast_of_the_day( db: aiosqlite.Connection, tz: ZoneInfo, day: str | None = None, *, force: bool = False, ) -> dict[str, Any]: day = day or datetime.now(tz).strftime("%Y-%m-%d") key = _roast_key(day) if not force: cached = await _get_cache(db, key) if cached: return {"text": cached, "source": "cache", "day": day} stats = await compute_stats(db) recent = await _recent_roasts(db, before_day=day) history = "" if recent: lines = "\n".join(f"- {text}" for text in recent) history = ( f"\n\nDiese Roasts der letzten {ROAST_HISTORY_DAYS} Tage wurden bereits verwendet " f"(nicht wiederholen, neue Idee):\n{lines}" ) prompt = ( "Schreibe den Roast-of-the-Day. " f"Streak: {stats['streak']} Tage. " f"Compliance über {stats['compliance_window_days']} aktive Tage: {stats['compliance_percent']}%. " f"Ein sarkastischer Spruch im dark-humor Medikamenten-Coach Stil.{history}" ) text = await _call_openrouter(prompt, system=ROAST_SYSTEM) if not text: text = pick("roast_fallback") source = "fallback" else: source = "ai" await _set_cache(db, key, text) logger.info("Generated roast of the day for %s (%s)", day, source) return {"text": text, "source": source, "day": day} async def get_roast_of_the_day(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]: day = datetime.now(tz).strftime("%Y-%m-%d") cached = await _get_cache(db, _roast_key(day)) if cached: return {"text": cached, "source": "cache", "day": day} return {"text": pick("roast_fallback"), "source": "fallback", "day": day}