From 570dcd4e938c582014cfc12f5c83d093cfd768cd Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 26 Apr 2026 17:01:05 +0200 Subject: [PATCH] =?UTF-8?q?KI-Tracking=20vollst=C3=A4ndig,=20Cloud-Limit?= =?UTF-8?q?=2020/Woche,=20Statusmail=20t=C3=A4glich=2006:00=20=E2=80=94=20?= =?UTF-8?q?SW=20by-v434,=20APP=5FVER=20413?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ki.complete() zählt sich selbst (user_id-Parameter, _track_usage) - CLOUD_WEEKLY_LIMIT=20, geprüft vor jedem Cloud-Call - user_id durchgereicht in health, diary, knigge, notes, ki-route - Admin-Panel: 7-Tage-Ansicht, Limit-Info, Top-Cloud-User-Tabelle - Statusmail täglich 06:00 CEST statt alle 2h --- backend/ki.py | 103 +++++++++++++++++++++---------- backend/routes/admin.py | 60 +++++++++--------- backend/routes/diary.py | 2 +- backend/routes/health.py | 2 + backend/routes/ki.py | 1 + backend/routes/knigge.py | 1 + backend/routes/notes.py | 3 +- backend/scheduler.py | 8 +-- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 29 ++++++--- backend/static/sw.js | 2 +- 11 files changed, 135 insertions(+), 78 deletions(-) diff --git a/backend/ki.py b/backend/ki.py index 47ae257..d82522e 100644 --- a/backend/ki.py +++ b/backend/ki.py @@ -22,11 +22,12 @@ from functools import wraps logger = logging.getLogger(__name__) -KI_MODE = os.getenv("KI_MODE", "local") # off | local | cloud -LOCAL_BASE_URL = os.getenv("KI_LOCAL_URL", "http://10.47.11.70:11435/v1") -LOCAL_MODEL = os.getenv("KI_LOCAL_MODEL", "gemma-4-31b-it") -CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-sonnet-4-6") -ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") +KI_MODE = os.getenv("KI_MODE", "local") # off | local | cloud +LOCAL_BASE_URL = os.getenv("KI_LOCAL_URL", "http://10.47.11.70:11435/v1") +LOCAL_MODEL = os.getenv("KI_LOCAL_MODEL", "gemma-4-31b-it") +CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-sonnet-4-6") +ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") +CLOUD_WEEKLY_LIMIT = int(os.getenv("KI_CLOUD_WEEKLY_LIMIT", "20")) # Lazy Imports — nur laden wenn wirklich benötigt _openai_client = None @@ -62,6 +63,50 @@ class KIPremiumRequired(Exception): # ------------------------------------------------------------------ # Haupt-Aufruf-Funktion — zentraler Eingang für alle KI-Requests # ------------------------------------------------------------------ +def _track_usage(user_id: int | None, source: str) -> None: + """Schreibt einen KI-Aufruf in ki_daily_calls (fire-and-forget, swallows errors).""" + if user_id is None: + return + try: + from database import db + from datetime import date + today = date.today().isoformat() + with db() as conn: + conn.execute( + """INSERT INTO ki_daily_calls (user_id, date, count, source) + VALUES (?, ?, 1, ?) + ON CONFLICT(user_id, date, source) + DO UPDATE SET count = count + 1""", + (user_id, today, source) + ) + except Exception as exc: + logger.warning(f"KI-Tracking fehlgeschlagen: {exc}") + + +def _check_weekly_cloud_limit(user_id: int | None) -> None: + """Wirft KIPremiumRequired wenn user_id das wöchentliche Cloud-Limit erreicht hat.""" + if user_id is None or CLOUD_WEEKLY_LIMIT <= 0: + return + try: + from database import db + with db() as conn: + used = conn.execute( + """SELECT COALESCE(SUM(count), 0) FROM ki_daily_calls + WHERE user_id=? AND source='cloud' + AND date >= DATE('now', '-6 days')""", + (user_id,) + ).fetchone()[0] + if used >= CLOUD_WEEKLY_LIMIT: + raise KIPremiumRequired( + f"Wöchentliches KI-Limit erreicht ({CLOUD_WEEKLY_LIMIT} Cloud-Anfragen/Woche). " + "Morgen wieder verfügbar." + ) + except KIPremiumRequired: + raise + except Exception as exc: + logger.warning(f"KI-Limit-Check fehlgeschlagen: {exc}") + + async def complete( prompt: str, system: str = "Du bist ein hilfreicher Assistent für Hundebesitzer.", @@ -71,55 +116,43 @@ async def complete( json_mode: bool = False, return_model: bool = False, return_source: bool = False, + user_id: int | None = None, ) -> str: """ KI-Completion. Wählt automatisch den richtigen Backend. - - Args: - prompt: User-Nachricht - system: System-Prompt - max_tokens: Maximale Antwortlänge - requires_premium: True = nur für Premium-User (nutzt Cloud) - user_is_premium: Ob der anfragende User Premium hat - json_mode: Antwort als JSON anfordern - return_source: Falls True: gibt (text, source) zurück, source = 'cloud'|'local' - - Returns: - KI-Antwort als String, oder (str, str) wenn return_source=True - - Raises: - KIPremiumRequired: Cloud-Feature ohne Premium - KIUnavailableError: KI komplett deaktiviert + Zählt jeden Aufruf in ki_daily_calls wenn user_id übergeben wird. + Prüft wöchentliches Cloud-Limit (CLOUD_WEEKLY_LIMIT) für Cloud-Aufrufe. """ if KI_MODE == "off": raise KIUnavailableError("KI ist deaktiviert.") - # Premium-Check vor Cloud-Aufrufen if requires_premium and not user_is_premium: - raise KIPremiumRequired( - "Dieses Feature ist Teil von Ban Yaro Premium." - ) + raise KIPremiumRequired("Dieses Feature ist Teil von Ban Yaro Premium.") - # Cloud-Aufruf: nur wenn Premium UND cloud-Modus + # Cloud-Aufruf: Premium UND cloud-Modus if requires_premium and user_is_premium and KI_MODE == "cloud": + _check_weekly_cloud_limit(user_id) text = await _cloud_complete(prompt, system, max_tokens, json_mode) + _track_usage(user_id, "cloud") if return_model: return (text, CLOUD_MODEL) return (text, "cloud") if return_source else text - # Lokaler Aufruf: Entwicklung + Free-User + # Lokaler Aufruf + Cloud-Fallback if KI_MODE in ("local", "cloud"): try: text = await _local_complete(prompt, system, max_tokens, json_mode) + _track_usage(user_id, "local") if return_model: return (text, LOCAL_MODEL) return (text, "local") if return_source else text except Exception as e: logger.warning(f"Lokales KI-Modell nicht erreichbar: {e}") - # Cloud-Fallback: im cloud-Modus immer, sonst nur für Premium-User if ANTHROPIC_KEY and (KI_MODE == "cloud" or (requires_premium and user_is_premium)): logger.info("Fallback auf Cloud-KI.") + _check_weekly_cloud_limit(user_id) text = await _cloud_complete(prompt, system, max_tokens, json_mode) + _track_usage(user_id, "cloud") if return_model: return (text, CLOUD_MODEL) return (text, "cloud") if return_source else text @@ -188,6 +221,7 @@ async def _cloud_complete( # ------------------------------------------------------------------ async def symptom_check(symptoms: str, dog_info: dict, + user_id: int | None = None, user_is_premium: bool = False) -> dict: """ Symptom-Triage für Hunde. @@ -216,9 +250,10 @@ Antworte NUR als JSON: result = await complete( prompt, system, max_tokens=400, - requires_premium=False, # Basis-Triage ist kostenlos + requires_premium=False, user_is_premium=user_is_premium, json_mode=True, + user_id=user_id, ) import json, re # Cloud-Modelle wrappen JSON manchmal in ```json … ``` — stripppen @@ -258,7 +293,7 @@ Strukturiert, konkret, motivierend. ) -async def diary_tags(entry_text: str) -> list[str]: +async def diary_tags(entry_text: str, user_id: int | None = None) -> list[str]: """ Automatische Tags für Tagebucheinträge — läuft lokal, kostenlos. """ @@ -274,7 +309,7 @@ reise, meilenstein, lustig, traurig, wetter, sonstige Antworte NUR mit den Tags, kommagetrennt, keine Erklärung. """ try: - result = await complete(prompt, max_tokens=50, requires_premium=False) + result = await complete(prompt, max_tokens=50, requires_premium=False, user_id=user_id) return [t.strip().lower() for t in result.split(",") if t.strip()] except Exception: return [] @@ -305,7 +340,8 @@ Bewerte als JSON: async def health_summary(health_data: list, dog_info: dict, - user_is_premium: bool = False) -> str: + user_is_premium: bool = False, + user_id: int | None = None) -> str: """ Tierärztliche Zusammenfassung aller Gesundheitsdaten — lokal für alle. Fasst Impfungen, Besuche, Medikamente etc. in einem lesbaren Bericht zusammen. @@ -364,8 +400,9 @@ Schreibe klar und verständlich für den Hundebesitzer. return await complete( prompt, system, max_tokens=700, - requires_premium=False, # Kostenlos für alle + requires_premium=False, user_is_premium=user_is_premium, + user_id=user_id, ) diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 8bcdff2..ea16ce8 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -149,41 +149,45 @@ async def stats(user=Depends(require_mod)): ).fetchall() } - # KI-Trainer Nutzung + # KI-Nutzung try: + from ki import CLOUD_WEEKLY_LIMIT ki_today = conn.execute( "SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE date=DATE('now')" ).fetchone()[0] + ki_week = conn.execute( + "SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE date>=DATE('now','-6 days')" + ).fetchone()[0] ki_month = conn.execute( "SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE date>=DATE('now','start of month')" ).fetchone()[0] ki_users_today = conn.execute( "SELECT COUNT(DISTINCT user_id) FROM ki_daily_calls WHERE date=DATE('now')" ).fetchone()[0] - # Aufschlüsselung nach Quelle (heute) - _src_today = { + # Aufschlüsselung nach Quelle (diese Woche) + _src_week = { r[0]: r[1] for r in conn.execute( "SELECT source, COALESCE(SUM(count),0) FROM ki_daily_calls " - "WHERE date=DATE('now') GROUP BY source" + "WHERE date>=DATE('now','-6 days') GROUP BY source" ).fetchall() } - ki_cloud_today = _src_today.get("cloud", 0) - ki_local_today = _src_today.get("local", 0) - ki_luna_today = _src_today.get("luna", 0) - # Aufschlüsselung nach Quelle (Monat) - _src_month = { - r[0]: r[1] for r in conn.execute( - "SELECT source, COALESCE(SUM(count),0) FROM ki_daily_calls " - "WHERE date>=DATE('now','start of month') GROUP BY source" + ki_cloud_week = _src_week.get("cloud", 0) + ki_local_week = _src_week.get("local", 0) + ki_luna_week = _src_week.get("luna", 0) + # Top-User Cloud diese Woche + ki_top_users = [ + {"user_id": r[0], "name": r[1], "cloud_calls": r[2]} for r in conn.execute( + """SELECT k.user_id, u.name, SUM(k.count) as n + FROM ki_daily_calls k JOIN users u ON u.id=k.user_id + WHERE k.source='cloud' AND k.date>=DATE('now','-6 days') + GROUP BY k.user_id ORDER BY n DESC LIMIT 10""" ).fetchall() - } - ki_cloud_month = _src_month.get("cloud", 0) - ki_local_month = _src_month.get("local", 0) - ki_luna_month = _src_month.get("luna", 0) + ] except Exception: - ki_today = ki_month = ki_users_today = 0 - ki_cloud_today = ki_local_today = ki_luna_today = 0 - ki_cloud_month = ki_local_month = ki_luna_month = 0 + from ki import CLOUD_WEEKLY_LIMIT + ki_today = ki_week = ki_month = ki_users_today = 0 + ki_cloud_week = ki_local_week = ki_luna_week = 0 + ki_top_users = [] # Ausstehende Wiki-Foto-Einreichungen try: @@ -245,15 +249,15 @@ async def stats(user=Depends(require_mod)): "osm_total": osm_total, "osm_tiles": osm_tiles, "osm_by_type": osm_by_type, - "ki_today": ki_today, - "ki_month": ki_month, - "ki_users_today": ki_users_today, - "ki_cloud_today": ki_cloud_today, - "ki_local_today": ki_local_today, - "ki_luna_today": ki_luna_today, - "ki_cloud_month": ki_cloud_month, - "ki_local_month": ki_local_month, - "ki_luna_month": ki_luna_month, + "ki_today": ki_today, + "ki_week": ki_week, + "ki_month": ki_month, + "ki_users_today": ki_users_today, + "ki_cloud_week": ki_cloud_week, + "ki_local_week": ki_local_week, + "ki_luna_week": ki_luna_week, + "ki_cloud_weekly_limit": CLOUD_WEEKLY_LIMIT, + "ki_top_users": ki_top_users, "social_total": social_total, "social_published": social_published, "social_scheduled": social_scheduled, diff --git a/backend/routes/diary.py b/backend/routes/diary.py index 888f953..8ea90ec 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -265,7 +265,7 @@ async def create_diary(dog_id: int, data: DiaryCreate, # KI: Auto-Tags wenn Text vorhanden (lokal, kostenlos) if data.text and len(data.text) > 10: try: - ai_tags = await KI.diary_tags(data.text) + ai_tags = await KI.diary_tags(data.text, user_id=user["id"]) tags = list(set(tags + ai_tags)) except Exception: pass diff --git a/backend/routes/health.py b/backend/routes/health.py index bbdb1ee..0de348b 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -412,6 +412,7 @@ async def symptom_check(dog_id: int, data: SymptomCheckRequest, symptoms=data.symptoms, dog_info=dog_info, user_is_premium=bool(user.get("is_premium")), + user_id=user["id"], ) return result except KIUnavailableError as e: @@ -444,6 +445,7 @@ async def ki_zusammenfassung(dog_id: int, user=Depends(get_current_user)): health_data=health_data, dog_info=dict(dog), user_is_premium=bool(user.get("is_premium")), + user_id=user["id"], ) return {"zusammenfassung": result} except KIPremiumRequired as e: diff --git a/backend/routes/ki.py b/backend/routes/ki.py index 6760225..aa8d001 100644 --- a/backend/routes/ki.py +++ b/backend/routes/ki.py @@ -55,6 +55,7 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon.""" system=system, max_tokens=600, requires_premium=False, + user_id=user["id"], ) return {"antwort": result} except ki_module.KIUnavailableError as e: diff --git a/backend/routes/knigge.py b/backend/routes/knigge.py index a44c339..779d023 100644 --- a/backend/routes/knigge.py +++ b/backend/routes/knigge.py @@ -105,6 +105,7 @@ async def ki_rat(data: KiRatRequest, user=Depends(get_current_user)): max_tokens=300, requires_premium=False, user_is_premium=bool(user.get("is_premium")), + user_id=user["id"], ) return {"rat": rat} except KIPremiumRequired as e: diff --git a/backend/routes/notes.py b/backend/routes/notes.py index 1c6527b..6901f62 100644 --- a/backend/routes/notes.py +++ b/backend/routes/notes.py @@ -156,10 +156,11 @@ async def ki_analyse(user=Depends(get_current_user)): try: import ki as ki_module - suggestions, _ = await ki_module.complete( + suggestions = await ki_module.complete( prompt, requires_premium=False, user_is_premium=False, + user_id=user["id"], ) except Exception as e: logger.warning("KI-Analyse fehlgeschlagen: %s", e) diff --git a/backend/scheduler.py b/backend/scheduler.py index 8696ec8..c99600e 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -92,10 +92,10 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) - # Alle 2 Stunden Status-Report per Mail + # Täglich 06:00 Uhr Status-Report per Mail _scheduler.add_job( _job_status_report, - CronTrigger(minute=0, hour="*/2"), + CronTrigger(hour=6, minute=0), id="status_report", replace_existing=True, misfire_grace_time=1800, @@ -109,7 +109,7 @@ def start(): misfire_grace_time=3600, ) _scheduler.start() - logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats. OSM-Cache: on-demand (kein Prewarm).") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -642,7 +642,7 @@ async def _job_ki_health_report(): # ------------------------------------------------------------------ -# JOB: Status-Report per Mail (4× täglich) +# JOB: Status-Report per Mail (täglich 06:00 Uhr) # ------------------------------------------------------------------ async def _job_status_report(): """Sendet einen HTML-Status-Report an ADMIN_EMAIL.""" diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 08af3be..7bd4f37 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '412'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '413'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 6d01b29..f5c049a 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -333,15 +333,13 @@ window.Page_admin = (() => {

KI-Nutzung

${[ - ['☁️ Claude heute', s.ki_cloud_today, 'var(--c-primary)'], - ['🖥️ LM Studio heute', s.ki_local_today, 'var(--c-success)'], - ['🌙 Luna heute', s.ki_luna_today, 'var(--c-warning)'], - ['Gesamt heute', s.ki_today, 'var(--c-text-secondary)'], - ['☁️ Claude Monat', s.ki_cloud_month, 'var(--c-primary)'], - ['🖥️ LM Studio Monat', s.ki_local_month, 'var(--c-success)'], - ['🌙 Luna Monat', s.ki_luna_month, 'var(--c-warning)'], - ['Gesamt Monat', s.ki_month, 'var(--c-text-secondary)'], - ['Aktive User heute', s.ki_users_today, 'var(--c-text-secondary)'], + ['☁️ Claude (7 Tage)', s.ki_cloud_week, 'var(--c-primary)'], + ['🖥️ LM Studio (7 Tage)', s.ki_local_week, 'var(--c-success)'], + ['🌙 Luna (7 Tage)', s.ki_luna_week, 'var(--c-warning)'], + ['Gesamt heute', s.ki_today, 'var(--c-text-secondary)'], + ['Gesamt 7 Tage', s.ki_week, 'var(--c-text-secondary)'], + ['Gesamt Monat', s.ki_month, 'var(--c-text-secondary)'], + ['Aktive User heute', s.ki_users_today, 'var(--c-text-secondary)'], ].map(([label, val, color]) => `
${label} @@ -349,6 +347,19 @@ window.Page_admin = (() => {
`).join('')}
+
+

+ User-Limit: ${s.ki_cloud_weekly_limit ?? 20} Cloud-Anfragen / Woche +

+ ${(s.ki_top_users || []).length ? ` +

Top Cloud-User (7 Tage)

+ ${s.ki_top_users.map((u, i) => ` +
+ ${i+1}. ${_esc(u.name)} + ${u.cloud_calls} +
+ `).join('')}` : ''} +
diff --git a/backend/static/sw.js b/backend/static/sw.js index 065c012..29b4e39 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v433'; +const CACHE_VERSION = 'by-v434'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten