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

@ -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,
)