banyaro/backend/routes/training.py
rene c03884cb81 Perf: 9 Performance-Fixes — SW by-v1072
Backend:
- DB: 3 neue Indizes (forum_posts thread+user, routes user) — Forum/Routen-Queries
- Caching: cache.py (TTL-Cache ohne neue Dependency) für 5 statische Listen
  (training_exercises, pflege_tipps, wiki_stats, wiki_gruppen, help_articles)
- diary.py + breeder_photos.py: Bildverarbeitung (ffmpeg/PIL/EXIF) per
  run_in_executor → blockiert Event-Loop nicht mehr
- scheduler.py: 11 kollidierende Jobs auf 5-Min-Intervalle gestaggert, coalesce=True
- social.py: ORDER BY RANDOM() ohne LIMIT in 2 Stellen gefixt
- alerts.py: Haversine-Loop bekommt SQL-Bounding-Box-Vorfilter

Frontend:
- sw.js: Tile-Cache mit LRU-Eviction (max 500 Einträge)
- admin.js: Event-Listener-Leak — Tab-Klicks per Delegation statt N Listener
- api.js: compressImage() Helper — Client-seitiges Resize auf max 2000px
  (HEIC/Videos/<500KB unverändert), integriert in 8 Upload-Stellen
  (diary, dog-profile×2, walks, poison, lost, health×2)

Bump APP_VER 1071 → 1072 (sw.js, app.js, main.py, index.html)
2026-05-26 06:30:36 +02:00

970 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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, require_admin
from cache import ttl_cache
router = APIRouter()
# ------------------------------------------------------------------
# Alle Übungen aus DB (öffentlich, kein Auth)
# Statische Daten → 1h TTL-Cache. Wird in update_exercise() invalidiert.
# ------------------------------------------------------------------
@ttl_cache(ttl=3600)
def _load_exercises_by_tab() -> dict:
import json as _json
CAT_TO_TAB = {
'Grundkommando': 'grundkommandos',
'Trick': 'tricks',
'Problemverhalten': 'problemverhalten',
'Mentale Auslastung': 'mentale-auslastung',
'Hundesport': 'hundesport',
'Koerperpflege': 'koerperpflege',
'Körperpflege': 'koerperpflege',
'Welpe Basics': 'welpe-basics',
}
with db() as conn:
rows = conn.execute("""
SELECT exercise_id, js_exercise_id, name, kategorie, schwierigkeit, alter_ab,
dauer, beschreibung, schritte, tipp
FROM training_exercises ORDER BY kategorie, name
""").fetchall()
by_tab: dict = {}
for r in rows:
tab = CAT_TO_TAB.get(r['kategorie'], r['kategorie'].lower().replace(' ', '-'))
by_tab.setdefault(tab, []).append({
'exercise_id': r['exercise_id'],
'js_exercise_id': r['js_exercise_id'],
'name': r['name'],
'kategorie': tab,
'schwierigkeit': r['schwierigkeit'] or 'mittel',
'alter': r['alter_ab'],
'dauer': r['dauer'],
'beschreibung': r['beschreibung'],
'schritte': _json.loads(r['schritte'] or '[]'),
'tipp': r['tipp'],
})
return by_tab
@router.get("/exercises")
async def get_exercises():
"""Alle Übungen aus der DB, gruppiert nach Tab-ID (1h-Cache)."""
return _load_exercises_by_tab()
# ------------------------------------------------------------------
# Admin: Übung bearbeiten (beschreibung / schritte / tipp)
# ------------------------------------------------------------------
class ExerciseUpdate(BaseModel):
beschreibung: Optional[str] = None
schritte: Optional[str] = None # JSON-String: '["Schritt 1", ...]'
tipp: Optional[str] = None
@router.put("/exercises/{exercise_id}")
async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(require_admin)):
"""Partial update von beschreibung/schritte/tipp einer Übung (nur Admin)."""
with db() as conn:
row = conn.execute(
"SELECT id FROM training_exercises WHERE id=?", (exercise_id,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
fields, vals = [], []
if body.beschreibung is not None:
fields.append("beschreibung=?"); vals.append(body.beschreibung)
if body.schritte is not None:
fields.append("schritte=?"); vals.append(body.schritte)
if body.tipp is not None:
fields.append("tipp=?"); vals.append(body.tipp)
if not fields:
return {"ok": True, "updated": 0}
vals.append(exercise_id)
conn.execute(f"UPDATE training_exercises SET {', '.join(fields)} WHERE id=?", vals)
# Cache invalidieren, damit der Admin-Edit sofort sichtbar wird
_load_exercises_by_tab.cache_clear()
return {"ok": True, "updated": len(fields)}
# ------------------------------------------------------------------
# Übungs-Status
# ------------------------------------------------------------------
class ProgressUpdate(BaseModel):
exercise_id: str
status: Optional[str] = None
dog_id: Optional[int] = None
@router.get("/progress")
async def get_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
if dog_id:
rows = conn.execute(
"SELECT exercise_id, status, updated_at FROM exercise_progress WHERE dog_id=?",
(dog_id,)
).fetchall()
else:
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:
if body.dog_id:
conn.execute("""
INSERT INTO exercise_progress (user_id, dog_id, exercise_id, status)
VALUES (?,?,?,?)
ON CONFLICT(dog_id, exercise_id) DO UPDATE
SET status=excluded.status, updated_at=datetime('now')
""", (uid, body.dog_id, body.exercise_id, body.status))
else:
conn.execute("""
INSERT INTO exercise_progress (user_id, exercise_id, status)
VALUES (?,?,?)
ON CONFLICT(dog_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
dog_id: Optional[int] = None
@router.get("/plan-progress")
async def get_plan_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
if dog_id:
rows = conn.execute(
"SELECT item_key, checked FROM training_plan_progress WHERE dog_id=?",
(dog_id,)
).fetchall()
else:
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, dog_id, item_key, checked)
VALUES (?,?,?,1)
""", (uid, body.dog_id, body.item_key))
else:
conn.execute(
"DELETE FROM training_plan_progress WHERE dog_id=? AND item_key=?",
(body.dog_id, 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(dog_id: Optional[int] = None, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
if dog_id:
rows = conn.execute(
"SELECT exercise_id, status FROM exercise_progress WHERE dog_id=?",
(dog_id,)
).fetchall()
else:
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 35 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: Optional[str] = "aufmerksam"
zufriedenheit: Optional[int] = 3
notiz: Optional[str] = None
tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll
@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)
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),
}
return {
"session": session,
"ist_top": bool(ist_top),
"badges": new_badges,
}
@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,
}
# ------------------------------------------------------------------
# Virtueller Trainer — statistikbasierte Empfehlungen
# ------------------------------------------------------------------
import math
from collections import defaultdict
def _recommend_reps(recent_avg: float, last_reps: int, last_mood: str) -> int:
base = last_reps or 5
if recent_avg >= 80:
reps = min(base + 1, 10)
elif recent_avg >= 60:
reps = base
elif recent_avg < 40:
reps = max(base - 2, 2)
else:
reps = max(base - 1, 3)
if last_mood == "müde":
reps = max(reps - 1, 2)
elif last_mood == "super":
reps = min(reps + 1, 10)
return reps
def _reason(type_: str, s: dict) -> str:
days, recent, trend = s["days_since"], s["recent_avg"], s["trend"]
if type_ == "üben":
if trend == "declining":
return f"Erfolgsquote fällt zuletzt {round(recent)}%."
if recent < 40:
return f"Noch Luft nach oben: {round(recent)}% zuletzt."
return f"Braucht Aufmerksamkeit: {round(recent)}% Erfolg."
if type_ == "festigen":
if trend == "improving":
return f"Guter Fortschritt auf {round(recent)}% dran bleiben."
if days >= 3:
return f"Seit {days} Tagen nicht geübt kurz auffrischen."
return f"Läuft gut ({round(recent)}%) festigen."
# entdecken
return f"Seit {days} Tag{'en' if days != 1 else ''} nicht geübt."
# Synthetische Stats wenn nur exercise_progress vorhanden (keine Sessions)
_STATUS_SYNTHETIC = {
'noch-nicht': {'recent_avg': 25.0, 'trend': 'new', 'days_since': 5, 'suggested_reps': 3},
'manchmal': {'recent_avg': 45.0, 'trend': 'stable', 'days_since': 7, 'suggested_reps': 5},
'meistens': {'recent_avg': 72.0, 'trend': 'stable', 'days_since': 5, 'suggested_reps': 5},
'sitzt': {'recent_avg': 92.0, 'trend': 'stable', 'days_since': 14, 'suggested_reps': 7},
}
def _ex_key(tab: str, name: str) -> str:
return f"{tab}_{name.replace(' ', '_').replace('/', '')}"
def _parse_ex_name(exercise_id: str) -> str:
parts = exercise_id.split("_", 1)
if len(parts) < 2:
return exercise_id
return parts[1].replace("__", " / ").replace("_", " ")
@router.get("/recommendations")
async def get_recommendations(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
today = datetime.date.today()
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 exercise_id, exercise_name, datum, wiederholungen,
erfolgsquote, hund_stimmung
FROM training_sessions
WHERE user_id=? AND dog_id=?
ORDER BY datum DESC, created_at DESC
""",
(uid, dog_id)
).fetchall()
progress_rows = conn.execute(
"SELECT exercise_id, status FROM exercise_progress WHERE user_id=?",
(uid,)
).fetchall()
progress = {r["exercise_id"]: r["status"] for r in progress_rows}
# --- Session-basierte Stats ---
by_ex = defaultdict(list)
for r in rows:
by_ex[r["exercise_id"]].append(dict(r))
stats = []
session_ids = set()
for ex_id, sessions in by_ex.items():
session_ids.add(ex_id)
last = sessions[0]
last_date = datetime.date.fromisoformat(last["datum"])
days_since = (today - last_date).days
all_success = [s["erfolgsquote"] for s in sessions]
avg_success = sum(all_success) / len(all_success)
recent_avg = sum(s["erfolgsquote"] for s in sessions[:3]) / len(sessions[:3])
older = sessions[3:6]
if older:
older_avg = sum(s["erfolgsquote"] for s in older) / len(older)
diff = recent_avg - older_avg
trend = "improving" if diff > 10 else ("declining" if diff < -10 else "stable")
else:
trend = "new"
prognose = None
if trend == "improving" and older:
improvement = recent_avg - (sum(s["erfolgsquote"] for s in older) / len(older))
if improvement > 0 and recent_avg < 80:
prognose = max(1, math.ceil((80 - recent_avg) / (improvement / 3)))
stats.append({
"exercise_id": ex_id,
"exercise_name": last["exercise_name"],
"tab": ex_id.split("_")[0],
"session_count": len(sessions),
"avg_success": round(avg_success, 1),
"recent_avg": round(recent_avg, 1),
"trend": trend,
"days_since": days_since,
"suggested_reps": _recommend_reps(recent_avg, last["wiederholungen"], last["hund_stimmung"]),
"prognose_sessions": prognose,
"from_status": False,
})
# --- Status-basierte synthetische Stats (Übungen ohne Sessions) ---
for ex_id, status in progress.items():
if ex_id in session_ids or status not in _STATUS_SYNTHETIC:
continue
synth = _STATUS_SYNTHETIC[status]
stats.append({
"exercise_id": ex_id,
"exercise_name": _parse_ex_name(ex_id),
"tab": ex_id.split("_")[0],
"session_count": 0,
"avg_success": synth["recent_avg"],
"recent_avg": synth["recent_avg"],
"trend": synth["trend"],
"days_since": synth["days_since"],
"suggested_reps": synth["suggested_reps"],
"prognose_sessions": None,
"from_status": True,
})
if not stats:
return {"recommendations": [], "all_stats": {}, "summary": {
"total_exercises_trained": 0, "exercises_on_track": 0, "exercises_need_work": 0}}
# --- Levelup: Tricks empfehlen wenn Grundlage stark ---
grundk_ids = [_ex_key("grundkommandos", n) for n in GRUNDKOMMANDOS_ORDER]
grundk_mastered = [eid for eid in grundk_ids if progress.get(eid) in ("sitzt", "meistens")]
levelup_rec = None
if len(grundk_mastered) >= 4:
tricks_pairs = [(_ex_key("tricks", n), n) for n in TRICKS_FIRST]
first_trick = next(
((eid, name) for eid, name in tricks_pairs
if progress.get(eid) is None and eid not in session_ids),
None
)
if first_trick:
eid, name = first_trick
levelup_rec = {
"exercise_id": eid,
"exercise_name": name,
"tab": "tricks",
"session_count": 0,
"avg_success": 0.0,
"recent_avg": 0.0,
"trend": "new",
"days_since": 0,
"suggested_reps": 5,
"prognose_sessions": None,
"from_status": True,
"type": "levelup",
"reason": f"{len(grundk_mastered)} Grundkommandos sitzen — Zeit für den nächsten Level!",
}
# --- Auswahl: üben / festigen / levelup-oder-entdecken ---
def needs_work_score(s):
score = min(s["days_since"], 7) * 0.3 + (100 - s["recent_avg"]) * 0.5
if s["trend"] == "declining":
score += 20
return score
not_today = [s for s in stats if s["days_since"] > 0]
needs_work = sorted([s for s in not_today if s["recent_avg"] < 70], key=needs_work_score, reverse=True)
festigen = sorted([s for s in not_today if s["recent_avg"] >= 70], key=lambda s: s["days_since"], reverse=True)
entdecken = sorted([s for s in not_today if s["days_since"] >= 5], key=lambda s: s["days_since"], reverse=True)
recs = []
used = set()
for pool, type_ in [(needs_work, "üben"), (festigen, "festigen")]:
for s in pool:
if s["exercise_id"] not in used:
recs.append({**s, "type": type_, "reason": _reason(type_, s)})
used.add(s["exercise_id"])
break
# Slot 3: levelup hat Vorrang vor entdecken
if levelup_rec and levelup_rec["exercise_id"] not in used:
recs.append(levelup_rec)
used.add(levelup_rec["exercise_id"])
else:
for s in entdecken:
if s["exercise_id"] not in used:
recs.append({**s, "type": "entdecken", "reason": _reason("entdecken", s)})
used.add(s["exercise_id"])
break
# Auffüllen
if len(recs) < 3:
for s in sorted(not_today, key=lambda s: s["days_since"], reverse=True):
if len(recs) >= 3:
break
if s["exercise_id"] not in used:
recs.append({**s, "type": "üben", "reason": _reason("üben", s)})
used.add(s["exercise_id"])
all_stats = {
s["exercise_id"]: {
"recent_avg": s["recent_avg"],
"session_count": s["session_count"],
"trend": s["trend"],
"days_since": s["days_since"],
}
for s in stats
}
return {
"recommendations": recs[:3],
"all_stats": all_stats,
"summary": {
"total_exercises_trained": len(stats),
"exercises_on_track": len([s for s in stats if s["recent_avg"] >= 70]),
"exercises_need_work": len([s for s in stats if s["recent_avg"] < 60]),
},
}
class KiFeedbackRequest(BaseModel):
dog_id: int
KI_DAILY_LIMIT = 10
@router.post("/ki-feedback")
async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)):
uid = user["id"]
today = datetime.date.today().isoformat()
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:
used = conn.execute(
"SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE user_id=? AND date=?",
(uid, today)
).fetchone()[0]
return {"feedback": cache_row["feedback"], "cached": True,
"daily_used": used, "daily_limit": KI_DAILY_LIMIT}
# Tageslimit prüfen (nur bei echtem API-Aufruf)
conn.execute("""
CREATE TABLE IF NOT EXISTS 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)
)
""")
daily_used = conn.execute(
"SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE user_id=? AND date=?",
(uid, today)
).fetchone()[0]
if daily_used >= KI_DAILY_LIMIT:
raise HTTPException(429, f"Tages-Limit erreicht ({KI_DAILY_LIMIT} Anfragen/Tag). Morgen wieder verfügbar.")
# 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, 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))
# Cache speichern + Tageszähler erhöhen
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)
)
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
""", (uid, today, ki_source))
new_count = conn.execute(
"SELECT COALESCE(SUM(count),0) FROM ki_daily_calls WHERE user_id=? AND date=?",
(uid, today)
).fetchone()[0]
return {"feedback": feedback_text, "cached": False,
"daily_used": new_count, "daily_limit": KI_DAILY_LIMIT}