Compare commits

...

2 Commits

Author SHA1 Message Date
Frank Schwenk 24ac7f2f48 feat: restore roast-of-the-day with 14-day prompt history
Separate roast generation from motivation again and pass cached roasts
from the last 14 days into the OpenRouter prompt to reduce repetition.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 08:45:09 +02:00
Frank Schwenk c59c3069c4 refactor: remove KI-Orakel from history page and backend
Drop oracle UI, API endpoint, scheduled generation, and related docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 08:32:28 +02:00
10 changed files with 65 additions and 115 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ VAPID_PRIVATE_KEY=
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_CLAIMS_EMAIL=mailto:admin@schwenk.online VAPID_CLAIMS_EMAIL=mailto:admin@schwenk.online
# OpenRouter (optional — Roast-of-the-Day + KI-Orakel) # OpenRouter (optional — Roast-of-the-Day)
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
OPENROUTER_MODEL=google/gemini-2.0-flash-001 OPENROUTER_MODEL=google/gemini-2.0-flash-001
+2 -4
View File
@@ -19,7 +19,6 @@ Persönliche Medikamenten-PWA mit dezenten Push-Erinnerungen, Einnahme-Logging u
### Spaß ### Spaß
- **Dopamin-Drop** — Animation + Erfolgsspruch beim Loggen - **Dopamin-Drop** — Animation + Erfolgsspruch beim Loggen
- **Roast-of-the-Day** — täglicher sarkastischer Spruch (OpenRouter, gecacht) - **Roast-of-the-Day** — täglicher sarkastischer Spruch (OpenRouter, gecacht)
- **KI-Orakel** — wöchentlicher Compliance-Report (OpenRouter, gecacht)
- **Easter Eggs** — Meilenstein-Badges (50/100 Logs, Streak 7/30/100) - **Easter Eggs** — Meilenstein-Badges (50/100 Logs, Streak 7/30/100)
- **~95 Humor-Texte** in `messages.yaml` (Reminder, Success, Missed, …) - **~95 Humor-Texte** in `messages.yaml` (Reminder, Success, Missed, …)
@@ -102,7 +101,7 @@ Volumes:
| `VAPID_PRIVATE_KEY` | Web-Push Private Key (PEM) | | `VAPID_PRIVATE_KEY` | Web-Push Private Key (PEM) |
| `VAPID_PUBLIC_KEY` | Web-Push Public Key | | `VAPID_PUBLIC_KEY` | Web-Push Public Key |
| `VAPID_CLAIMS_EMAIL` | mailto:-Adresse für VAPID | | `VAPID_CLAIMS_EMAIL` | mailto:-Adresse für VAPID |
| `OPENROUTER_API_KEY` | Optional — für Roast + Orakel | | `OPENROUTER_API_KEY` | Optional — für Roast-of-the-Day |
| `OPENROUTER_MODEL` | Default: `google/gemini-2.0-flash-001` | | `OPENROUTER_MODEL` | Default: `google/gemini-2.0-flash-001` |
### `meds.yaml` ### `meds.yaml`
@@ -144,7 +143,6 @@ Alle Endpunkte unter `/api/*`. Auth via `Authorization: Bearer <token>` (außer
| GET | `/api/history?days=90` | Heatmap-Daten + Stats | | GET | `/api/history?days=90` | Heatmap-Daten + Stats |
| GET | `/api/stats` | Streak, Compliance, Meilensteine | | GET | `/api/stats` | Streak, Compliance, Meilensteine |
| GET | `/api/roast` | Roast-of-the-Day | | GET | `/api/roast` | Roast-of-the-Day |
| GET | `/api/oracle` | Wöchentliches KI-Orakel |
| POST | `/api/push/subscribe` | Web-Push Subscription speichern | | POST | `/api/push/subscribe` | Web-Push Subscription speichern |
--- ---
@@ -176,7 +174,7 @@ Notification-Actions: **Genommen ✓**, **+15 Min**, **+30 Min**
| **Vanilla JS** statt React/Vue | Kein Build-Step, schnelle Iteration, passt zu persönlicher App | | **Vanilla JS** statt React/Vue | Kein Build-Step, schnelle Iteration, passt zu persönlicher App |
| **SQLite** statt Postgres | Single-User, eine Datei, Backup = `data/medis.sqlite` kopieren | | **SQLite** statt Postgres | Single-User, eine Datei, Backup = `data/medis.sqlite` kopieren |
| **Web Push vom Server** statt Client-Timer | Zuverlässig auch bei geschlossener App; APScheduler im Container | | **Web Push vom Server** statt Client-Timer | Zuverlässig auch bei geschlossener App; APScheduler im Container |
| **OpenRouter nur für Roast + Orakel** | Kosten/Latenz — Notifications nutzen statische `messages.yaml` | | **OpenRouter nur für Roast** | Kosten/Latenz — Notifications nutzen statische `messages.yaml` |
| **Model: gemini-2.0-flash** | Günstig, schnell, gut genug für kurze deutsche Roasts | | **Model: gemini-2.0-flash** | Günstig, schnell, gut genug für kurze deutsche Roasts |
| **PIN plain in .env** statt Hash | Single-User, unkritische Daten; `hmac.compare_digest` gegen Timing-Leaks | | **PIN plain in .env** statt Hash | Single-User, unkritische Daten; `hmac.compare_digest` gegen Timing-Leaks |
| **JWT 90 Tage** | Lange Session auf persönlichem Gerät, kein ständiges PIN-Eingeben | | **JWT 90 Tage** | Lange Session auf persönlichem Gerät, kein ständiges PIN-Eingeben |
-2
View File
@@ -129,8 +129,6 @@ async function loadHistory() {
const data = await api.history(90); const data = await api.history(90);
renderHeatmap(data.days); renderHeatmap(data.days);
updateStats(data.stats); updateStats(data.stats);
const oracle = await api.oracle().catch(() => ({ text: "Das Orakel schweigt. Wahrscheinlich enttäuscht." }));
document.getElementById("oracleText").textContent = oracle.text;
} }
async function handleDeepLink() { async function handleDeepLink() {
-4
View File
@@ -67,10 +67,6 @@
<div class="statLabel">Genommen</div> <div class="statLabel">Genommen</div>
</div> </div>
</div> </div>
<div class="oracleCard" id="oracleCard">
<div class="oracleLabel">🔮 KI-Orakel</div>
<p id="oracleText">Lade Orakel…</p>
</div>
<h2 class="sectionTitle">90-Tage Heatmap</h2> <h2 class="sectionTitle">90-Tage Heatmap</h2>
<div id="heatmap" class="heatmap"></div> <div id="heatmap" class="heatmap"></div>
</div> </div>
-1
View File
@@ -26,7 +26,6 @@ export const api = {
history: (days = 90) => request(`/api/history?days=${days}`), history: (days = 90) => request(`/api/history?days=${days}`),
stats: () => request("/api/stats"), stats: () => request("/api/stats"),
roast: () => request("/api/roast"), roast: () => request("/api/roast"),
oracle: () => request("/api/oracle"),
snooze: (slot_id, minutes) => request("/api/snooze", { method: "POST", body: JSON.stringify({ slot_id, minutes }) }), snooze: (slot_id, minutes) => request("/api/snooze", { method: "POST", body: JSON.stringify({ slot_id, minutes }) }),
vapidKey: () => request("/api/vapid-public-key"), vapidKey: () => request("/api/vapid-public-key"),
pushSubscribe: (subscription) => pushSubscribe: (subscription) =>
+3 -3
View File
@@ -97,18 +97,18 @@ body {
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } @keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
/* Cards */ /* Cards */
.roastCard, .oracleCard, .motivationCard { .roastCard, .motivationCard {
background: linear-gradient(135deg, var(--bg-card) 0%, #3a1855 100%); background: linear-gradient(135deg, var(--bg-card) 0%, #3a1855 100%);
border: 2px solid var(--accent-purple); border: 2px solid var(--accent-purple);
border-radius: var(--radius); padding: 1rem 1.25rem; border-radius: var(--radius); padding: 1rem 1.25rem;
margin-bottom: 1.25rem; box-shadow: 0 4px 20px rgba(124,77,255,.2); margin-bottom: 1.25rem; box-shadow: 0 4px 20px rgba(124,77,255,.2);
} }
.roastLabel, .oracleLabel, .motivationLabel { .roastLabel, .motivationLabel {
font-size: .65rem; text-transform: uppercase; letter-spacing: .06em; font-size: .65rem; text-transform: uppercase; letter-spacing: .06em;
line-height: 1.35; line-height: 1.35;
color: var(--accent-purple); margin-bottom: .5rem; font-weight: 600; color: var(--accent-purple); margin-bottom: .5rem; font-weight: 600;
} }
.roastCard p, .oracleCard p, .motivationCard p { line-height: 1.5; font-size: .95rem; } .roastCard p, .motivationCard p { line-height: 1.5; font-size: .95rem; }
.slotCards { display: flex; flex-direction: column; gap: 1rem; } .slotCards { display: flex; flex-direction: column; gap: 1rem; }
+42 -53
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime, timedelta
from typing import Any from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -21,11 +21,13 @@ MOTIVATION_SYSTEM = (
"Genau ein Satz auf Deutsch, max 25 Wörter. Keine Anführungszeichen um den Spruch." "Genau ein Satz auf Deutsch, max 25 Wörter. Keine Anführungszeichen um den Spruch."
) )
ORACLE_SYSTEM = ( ROAST_SYSTEM = (
"Du bist das tägliche KI-Orakel einer ADHS-Medikamenten-App. " "Du bist ein sarkastischer, dark-humor Medikamenten-Coach für jemanden mit ADHS. "
"Passiv-aggressiv, trocken, max 4 Sätze auf Deutsch. Keine medizinischen Ratschläge." "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: async def _get_cache(db: aiosqlite.Connection, key: str) -> str | None:
cur = await db.execute("SELECT content FROM ai_cache WHERE key = ?", (key,)) cur = await db.execute("SELECT content FROM ai_cache WHERE key = ?", (key,))
@@ -93,16 +95,24 @@ def _motivation_key(day: str) -> str:
return f"motivation:v2:{day}" return f"motivation:v2:{day}"
def _oracle_key(day: str) -> str: def _roast_key(day: str) -> str:
return f"oracle:v3:{day}" return f"roast:{day}"
async def _cached_oracle(db: aiosqlite.Connection, day: str) -> str | None: async def _recent_roasts(
cached = await _get_cache(db, _oracle_key(day)) db: aiosqlite.Connection,
if cached: *,
return cached before_day: str,
week = datetime.strptime(day, "%Y-%m-%d").strftime("%Y-W%W") days: int = ROAST_HISTORY_DAYS,
return await _get_cache(db, f"oracle:v2:{week}") ) -> 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: async def _cached_motivation(db: aiosqlite.Connection, day: str) -> str | None:
@@ -152,11 +162,7 @@ async def get_daily_motivation(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[s
return {"text": pick("motivation_fallback"), "source": "fallback", "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]: async def generate_roast_of_the_day(
return await get_daily_motivation(db, tz)
async def generate_oracle(
db: aiosqlite.Connection, db: aiosqlite.Connection,
tz: ZoneInfo, tz: ZoneInfo,
day: str | None = None, day: str | None = None,
@@ -164,58 +170,41 @@ async def generate_oracle(
force: bool = False, force: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
day = day or datetime.now(tz).strftime("%Y-%m-%d") day = day or datetime.now(tz).strftime("%Y-%m-%d")
key = _oracle_key(day) key = _roast_key(day)
if not force: if not force:
cached = await _cached_oracle(db, day) cached = await _get_cache(db, key)
if cached: if cached:
return {"text": cached, "source": "cache", "day": day} return {"text": cached, "source": "cache", "day": day}
stats = await compute_stats(db) stats = await compute_stats(db)
days_active = stats["days_active"] recent = await _recent_roasts(db, before_day=day)
window = stats["compliance_window_days"] history = ""
young_app = days_active < 90 if recent:
context = ( lines = "\n".join(f"- {text}" for text in recent)
f"App aktiv seit {days_active} Tag(en), erste Einnahme am {stats['first_day']}. " history = (
if young_app f"\n\nDiese Roasts der letzten {ROAST_HISTORY_DAYS} Tage wurden bereits verwendet "
else "" f"(nicht wiederholen, neue Idee):\n{lines}"
) )
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 = ( prompt = (
f"Täglicher Orakel-Report für heute. {context}{caveat}" "Schreibe den Roast-of-the-Day. "
f"Streak: {stats['streak']} Tage. " f"Streak: {stats['streak']} Tage. "
f"Compliance über {window} Tag(e): {stats['compliance_percent']}%. " f"Compliance über {stats['compliance_window_days']} aktive Tage: {stats['compliance_percent']}%. "
f"Genommen gesamt: {stats['total_taken']}." f"Ein sarkastischer Spruch im dark-humor Medikamenten-Coach Stil.{history}"
) )
text = await _call_openrouter(prompt, system=ORACLE_SYSTEM) text = await _call_openrouter(prompt, system=ROAST_SYSTEM)
if not text: if not text:
if young_app: text = pick("roast_fallback")
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" source = "fallback"
else: else:
source = "ai" source = "ai"
await _set_cache(db, key, text) await _set_cache(db, key, text)
logger.info("Generated daily oracle for %s (%s)", day, source) logger.info("Generated roast of the day for %s (%s)", day, source)
return {"text": text, "source": source, "day": day} return {"text": text, "source": source, "day": day}
async def get_oracle(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]: async def get_roast_of_the_day(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]:
day = datetime.now(tz).strftime("%Y-%m-%d") day = datetime.now(tz).strftime("%Y-%m-%d")
cached = await _cached_oracle(db, day) cached = await _get_cache(db, _roast_key(day))
if cached: if cached:
return {"text": cached, "source": "cache", "day": day} return {"text": cached, "source": "cache", "day": day}
return { return {"text": pick("roast_fallback"), "source": "fallback", "day": day}
"text": "Das Orakel bereitet sich vor. Schau später nochmal rein.",
"source": "pending",
"day": day,
}
+5 -27
View File
@@ -11,7 +11,7 @@ from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from server.ai import generate_daily_motivation, generate_oracle, get_oracle, get_roast_of_the_day from server.ai import generate_daily_motivation, generate_roast_of_the_day, get_roast_of_the_day
from server.auth import create_token, require_auth, verify_pin from server.auth import create_token, require_auth, verify_pin
from server.config_loader import load_meds_config, slot_to_dict, today_str from server.config_loader import load_meds_config, slot_to_dict, today_str
from server.db import ensure_schema, get_db, new_id, utc_now_iso from server.db import ensure_schema, get_db, new_id, utc_now_iso
@@ -40,24 +40,14 @@ _configure_logging()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def _ensure_today_motivation() -> None: async def _ensure_today_ai_texts() -> None:
config = load_meds_config() config = load_meds_config()
db = await get_db() db = await get_db()
try: try:
await generate_daily_motivation(db, config.timezone) await generate_daily_motivation(db, config.timezone)
await generate_roast_of_the_day(db, config.timezone)
except Exception: except Exception:
logger.exception("Startup motivation generation failed") logger.exception("Startup AI text generation failed")
finally:
await db.close()
async def _ensure_today_oracle() -> None:
config = load_meds_config()
db = await get_db()
try:
await generate_oracle(db, config.timezone)
except Exception:
logger.exception("Startup oracle generation failed")
finally: finally:
await db.close() await db.close()
@@ -68,8 +58,7 @@ async def lifespan(app: FastAPI):
await ensure_schema(db) await ensure_schema(db)
await db.close() await db.close()
start_scheduler() start_scheduler()
asyncio.create_task(_ensure_today_motivation()) asyncio.create_task(_ensure_today_ai_texts())
asyncio.create_task(_ensure_today_oracle())
yield yield
@@ -219,17 +208,6 @@ async def get_roast(_: dict = Depends(require_auth)) -> JSONResponse:
return JSONResponse(data, headers=NO_STORE) return JSONResponse(data, headers=NO_STORE)
@app.get("/api/oracle")
async def get_oracle_route(_: dict = Depends(require_auth)) -> JSONResponse:
config = load_meds_config()
db = await get_db()
try:
data = await get_oracle(db, config.timezone)
finally:
await db.close()
return JSONResponse(data, headers=NO_STORE)
# Static files — must be after API routes # Static files — must be after API routes
PUBLIC = pathlib.Path(settings.public_dir) PUBLIC = pathlib.Path(settings.public_dir)
if PUBLIC.exists(): if PUBLIC.exists():
+6 -6
View File
@@ -9,7 +9,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.date import DateTrigger
from server.ai import generate_daily_motivation, generate_oracle from server.ai import generate_daily_motivation, generate_roast_of_the_day
from server.config_loader import load_meds_config, slot_to_dict, today_str from server.config_loader import load_meds_config, slot_to_dict, today_str
from server.db import get_db, utc_now_iso from server.db import get_db, utc_now_iso
from server.messages import pick from server.messages import pick
@@ -54,13 +54,13 @@ async def _generate_daily_motivation() -> None:
await db.close() await db.close()
async def _generate_daily_oracle() -> None: async def _generate_daily_roast() -> None:
config = load_meds_config() config = load_meds_config()
db = await get_db() db = await get_db()
try: try:
await generate_oracle(db, config.timezone) await generate_roast_of_the_day(db, config.timezone)
except Exception: except Exception:
logger.exception("Daily oracle generation failed") logger.exception("Daily roast generation failed")
finally: finally:
await db.close() await db.close()
@@ -159,9 +159,9 @@ def start_scheduler() -> None:
) )
scheduler.add_job( scheduler.add_job(
_generate_daily_oracle, _generate_daily_roast,
trigger=CronTrigger(hour=3, minute=5, timezone=tz), trigger=CronTrigger(hour=3, minute=5, timezone=tz),
id="daily-oracle", id="daily-roast",
replace_existing=True, replace_existing=True,
) )
+6 -14
View File
@@ -1,36 +1,28 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Regenerate cached AI texts (motivation + oracle).""" """Regenerate cached AI texts (motivation, roast)."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
from server.ai import generate_daily_motivation, generate_oracle from server.ai import generate_daily_motivation, generate_roast_of_the_day
from server.config_loader import load_meds_config from server.config_loader import load_meds_config
from server.db import get_db from server.db import get_db
async def main() -> None: async def main() -> None:
parser = argparse.ArgumentParser(description="Regenerate cached AI texts") parser = argparse.ArgumentParser(description="Regenerate cached AI texts")
parser.add_argument("--motivation", action="store_true", help="Regenerate today's motivation")
parser.add_argument("--oracle", action="store_true", help="Regenerate today's oracle")
parser.add_argument("--force", action="store_true", help="Regenerate even if cache exists") parser.add_argument("--force", action="store_true", help="Regenerate even if cache exists")
args = parser.parse_args() args = parser.parse_args()
if not args.motivation and not args.oracle:
args.motivation = True
args.oracle = True
config = load_meds_config() config = load_meds_config()
db = await get_db() db = await get_db()
try: try:
if args.motivation: motivation = await generate_daily_motivation(db, config.timezone, force=args.force)
result = await generate_daily_motivation(db, config.timezone, force=args.force) roast = await generate_roast_of_the_day(db, config.timezone, force=args.force)
print("motivation:", result) print("motivation:", motivation)
if args.oracle: print("roast:", roast)
result = await generate_oracle(db, config.timezone, force=args.force)
print("oracle:", result)
finally: finally:
await db.close() await db.close()