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

View file

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

View file

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

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

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

View file

@ -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."""

View file

@ -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 = (() => {

View file

@ -333,15 +333,13 @@ window.Page_admin = (() => {
<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)">
${[
['☁️ 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]) => `
<div style="display:flex;justify-content:space-between;font-size:var(--text-sm)">
<span style="color:var(--c-text-secondary)">${label}</span>
@ -349,6 +347,19 @@ window.Page_admin = (() => {
</div>
`).join('')}
</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 class="card" style="padding:var(--space-4)">

View file

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