"""BAN YARO — Übungs- & Trainingsfortschritt""" from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional import datetime import ki from database import db from auth import get_current_user router = APIRouter() # ------------------------------------------------------------------ # Übungs-Status # ------------------------------------------------------------------ class ProgressUpdate(BaseModel): exercise_id: str status: Optional[str] = None # null/noch-nicht/manchmal/meistens/sitzt @router.get("/progress") async def get_progress(user=Depends(get_current_user)): uid = user["id"] with db() as conn: rows = conn.execute( "SELECT exercise_id, status, updated_at FROM exercise_progress WHERE user_id=?", (uid,) ).fetchall() return [dict(r) for r in rows] @router.post("/progress") async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)): uid = user["id"] with db() as conn: conn.execute(""" INSERT INTO exercise_progress (user_id, exercise_id, status) VALUES (?,?,?) ON CONFLICT(user_id, exercise_id) DO UPDATE SET status=excluded.status, updated_at=datetime('now') """, (uid, body.exercise_id, body.status)) return {"ok": True} # ------------------------------------------------------------------ # Trainingsplan-Checkboxen # ------------------------------------------------------------------ class PlanProgress(BaseModel): item_key: str checked: bool @router.get("/plan-progress") async def get_plan_progress(user=Depends(get_current_user)): uid = user["id"] with db() as conn: rows = conn.execute( "SELECT item_key, checked FROM training_plan_progress WHERE user_id=?", (uid,) ).fetchall() return [dict(r) for r in rows] @router.post("/plan-progress") async def upsert_plan_progress(body: PlanProgress, user=Depends(get_current_user)): uid = user["id"] with db() as conn: if body.checked: conn.execute(""" INSERT OR REPLACE INTO training_plan_progress (user_id, item_key, checked) VALUES (?,?,1) """, (uid, body.item_key)) else: conn.execute( "DELETE FROM training_plan_progress WHERE user_id=? AND item_key=?", (uid, body.item_key) ) return {"ok": True} # ------------------------------------------------------------------ # Empfehlungen (rule-based) # ------------------------------------------------------------------ GRUNDKOMMANDOS_ORDER = ['Sitz', 'Platz', 'Bleib', 'Hier / Komm', 'Fuß', 'Aus / Lass es', 'Warte'] TRICKS_FIRST = ['Pfote / Schütteln', 'Dreh', 'Auf die Decke', 'Nasenarbeit / Suchen'] @router.get("/suggestions") async def get_suggestions(user=Depends(get_current_user)): uid = user["id"] with db() as conn: rows = conn.execute( "SELECT exercise_id, status FROM exercise_progress WHERE user_id=?", (uid,) ).fetchall() progress = {r["exercise_id"]: r["status"] for r in rows} def key(name): return f"grundkommandos_{name.replace(' ', '_').replace('/', '')}" def tkey(name): return f"tricks_{name.replace(' ', '_').replace('/', '')}" suggestions = [] # Noch-nicht Übungen — direkte Hilfe stuck = [n for n in GRUNDKOMMANDOS_ORDER if progress.get(key(n)) == 'noch-nicht'] if stuck: suggestions.append({ "type": "help", "icon": "warning", "title": f'\u201e{stuck[0]}\u201c klappt noch nicht', "text": "Mach einen Schritt zurück: Kürzere Einheiten, mehr Leckerlis, weniger Ablenkung. Schau dir die Trainingsgrundlagen an.", "action_tab": "grundkommandos", "action_name": stuck[0], }) # Manchmal-Übungen — intensivieren almost = [n for n in GRUNDKOMMANDOS_ORDER if progress.get(key(n)) == 'manchmal'] if almost: suggestions.append({ "type": "boost", "icon": "fire", "title": f'Fast da: \u201e{almost[0]}\u201c', "text": "Du bist auf dem richtigen Weg! Übe täglich 3–5 Minuten — dann sitzt es bald.", "action_tab": "grundkommandos", "action_name": almost[0], }) # Nächste ungestartete Grundkommando grundk_mastered = [n for n in GRUNDKOMMANDOS_ORDER if progress.get(key(n)) == 'sitzt'] next_up = next((n for n in GRUNDKOMMANDOS_ORDER if progress.get(key(n)) in (None, 'noch-nicht') and n not in stuck), None) if len(grundk_mastered) == len(GRUNDKOMMANDOS_ORDER): # Alle Grundkommandos gemeistert → Tricks empfehlen next_trick = next((n for n in TRICKS_FIRST if progress.get(tkey(n)) is None), None) if next_trick: suggestions.append({ "type": "next", "icon": "star", "title": "Alle Grundkommandos sitzen! 🎉", "text": f'Zeit für Tricks! Starte mit \u201e{next_trick}\u201c \u2014 Spaß garantiert.', "action_tab": "tricks", "action_name": next_trick, }) elif next_up and not almost: suggestions.append({ "type": "next", "icon": "arrow-right", "title": f"Bereit für den nächsten Schritt?", "text": f'Starte jetzt mit \u201e{next_up}\u201c. {"Du hast bereits " + str(len(grundk_mastered)) + " Grundkommandos gemeistert!" if grundk_mastered else "Es ist die perfekte Basis für alles Weitere."}', "action_tab": "grundkommandos", "action_name": next_up, }) elif not progress: # Noch gar kein Fortschritt suggestions.append({ "type": "start", "icon": "flag", "title": "Womit fangen wir an?", "text": "\u201eSitz\u201c ist das erste Kommando für jeden Hund \u2014 einfach, schnell erlernt und die Basis für alles.", "action_tab": "grundkommandos", "action_name": "Sitz", }) return suggestions[:2] # max 2 Empfehlungen # ------------------------------------------------------------------ # Training: Session-Protokoll # ------------------------------------------------------------------ STIMMUNGS_LABELS = { "aufmerksam": "aufmerksam", "muede": "müde", "abgelenkt": "abgelenkt", "super": "super motiviert", } TRAINING_BADGES = [ ("training_first", 1, "Erste Trainingseinheit", "Ihr habt gemeinsam die erste Einheit abgeschlossen \U0001f43e"), ("training_5", 5, "5 Einheiten", "5 Trainingseinheiten \u2014 ihr seid dabei!"), ("training_10", 10, "10 Einheiten", "10 Einheiten! {name} macht gro\u00dfartige Fortschritte."), ("training_25", 25, "25 Einheiten", "25 Einheiten \u2014 eine echte Trainingspartnerschaft!"), ("training_50", 50, "50 Einheiten", "50 Einheiten! {name} und du seid ein echtes Team."), ("training_top_5", 5, "5 Top-Trainings", "5 Top-Trainings \u2014 {name} ist ein Schnellerner!"), ] def _check_badges(conn, user_id: int, dog_name: str) -> list: """Prüft und vergibt Trainings-Badges. Gibt neu verdiente Badges zurück.""" total = conn.execute( "SELECT COUNT(*) FROM training_sessions WHERE user_id=?", (user_id,) ).fetchone()[0] top_count = conn.execute( "SELECT COUNT(*) FROM training_sessions WHERE user_id=? AND ist_top=1", (user_id,) ).fetchone()[0] existing = { r[0] for r in conn.execute( "SELECT badge_id FROM user_badges WHERE user_id=?", (user_id,) ).fetchall() } new_badges = [] for badge_id, threshold, title, desc_tpl in TRAINING_BADGES: if badge_id in existing: continue count = top_count if badge_id == "training_top_5" else total if count >= threshold: desc = desc_tpl.format(name=dog_name) conn.execute( "INSERT OR IGNORE INTO user_badges (user_id, badge_id) VALUES (?,?)", (user_id, badge_id) ) new_badges.append({"id": badge_id, "title": title, "desc": desc}) return new_badges class SessionCreate(BaseModel): dog_id: int exercise_id: str exercise_name: str datum: Optional[str] = None wiederholungen: int = 1 erfolgsquote: int = 50 hund_stimmung: str = "aufmerksam" zufriedenheit: int = 3 notiz: Optional[str] = None tagebuch_eintrag: bool = False @router.post("/sessions") async def log_session(body: SessionCreate, user=Depends(get_current_user)): uid = user["id"] with db() as conn: # Hund-Zugehörigkeit prüfen dog = conn.execute( "SELECT id, name FROM dogs WHERE id=? AND user_id=?", (body.dog_id, uid) ).fetchone() if not dog: raise HTTPException(404, "Hund nicht gefunden.") dog_name = dog["name"] datum = body.datum or datetime.date.today().isoformat() ist_top = int(body.erfolgsquote >= 80 and body.zufriedenheit >= 4) cur = conn.execute( """ INSERT INTO training_sessions (user_id, dog_id, exercise_id, exercise_name, datum, wiederholungen, erfolgsquote, hund_stimmung, zufriedenheit, notiz, ist_top) VALUES (?,?,?,?,?,?,?,?,?,?,?) """, (uid, body.dog_id, body.exercise_id, body.exercise_name, datum, body.wiederholungen, body.erfolgsquote, body.hund_stimmung, body.zufriedenheit, body.notiz, ist_top) ) session_id = cur.lastrowid # Badges prüfen new_badges = _check_badges(conn, uid, dog_name) # Tagebucheintrag erstellen? diary_entry_id = None if body.tagebuch_eintrag or ist_top: stimmung_label = STIMMUNGS_LABELS.get(body.hund_stimmung, body.hund_stimmung) if ist_top: titel = f"\U0001f3af {body.exercise_name} \u2014 Top-Training!" else: titel = f"\U0001f3af Training: {body.exercise_name}" text_parts = [ f"{body.wiederholungen} Wiederholungen \u00b7 " f"Erfolgsquote: {body.erfolgsquote}% \u00b7 " f"Stimmung: {stimmung_label}" ] if body.notiz: text_parts.append(f"\n\n{body.notiz}") eintrag_text = "".join(text_parts) diary_cur = conn.execute( """ INSERT INTO diary (dog_id, datum, typ, titel, text) VALUES (?,?,?,?,?) """, (body.dog_id, datum, "training", titel, eintrag_text) ) diary_entry_id = diary_cur.lastrowid conn.execute( "INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?,?)", (diary_entry_id, body.dog_id) ) conn.execute( "UPDATE training_sessions SET diary_entry_id=? WHERE id=?", (diary_entry_id, session_id) ) session = { "id": session_id, "user_id": uid, "dog_id": body.dog_id, "exercise_id": body.exercise_id, "exercise_name": body.exercise_name, "datum": datum, "wiederholungen": body.wiederholungen, "erfolgsquote": body.erfolgsquote, "hund_stimmung": body.hund_stimmung, "zufriedenheit": body.zufriedenheit, "notiz": body.notiz, "ist_top": bool(ist_top), "diary_entry_id": diary_entry_id, } return { "session": session, "ist_top": bool(ist_top), "badges": new_badges, "diary_entry_id": diary_entry_id, } @router.get("/sessions") async def get_sessions( dog_id: int, limit: int = 50, offset: int = 0, user=Depends(get_current_user) ): uid = user["id"] with db() as conn: dog = conn.execute( "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid) ).fetchone() if not dog: raise HTTPException(404, "Hund nicht gefunden.") rows = conn.execute( """ SELECT * FROM training_sessions WHERE user_id=? AND dog_id=? ORDER BY datum DESC, created_at DESC LIMIT ? OFFSET ? """, (uid, dog_id, limit, offset) ).fetchall() return [dict(r) for r in rows] @router.get("/calendar") async def get_calendar( dog_id: int, year: Optional[int] = None, month: Optional[int] = None, user=Depends(get_current_user) ): uid = user["id"] today = datetime.date.today() year = year or today.year month = month or today.month month_str = f"{year:04d}-{month:02d}" with db() as conn: dog = conn.execute( "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid) ).fetchone() if not dog: raise HTTPException(404, "Hund nicht gefunden.") rows = conn.execute( """ SELECT datum, COUNT(*) AS count, MAX(ist_top) AS top FROM training_sessions WHERE user_id=? AND dog_id=? AND datum LIKE ? GROUP BY datum """, (uid, dog_id, month_str + "-%") ).fetchall() days = { r["datum"]: {"count": r["count"], "top": bool(r["top"])} for r in rows } return {"days": days} @router.get("/stats") async def get_stats(dog_id: int, user=Depends(get_current_user)): uid = user["id"] with db() as conn: dog = conn.execute( "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid) ).fetchone() if not dog: raise HTTPException(404, "Hund nicht gefunden.") total_sessions = conn.execute( "SELECT COUNT(*) FROM training_sessions WHERE user_id=? AND dog_id=?", (uid, dog_id) ).fetchone()[0] total_exercises = conn.execute( "SELECT COUNT(DISTINCT exercise_id) FROM training_sessions WHERE user_id=? AND dog_id=?", (uid, dog_id) ).fetchone()[0] avg_row = conn.execute( "SELECT AVG(erfolgsquote) FROM training_sessions WHERE user_id=? AND dog_id=?", (uid, dog_id) ).fetchone() avg_erfolgsquote = round(avg_row[0], 1) if avg_row[0] is not None else 0.0 best_row = conn.execute( """ SELECT exercise_name, AVG(erfolgsquote) AS avg_e FROM training_sessions WHERE user_id=? AND dog_id=? GROUP BY exercise_id ORDER BY avg_e DESC LIMIT 1 """, (uid, dog_id) ).fetchone() best_exercise = ( {"name": best_row["exercise_name"], "avg_erfolg": round(best_row["avg_e"], 1)} if best_row else None ) training_days = conn.execute( "SELECT COUNT(DISTINCT datum) FROM training_sessions WHERE user_id=? AND dog_id=?", (uid, dog_id) ).fetchone()[0] top_sessions = conn.execute( "SELECT COUNT(*) FROM training_sessions WHERE user_id=? AND dog_id=? AND ist_top=1", (uid, dog_id) ).fetchone()[0] today = datetime.date.today() week_start = (today - datetime.timedelta(days=today.weekday())).isoformat() this_week = conn.execute( "SELECT COUNT(*) FROM training_sessions WHERE user_id=? AND dog_id=? AND datum >= ?", (uid, dog_id, week_start) ).fetchone()[0] month_start = today.replace(day=1).isoformat() this_month = conn.execute( "SELECT COUNT(*) FROM training_sessions WHERE user_id=? AND dog_id=? AND datum >= ?", (uid, dog_id, month_start) ).fetchone()[0] # Streak: aufeinanderfolgende Tage bis heute day_rows = conn.execute( """ SELECT DISTINCT datum FROM training_sessions WHERE user_id=? AND dog_id=? ORDER BY datum DESC """, (uid, dog_id) ).fetchall() streak_days = 0 check = today for row in day_rows: d = datetime.date.fromisoformat(row["datum"]) if d == check: streak_days += 1 check -= datetime.timedelta(days=1) else: break return { "total_sessions": total_sessions, "total_exercises": total_exercises, "avg_erfolgsquote": avg_erfolgsquote, "best_exercise": best_exercise, "training_days": training_days, "top_sessions": top_sessions, "this_week": this_week, "this_month": this_month, "streak_days": streak_days, } class KiFeedbackRequest(BaseModel): dog_id: int @router.post("/ki-feedback") async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)): uid = user["id"] with db() as conn: dog = conn.execute( "SELECT id, name, rasse, geburtstag FROM dogs WHERE id=? AND user_id=?", (body.dog_id, uid) ).fetchone() if not dog: raise HTTPException(404, "Hund nicht gefunden.") # Cache prüfen cache_row = conn.execute( "SELECT feedback, generated_at FROM training_ki_cache WHERE dog_id=?", (body.dog_id,) ).fetchone() if cache_row: generated_at = datetime.datetime.fromisoformat(cache_row["generated_at"]) age_hours = (datetime.datetime.utcnow() - generated_at).total_seconds() / 3600 # Neue Sessions seit letzter Generierung? new_since = conn.execute( "SELECT COUNT(*) FROM training_sessions WHERE dog_id=? AND created_at > ?", (body.dog_id, cache_row["generated_at"]) ).fetchone()[0] if age_hours < 6 and new_since == 0: return {"feedback": cache_row["feedback"], "cached": True} # Hund-Alter berechnen alter_str = "unbekannt" if dog["geburtstag"]: try: geb = datetime.date.fromisoformat(dog["geburtstag"]) alter_jahre = (datetime.date.today() - geb).days // 365 alter_str = f"{alter_jahre} Jahre" except Exception: pass # Letzte 20 Sessions laden sessions = conn.execute( """ SELECT datum, exercise_name, wiederholungen, erfolgsquote, hund_stimmung, zufriedenheit, notiz, ist_top FROM training_sessions WHERE dog_id=? ORDER BY datum DESC, created_at DESC LIMIT 20 """, (body.dog_id,) ).fetchall() # Übungsfortschritt laden progress_rows = conn.execute( "SELECT exercise_id, status FROM exercise_progress WHERE user_id=?", (uid,) ).fetchall() # Prompt zusammenbauen dog_name = dog["name"] dog_rasse = dog["rasse"] or "unbekannter Rasse" sessions_text = "\n".join( f" {r['datum']}: {r['exercise_name']}, " f"{r['wiederholungen']}x, {r['erfolgsquote']}% Erfolg, " f"Stimmung: {STIMMUNGS_LABELS.get(r['hund_stimmung'], r['hund_stimmung'])}, " f"Zufriedenheit: {r['zufriedenheit']}/5" + (f", Notiz: {r['notiz']}" if r["notiz"] else "") + (" [TOP]" if r["ist_top"] else "") for r in sessions ) or " (noch keine Sessions)" progress_text = "\n".join( f" {r['exercise_id']}: {r['status']}" for r in progress_rows ) or " (kein Fortschritt erfasst)" prompt = ( f"Hund: {dog_name}, {dog_rasse}, {alter_str}\n" f"Letzte Sessions:\n{sessions_text}\n" f"Übungsfortschritt:\n{progress_text}\n\n" "Antworte mit maximal 3 kurzen Abschnitten:\n" "1. Was gut läuft (1-2 Sätze, immer positiv beginnen)\n" "2. Konkrete Empfehlung (1-2 Sätze, spezifisch auf die Daten bezogen)\n" "3. Kleiner motivierender Abschluss (1 Satz)\n\n" "Sprich über den Hund, nicht über den Besitzer. Kein Druck, keine Forderungen." ) system = ( "Du bist ein einfühlsamer, positiver Hundetrainer. " "Analysiere die Trainingshistorie und gib kurzes, motivierendes Feedback auf Deutsch." ) try: feedback_text = await ki.complete( prompt, system=system, max_tokens=400, requires_premium=False, user_is_premium=user.get("is_premium", False), ) except (ki.KIUnavailableError, ki.KIPremiumRequired) as e: raise HTTPException(503, str(e)) # Cache speichern with db() as conn: conn.execute( """ INSERT INTO training_ki_cache (dog_id, feedback, generated_at) VALUES (?,?,datetime('now')) ON CONFLICT(dog_id) DO UPDATE SET feedback=excluded.feedback, generated_at=excluded.generated_at """, (body.dog_id, feedback_text) ) return {"feedback": feedback_text, "cached": False}