diff --git a/backend/database.py b/backend/database.py index 3acd66a..cb4264e 100644 --- a/backend/database.py +++ b/backend/database.py @@ -530,6 +530,8 @@ def _migrate(conn_factory): ("social_content", "exercise_id", "TEXT"), ("social_content", "post_url", "TEXT"), ("dogs", "rasse_id", "INTEGER"), + # Pflege: Schere vs. Trimmen unterscheiden + ("pflege_tipps", "fell_pflege_art", "TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -1108,3 +1110,22 @@ def _migrate(conn_factory): except Exception: pass logger.info("Migration: routes.is_valid bereit.") + + # ki_daily_calls: source-Spalte + PK auf (user_id, date, source) erweitern + ki_cols = {r[1] for r in conn.execute("PRAGMA table_info(ki_daily_calls)").fetchall()} + if "source" not in ki_cols: + conn.executescript(""" + ALTER TABLE ki_daily_calls RENAME TO ki_daily_calls_old; + CREATE TABLE ki_daily_calls ( + user_id INTEGER NOT NULL, + date TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT 'cloud', + PRIMARY KEY (user_id, date, source) + ); + INSERT INTO ki_daily_calls (user_id, date, count, source) + SELECT user_id, date, count, 'cloud' FROM ki_daily_calls_old; + DROP TABLE ki_daily_calls_old; + CREATE INDEX IF NOT EXISTS idx_ki_daily_source ON ki_daily_calls(date, source); + """) + logger.info("Migration: ki_daily_calls.source bereit.") diff --git a/backend/ki.py b/backend/ki.py index dd3b426..b2224f9 100644 --- a/backend/ki.py +++ b/backend/ki.py @@ -25,7 +25,7 @@ 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-opus-4-6") +CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-sonnet-4-6") ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") # Lazy Imports — nur laden wenn wirklich benötigt @@ -70,6 +70,7 @@ async def complete( user_is_premium: bool = False, json_mode: bool = False, return_model: bool = False, + return_source: bool = False, ) -> str: """ KI-Completion. Wählt automatisch den richtigen Backend. @@ -81,9 +82,10 @@ async def complete( 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 + KI-Antwort als String, oder (str, str) wenn return_source=True Raises: KIPremiumRequired: Cloud-Feature ohne Premium @@ -101,20 +103,26 @@ async def complete( # Cloud-Aufruf: nur wenn Premium UND cloud-Modus if requires_premium and user_is_premium and KI_MODE == "cloud": text = await _cloud_complete(prompt, system, max_tokens, json_mode) - return (text, CLOUD_MODEL) if return_model else text + if return_model: + return (text, CLOUD_MODEL) + return (text, "cloud") if return_source else text # Lokaler Aufruf: Entwicklung + Free-User if KI_MODE in ("local", "cloud"): try: text = await _local_complete(prompt, system, max_tokens, json_mode) - return (text, LOCAL_MODEL) if return_model else text + 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.") text = await _cloud_complete(prompt, system, max_tokens, json_mode) - return (text, CLOUD_MODEL) if return_model else text + if return_model: + return (text, CLOUD_MODEL) + return (text, "cloud") if return_source else text raise KIUnavailableError( "KI-Modell momentan nicht erreichbar. Bitte später erneut versuchen." ) from e diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 0f14a55..38e6d1c 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -160,8 +160,30 @@ async def stats(user=Depends(require_mod)): 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 = { + 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" + ).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" + ).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 # Social Media Tracking try: @@ -217,6 +239,12 @@ async def stats(user=Depends(require_mod)): "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, "social_total": social_total, "social_published": social_published, "social_scheduled": social_scheduled, diff --git a/backend/routes/social.py b/backend/routes/social.py index 53f7901..f0f4d30 100644 --- a/backend/routes/social.py +++ b/backend/routes/social.py @@ -466,9 +466,19 @@ _PFLEGE_TIPPS = [ "fell_typ":"lang","saison":None,"tipp":"Von Beinen und Schwanz beginnen — dort filzt es zuerst."}, {"id":"fell_buersten_lockig","titel":"Pflege bei Lockenfell (Pudel, Labradoodle)","kat":"Fell", "beschreibung":"Lockiges Fell verliert kaum Haare, verfilzt aber stark — spezielle Technik nötig.", - "schritte":["Täglich durchkämmen mit Metallkamm","Verfilzungen mit Finger lösen, dann Kamm","Alle 6–8 Wochen Schertermin","Zwischen Augen und Pfoten regelmäßig trimmen"], + "schritte":["Täglich durchkämmen mit Metallkamm","Verfilzungen mit Finger lösen, dann Kamm","Alle 6–8 Wochen Schertermin","Zwischen Augen und Pfoten regelmäßig kürzen"], "materialien":"Metallkamm, Pin-Bürste, Schere","haeufigkeit":"Täglich + 6–8 Wochen Schertermin", - "fell_typ":"lockig","saison":None,"tipp":"Lockiges Fell = hypoallergen, aber Pflegeaufwand unterschätzt!"}, + "fell_typ":"lockig","saison":None,"fell_pflege_art":"schneiden","tipp":"Lockiges Fell = hypoallergen, aber Pflegeaufwand unterschätzt!"}, + {"id":"fell_scheren_technik","titel":"Fell schneiden: Technik & Scheren-Tipps","kat":"Fell", + "beschreibung":"Für Rassen mit kontinuierlichem Fellwuchs (Pudel, Bichon, Spoodle) — Scheren statt Trimmen!", + "schritte":["Fell nach dem Bad komplett trocknen und bürsten","Schere oder Clipper parallel zur Haarwuchsrichtung führen","Empfindliche Stellen (Gesicht, Pfoten) mit Effilierschere","Alle 6–8 Wochen zum Groomer oder selbst lernen","Nach dem Scheren: Fell bürsten und kontrollieren"], + "materialien":"Effilierschere, Clipper, Metallkamm","haeufigkeit":"Alle 6–8 Wochen", + "fell_typ":"lockig","saison":None,"fell_pflege_art":"schneiden","tipp":"Nie nasses Fell scheren — immer erst trocknen, sonst ungleichmäßiges Ergebnis."}, + {"id":"fell_trimmen_technik","titel":"Fell trimmen: Stripping beim Rauhaar-Terrier","kat":"Fell", + "beschreibung":"Rauhaardrahtiges Fell (Westie, Schnauzer, Jack Russell) hat natürliche Wachstumsbegrenzung — Trimmen statt Scheren!", + "schritte":["Daumen und Zeigefinger: abgestorbene Haare zupfen (Stripping)","Immer in Haarwuchsrichtung arbeiten","Unterwolle mit feinem Rake ausbürsten","Niemals scheren — zerstört Textur dauerhaft","Alle 3–4 Monate professionelles Hand-Stripping"], + "materialien":"Stripper-Messer (Trimmmesser), Rake, Kreide für Grip","haeufigkeit":"Alle 3–4 Monate", + "fell_typ":"alle","saison":None,"fell_pflege_art":"trimmen","tipp":"Geschorenes Drahthaarfell verliert seine typische Textur dauerhaft — immer trimmen!"}, {"id":"fell_unterwolle", "titel":"Unterwolle ausbürsten (Fellwechsel)","kat":"Fell", "beschreibung":"Zweimal jährlich toter Unterwolle-Berg — richtig ausgebürstet statt überall verteilt.", "schritte":["Undercoat-Rake gegen Haarwuchsrichtung","Abschnittweise: Rücken, Flanken, Bauch","Furminator maximal 2x/Woche","Nach dem Bürsten: Hund ausschütteln lassen"], @@ -949,6 +959,28 @@ async def _ki_complete(prompt: str) -> str: ) +async def _ki_complete_tracked(prompt: str, user_id: int) -> str: + """Wie _ki_complete, zählt die Anfrage in ki_daily_calls (source='luna').""" + import datetime as _dt + import ki as ki_module + text = await ki_module.complete( + prompt, + system=_SYSTEM, + max_tokens=1200, + requires_premium=False, + ) + today = str(_dt.date.today()) + try: + with db() as conn: + conn.execute(""" + INSERT INTO ki_daily_calls (user_id, date, count, source) VALUES (?, ?, 1, 'luna') + ON CONFLICT(user_id, date, source) DO UPDATE SET count = count + 1 + """, (user_id, today)) + except Exception: + pass + return text + + def _parse_json(raw: str) -> dict: import re try: @@ -1064,7 +1096,7 @@ async def generate_content(req: GenerateRequest, user=Depends(require_social_med ) try: - raw = await _ki_complete(prompt) + raw = await _ki_complete_tracked(prompt, user["id"]) data = _parse_json(raw) except Exception as e: logger.error("Social-Media-Generierung fehlgeschlagen: %s", e) @@ -1113,7 +1145,7 @@ async def evaluate_content(req: EvaluateRequest, user=Depends(require_social_med ) try: - raw = await _ki_complete(prompt) + raw = await _ki_complete_tracked(prompt, user["id"]) data = _parse_json(raw) except Exception as e: raise HTTPException(500, f"KI-Fehler: {e}") @@ -1197,13 +1229,21 @@ def _seed_pflege(): conn.execute( """INSERT OR IGNORE INTO pflege_tipps (tipp_id, titel, kategorie, beschreibung, schritte, - materialien, haeufigkeit, fell_typ, saison, rassengruppe, tipp) - VALUES (?,?,?,?,?,?,?,?,?,?,?)""", + materialien, haeufigkeit, fell_typ, saison, rassengruppe, tipp, + fell_pflege_art) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", (p["id"], p["titel"], p["kat"], p.get("beschreibung"), json.dumps(p.get("schritte", []), ensure_ascii=False), p.get("materialien"), p.get("haeufigkeit"), - p.get("fell_typ"), p.get("saison"), p.get("rassengruppe"), p.get("tipp")), + p.get("fell_typ"), p.get("saison"), p.get("rassengruppe"), p.get("tipp"), + p.get("fell_pflege_art")), ) + # UPDATE für bereits bestehende Tipps (idempotent) + if p.get("fell_pflege_art"): + conn.execute( + "UPDATE pflege_tipps SET fell_pflege_art=? WHERE tipp_id=? AND (fell_pflege_art IS NULL OR fell_pflege_art != ?)", + (p["fell_pflege_art"], p["id"], p["fell_pflege_art"]), + ) try: _seed_exercises() @@ -1251,7 +1291,7 @@ async def training_tip(user=Depends(require_social_media)): ) try: - raw = await _ki_complete(prompt) + raw = await _ki_complete_tracked(prompt, user["id"]) data = _parse_json(raw) except Exception as e: raise HTTPException(500, f"KI-Fehler: {e}") @@ -1407,7 +1447,7 @@ async def breed_of_day(user=Depends(require_social_media)): ) try: - raw = await _ki_complete(prompt) + raw = await _ki_complete_tracked(prompt, user["id"]) data = _parse_json(raw) except Exception as e: raise HTTPException(500, f"KI-Fehler: {e}") @@ -1546,7 +1586,7 @@ async def pflege_tipp(breed_id: Optional[int] = None, user=Depends(require_socia ) try: - raw = await _ki_complete(prompt) + raw = await _ki_complete_tracked(prompt, user["id"]) data = _parse_json(raw) except Exception as e: raise HTTPException(500, f"KI-Fehler: {e}") diff --git a/backend/routes/training.py b/backend/routes/training.py index c1f8dbc..b802a1b 100644 --- a/backend/routes/training.py +++ b/backend/routes/training.py @@ -777,11 +777,10 @@ async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)): ).fetchone()[0] if age_hours < 6 and new_since == 0: - daily_used = conn.execute( - "SELECT COALESCE(count,0) FROM ki_daily_calls WHERE user_id=? AND date=?", + used = conn.execute( + "SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE user_id=? AND date=?", (uid, today) - ).fetchone() - used = daily_used[0] if daily_used else 0 + ).fetchone()[0] return {"feedback": cache_row["feedback"], "cached": True, "daily_used": used, "daily_limit": KI_DAILY_LIMIT} @@ -791,14 +790,14 @@ async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)): user_id INTEGER NOT NULL, date TEXT NOT NULL, count INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (user_id, date) + source TEXT NOT NULL DEFAULT 'cloud', + PRIMARY KEY (user_id, date, source) ) """) - row = conn.execute( - "SELECT count FROM ki_daily_calls WHERE user_id=? AND date=?", + daily_used = conn.execute( + "SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE user_id=? AND date=?", (uid, today) - ).fetchone() - daily_used = row[0] if row else 0 + ).fetchone()[0] if daily_used >= KI_DAILY_LIMIT: raise HTTPException(429, f"Tages-Limit erreicht ({KI_DAILY_LIMIT} Anfragen/Tag). Morgen wieder verfügbar.") @@ -867,12 +866,13 @@ async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)): ) try: - feedback_text = await ki.complete( + feedback_text, ki_source = await ki.complete( prompt, system=system, max_tokens=400, requires_premium=False, user_is_premium=user.get("is_premium", False), + return_source=True, ) except (ki.KIUnavailableError, ki.KIPremiumRequired) as e: raise HTTPException(503, str(e)) @@ -889,11 +889,11 @@ async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)): (body.dog_id, feedback_text) ) conn.execute(""" - INSERT INTO ki_daily_calls (user_id, date, count) VALUES (?, ?, 1) - ON CONFLICT(user_id, date) DO UPDATE SET count = count + 1 - """, (uid, today)) + INSERT INTO ki_daily_calls (user_id, date, count, source) VALUES (?, ?, 1, ?) + ON CONFLICT(user_id, date, source) DO UPDATE SET count = count + 1 + """, (uid, today, ki_source)) new_count = conn.execute( - "SELECT count FROM ki_daily_calls WHERE user_id=? AND date=?", + "SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE user_id=? AND date=?", (uid, today) ).fetchone()[0] diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 4f1245d..b269f9e 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -207,12 +207,18 @@ window.Page_admin = (() => {
-

KI-Trainer Nutzung (Claude API)

+

KI-Nutzung

${[ - ['Anfragen heute', s.ki_today, 'var(--c-primary)'], - ['Anfragen diesen Monat', s.ki_month, 'var(--c-text-secondary)'], - ['Aktive User heute', s.ki_users_today, 'var(--c-text-secondary)'], + ['☁️ 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)'], ].map(([label, val, color]) => `
${label}