KI-Tracking vollständig, Cloud-Limit 20/Woche, Statusmail täglich 06:00 — SW by-v434, APP_VER 413

- 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
This commit is contained in:
rene 2026-04-26 17:01:05 +02:00
parent 85836e4e6e
commit 570dcd4e93
11 changed files with 135 additions and 78 deletions

View file

@ -27,6 +27,7 @@ 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") LOCAL_MODEL = os.getenv("KI_LOCAL_MODEL", "gemma-4-31b-it")
CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-sonnet-4-6") CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-sonnet-4-6")
ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") 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 # Lazy Imports — nur laden wenn wirklich benötigt
_openai_client = None _openai_client = None
@ -62,6 +63,50 @@ class KIPremiumRequired(Exception):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Haupt-Aufruf-Funktion — zentraler Eingang für alle KI-Requests # 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( async def complete(
prompt: str, prompt: str,
system: str = "Du bist ein hilfreicher Assistent für Hundebesitzer.", system: str = "Du bist ein hilfreicher Assistent für Hundebesitzer.",
@ -71,55 +116,43 @@ async def complete(
json_mode: bool = False, json_mode: bool = False,
return_model: bool = False, return_model: bool = False,
return_source: bool = False, return_source: bool = False,
user_id: int | None = None,
) -> str: ) -> str:
""" """
KI-Completion. Wählt automatisch den richtigen Backend. KI-Completion. Wählt automatisch den richtigen Backend.
Zählt jeden Aufruf in ki_daily_calls wenn user_id übergeben wird.
Args: Prüft wöchentliches Cloud-Limit (CLOUD_WEEKLY_LIMIT) für Cloud-Aufrufe.
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
""" """
if KI_MODE == "off": if KI_MODE == "off":
raise KIUnavailableError("KI ist deaktiviert.") raise KIUnavailableError("KI ist deaktiviert.")
# Premium-Check vor Cloud-Aufrufen
if requires_premium and not user_is_premium: if requires_premium and not user_is_premium:
raise KIPremiumRequired( raise KIPremiumRequired("Dieses Feature ist Teil von Ban Yaro Premium.")
"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": 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) text = await _cloud_complete(prompt, system, max_tokens, json_mode)
_track_usage(user_id, "cloud")
if return_model: if return_model:
return (text, CLOUD_MODEL) return (text, CLOUD_MODEL)
return (text, "cloud") if return_source else text return (text, "cloud") if return_source else text
# Lokaler Aufruf: Entwicklung + Free-User # Lokaler Aufruf + Cloud-Fallback
if KI_MODE in ("local", "cloud"): if KI_MODE in ("local", "cloud"):
try: try:
text = await _local_complete(prompt, system, max_tokens, json_mode) text = await _local_complete(prompt, system, max_tokens, json_mode)
_track_usage(user_id, "local")
if return_model: if return_model:
return (text, LOCAL_MODEL) return (text, LOCAL_MODEL)
return (text, "local") if return_source else text return (text, "local") if return_source else text
except Exception as e: except Exception as e:
logger.warning(f"Lokales KI-Modell nicht erreichbar: {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)): if ANTHROPIC_KEY and (KI_MODE == "cloud" or (requires_premium and user_is_premium)):
logger.info("Fallback auf Cloud-KI.") logger.info("Fallback auf Cloud-KI.")
_check_weekly_cloud_limit(user_id)
text = await _cloud_complete(prompt, system, max_tokens, json_mode) text = await _cloud_complete(prompt, system, max_tokens, json_mode)
_track_usage(user_id, "cloud")
if return_model: if return_model:
return (text, CLOUD_MODEL) return (text, CLOUD_MODEL)
return (text, "cloud") if return_source else text 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, async def symptom_check(symptoms: str, dog_info: dict,
user_id: int | None = None,
user_is_premium: bool = False) -> dict: user_is_premium: bool = False) -> dict:
""" """
Symptom-Triage für Hunde. Symptom-Triage für Hunde.
@ -216,9 +250,10 @@ Antworte NUR als JSON:
result = await complete( result = await complete(
prompt, system, prompt, system,
max_tokens=400, max_tokens=400,
requires_premium=False, # Basis-Triage ist kostenlos requires_premium=False,
user_is_premium=user_is_premium, user_is_premium=user_is_premium,
json_mode=True, json_mode=True,
user_id=user_id,
) )
import json, re import json, re
# Cloud-Modelle wrappen JSON manchmal in ```json … ``` — stripppen # 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. 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. Antworte NUR mit den Tags, kommagetrennt, keine Erklärung.
""" """
try: 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()] return [t.strip().lower() for t in result.split(",") if t.strip()]
except Exception: except Exception:
return [] return []
@ -305,7 +340,8 @@ Bewerte als JSON:
async def health_summary(health_data: list, dog_info: dict, 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. Tierärztliche Zusammenfassung aller Gesundheitsdaten lokal für alle.
Fasst Impfungen, Besuche, Medikamente etc. in einem lesbaren Bericht zusammen. 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( return await complete(
prompt, system, prompt, system,
max_tokens=700, max_tokens=700,
requires_premium=False, # Kostenlos für alle requires_premium=False,
user_is_premium=user_is_premium, user_is_premium=user_is_premium,
user_id=user_id,
) )

View file

@ -149,41 +149,45 @@ async def stats(user=Depends(require_mod)):
).fetchall() ).fetchall()
} }
# KI-Trainer Nutzung # KI-Nutzung
try: try:
from ki import CLOUD_WEEKLY_LIMIT
ki_today = conn.execute( ki_today = conn.execute(
"SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE date=DATE('now')" "SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE date=DATE('now')"
).fetchone()[0] ).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( ki_month = conn.execute(
"SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE date>=DATE('now','start of month')" "SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE date>=DATE('now','start of month')"
).fetchone()[0] ).fetchone()[0]
ki_users_today = conn.execute( ki_users_today = conn.execute(
"SELECT COUNT(DISTINCT user_id) FROM ki_daily_calls WHERE date=DATE('now')" "SELECT COUNT(DISTINCT user_id) FROM ki_daily_calls WHERE date=DATE('now')"
).fetchone()[0] ).fetchone()[0]
# Aufschlüsselung nach Quelle (heute) # Aufschlüsselung nach Quelle (diese Woche)
_src_today = { _src_week = {
r[0]: r[1] for r in conn.execute( r[0]: r[1] for r in conn.execute(
"SELECT source, COALESCE(SUM(count),0) FROM ki_daily_calls " "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() ).fetchall()
} }
ki_cloud_today = _src_today.get("cloud", 0) ki_cloud_week = _src_week.get("cloud", 0)
ki_local_today = _src_today.get("local", 0) ki_local_week = _src_week.get("local", 0)
ki_luna_today = _src_today.get("luna", 0) ki_luna_week = _src_week.get("luna", 0)
# Aufschlüsselung nach Quelle (Monat) # Top-User Cloud diese Woche
_src_month = { ki_top_users = [
r[0]: r[1] for r in conn.execute( {"user_id": r[0], "name": r[1], "cloud_calls": r[2]} for r in conn.execute(
"SELECT source, COALESCE(SUM(count),0) FROM ki_daily_calls " """SELECT k.user_id, u.name, SUM(k.count) as n
"WHERE date>=DATE('now','start of month') GROUP BY source" 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() ).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: except Exception:
ki_today = ki_month = ki_users_today = 0 from ki import CLOUD_WEEKLY_LIMIT
ki_cloud_today = ki_local_today = ki_luna_today = 0 ki_today = ki_week = ki_month = ki_users_today = 0
ki_cloud_month = ki_local_month = ki_luna_month = 0 ki_cloud_week = ki_local_week = ki_luna_week = 0
ki_top_users = []
# Ausstehende Wiki-Foto-Einreichungen # Ausstehende Wiki-Foto-Einreichungen
try: try:
@ -246,14 +250,14 @@ async def stats(user=Depends(require_mod)):
"osm_tiles": osm_tiles, "osm_tiles": osm_tiles,
"osm_by_type": osm_by_type, "osm_by_type": osm_by_type,
"ki_today": ki_today, "ki_today": ki_today,
"ki_week": ki_week,
"ki_month": ki_month, "ki_month": ki_month,
"ki_users_today": ki_users_today, "ki_users_today": ki_users_today,
"ki_cloud_today": ki_cloud_today, "ki_cloud_week": ki_cloud_week,
"ki_local_today": ki_local_today, "ki_local_week": ki_local_week,
"ki_luna_today": ki_luna_today, "ki_luna_week": ki_luna_week,
"ki_cloud_month": ki_cloud_month, "ki_cloud_weekly_limit": CLOUD_WEEKLY_LIMIT,
"ki_local_month": ki_local_month, "ki_top_users": ki_top_users,
"ki_luna_month": ki_luna_month,
"social_total": social_total, "social_total": social_total,
"social_published": social_published, "social_published": social_published,
"social_scheduled": social_scheduled, "social_scheduled": social_scheduled,

View file

@ -265,7 +265,7 @@ async def create_diary(dog_id: int, data: DiaryCreate,
# KI: Auto-Tags wenn Text vorhanden (lokal, kostenlos) # KI: Auto-Tags wenn Text vorhanden (lokal, kostenlos)
if data.text and len(data.text) > 10: if data.text and len(data.text) > 10:
try: 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)) tags = list(set(tags + ai_tags))
except Exception: except Exception:
pass pass

View file

@ -412,6 +412,7 @@ async def symptom_check(dog_id: int, data: SymptomCheckRequest,
symptoms=data.symptoms, symptoms=data.symptoms,
dog_info=dog_info, dog_info=dog_info,
user_is_premium=bool(user.get("is_premium")), user_is_premium=bool(user.get("is_premium")),
user_id=user["id"],
) )
return result return result
except KIUnavailableError as e: except KIUnavailableError as e:
@ -444,6 +445,7 @@ async def ki_zusammenfassung(dog_id: int, user=Depends(get_current_user)):
health_data=health_data, health_data=health_data,
dog_info=dict(dog), dog_info=dict(dog),
user_is_premium=bool(user.get("is_premium")), user_is_premium=bool(user.get("is_premium")),
user_id=user["id"],
) )
return {"zusammenfassung": result} return {"zusammenfassung": result}
except KIPremiumRequired as e: except KIPremiumRequired as e:

View file

@ -55,6 +55,7 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
system=system, system=system,
max_tokens=600, max_tokens=600,
requires_premium=False, requires_premium=False,
user_id=user["id"],
) )
return {"antwort": result} return {"antwort": result}
except ki_module.KIUnavailableError as e: except ki_module.KIUnavailableError as e:

View file

@ -105,6 +105,7 @@ async def ki_rat(data: KiRatRequest, user=Depends(get_current_user)):
max_tokens=300, max_tokens=300,
requires_premium=False, requires_premium=False,
user_is_premium=bool(user.get("is_premium")), user_is_premium=bool(user.get("is_premium")),
user_id=user["id"],
) )
return {"rat": rat} return {"rat": rat}
except KIPremiumRequired as e: except KIPremiumRequired as e:

View file

@ -156,10 +156,11 @@ async def ki_analyse(user=Depends(get_current_user)):
try: try:
import ki as ki_module import ki as ki_module
suggestions, _ = await ki_module.complete( suggestions = await ki_module.complete(
prompt, prompt,
requires_premium=False, requires_premium=False,
user_is_premium=False, user_is_premium=False,
user_id=user["id"],
) )
except Exception as e: except Exception as e:
logger.warning("KI-Analyse fehlgeschlagen: %s", e) logger.warning("KI-Analyse fehlgeschlagen: %s", e)

View file

@ -92,10 +92,10 @@ def start():
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
) )
# Alle 2 Stunden Status-Report per Mail # Täglich 06:00 Uhr Status-Report per Mail
_scheduler.add_job( _scheduler.add_job(
_job_status_report, _job_status_report,
CronTrigger(minute=0, hour="*/2"), CronTrigger(hour=6, minute=0),
id="status_report", id="status_report",
replace_existing=True, replace_existing=True,
misfire_grace_time=1800, misfire_grace_time=1800,
@ -109,7 +109,7 @@ def start():
misfire_grace_time=3600, misfire_grace_time=3600,
) )
_scheduler.start() _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(): 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(): async def _job_status_report():
"""Sendet einen HTML-Status-Report an ADMIN_EMAIL.""" """Sendet einen HTML-Status-Report an ADMIN_EMAIL."""

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 = (() => { const App = (() => {

View file

@ -333,13 +333,11 @@ window.Page_admin = (() => {
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">KI-Nutzung</p> <p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">KI-Nutzung</p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)"> <div style="display:flex;flex-direction:column;gap:var(--space-2)">
${[ ${[
['☁️ Claude heute', s.ki_cloud_today, 'var(--c-primary)'], ['☁️ Claude (7 Tage)', s.ki_cloud_week, 'var(--c-primary)'],
['🖥️ LM Studio heute', s.ki_local_today, 'var(--c-success)'], ['🖥️ LM Studio (7 Tage)', s.ki_local_week, 'var(--c-success)'],
['🌙 Luna heute', s.ki_luna_today, 'var(--c-warning)'], ['🌙 Luna (7 Tage)', s.ki_luna_week, 'var(--c-warning)'],
['Gesamt heute', s.ki_today, 'var(--c-text-secondary)'], ['Gesamt heute', s.ki_today, 'var(--c-text-secondary)'],
['☁️ Claude Monat', s.ki_cloud_month, 'var(--c-primary)'], ['Gesamt 7 Tage', s.ki_week, 'var(--c-text-secondary)'],
['🖥️ 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)'], ['Gesamt Monat', s.ki_month, 'var(--c-text-secondary)'],
['Aktive User heute', s.ki_users_today, 'var(--c-text-secondary)'], ['Aktive User heute', s.ki_users_today, 'var(--c-text-secondary)'],
].map(([label, val, color]) => ` ].map(([label, val, color]) => `
@ -349,6 +347,19 @@ window.Page_admin = (() => {
</div> </div>
`).join('')} `).join('')}
</div> </div>
<div style="margin-top:var(--space-3);padding-top:var(--space-3);border-top:1px solid var(--c-border)">
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0 0 var(--space-2)">
User-Limit: <strong>${s.ki_cloud_weekly_limit ?? 20} Cloud-Anfragen / Woche</strong>
</p>
${(s.ki_top_users || []).length ? `
<p style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);margin:var(--space-2) 0 var(--space-1)">Top Cloud-User (7 Tage)</p>
${s.ki_top_users.map((u, i) => `
<div style="display:flex;justify-content:space-between;font-size:var(--text-xs)">
<span style="color:var(--c-text-secondary)">${i+1}. ${_esc(u.name)}</span>
<span style="font-weight:600;color:${u.cloud_calls >= (s.ki_cloud_weekly_limit ?? 20) ? 'var(--c-danger)' : 'var(--c-primary)'}">${u.cloud_calls}</span>
</div>
`).join('')}` : ''}
</div>
</div> </div>
<div class="card" style="padding:var(--space-4)"> <div class="card" style="padding:var(--space-4)">

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v433'; const CACHE_VERSION = 'by-v434';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten