196 lines
6.7 KiB
Python
196 lines
6.7 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
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."
|
|
)
|
|
|
|
ORACLE_SYSTEM = (
|
|
"Du bist das wöchentliche KI-Orakel einer ADHS-Medikamenten-App. "
|
|
"Passiv-aggressiv, trocken, max 4 Sätze auf Deutsch. Keine medizinischen Ratschläge."
|
|
)
|
|
|
|
|
|
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:
|
|
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": 120,
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data["choices"][0]["message"]["content"].strip()
|
|
except Exception:
|
|
logger.exception("OpenRouter request failed")
|
|
return None
|
|
|
|
|
|
def _motivation_key(day: str) -> str:
|
|
return f"motivation:v2:{day}"
|
|
|
|
|
|
def _oracle_key(week: str) -> str:
|
|
return f"oracle:v2:{week}"
|
|
|
|
|
|
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 get_roast_of_the_day(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]:
|
|
return await get_daily_motivation(db, tz)
|
|
|
|
|
|
async def generate_oracle(
|
|
db: aiosqlite.Connection,
|
|
tz: ZoneInfo,
|
|
week: str | None = None,
|
|
*,
|
|
force: bool = False,
|
|
) -> dict[str, Any]:
|
|
week = week or datetime.now(tz).strftime("%Y-W%W")
|
|
key = _oracle_key(week)
|
|
if not force:
|
|
cached = await _get_cache(db, key)
|
|
if cached:
|
|
return {"text": cached, "source": "cache", "week": week}
|
|
|
|
stats = await compute_stats(db)
|
|
days_active = stats["days_active"]
|
|
window = stats["compliance_window_days"]
|
|
young_app = days_active < 90
|
|
context = (
|
|
f"App aktiv seit {days_active} Tag(en), erste Einnahme am {stats['first_day']}. "
|
|
if young_app
|
|
else ""
|
|
)
|
|
caveat = (
|
|
"Die App ist noch jung — lange Streaks oder 90-Tage-Compliance sind noch nicht realistisch erreichbar. "
|
|
"Bewerte nur die verfügbaren Daten, sei fair aber sarkastisch. "
|
|
if young_app
|
|
else ""
|
|
)
|
|
prompt = (
|
|
f"Wöchentlicher Orakel-Report. {context}{caveat}"
|
|
f"Streak: {stats['streak']} Tage. "
|
|
f"Compliance über {window} Tag(e): {stats['compliance_percent']}%. "
|
|
f"Genommen gesamt: {stats['total_taken']}."
|
|
)
|
|
text = await _call_openrouter(prompt, system=ORACLE_SYSTEM)
|
|
if not text:
|
|
if young_app:
|
|
text = (
|
|
f"Tag {days_active} deiner Medis-Karriere. "
|
|
f"Streak: {stats['streak']}. Compliance ({window} Tage): {stats['compliance_percent']}%. "
|
|
"Das Orakel ist beeindruckt — oder gelangweilt. Grenzwertig."
|
|
)
|
|
else:
|
|
text = pick("streak", streak=stats["streak"]) + f" Compliance: {stats['compliance_percent']}%."
|
|
source = "fallback"
|
|
else:
|
|
source = "ai"
|
|
await _set_cache(db, key, text)
|
|
logger.info("Generated oracle for %s (%s)", week, source)
|
|
return {"text": text, "source": source, "week": week}
|
|
|
|
|
|
async def get_oracle(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]:
|
|
week = datetime.now(tz).strftime("%Y-W%W")
|
|
cached = await _get_cache(db, _oracle_key(week))
|
|
if cached:
|
|
return {"text": cached, "source": "cache", "week": week}
|
|
return {
|
|
"text": "Das Orakel bereitet sich vor. Schau später nochmal rein.",
|
|
"source": "pending",
|
|
"week": week,
|
|
}
|