banyaro/backend/routes/training.py
rene 180de32e57 Session 2026-04-21: SEO, Wiki-Anreicherung, Training, Lober
SEO & Crawler:
- robots.txt, llms.txt, sitemap.xml (508 Seiten bei Google)
- SSR-Seiten: /info, /wiki/rassen, /wiki/rasse/{slug}, /knigge
- Open Graph, JSON-LD, Breadcrumbs in index.html

Navigation:
- Training unter "Mein Hund", Wissen collapsible
- Welcome-Seite und Landing-Page auf 5-Gruppen-Struktur

Wiki:
- KI-Anreicherung (Claude API): beschreibung, vorkommen_de, Steckbrief
- "So einen hab ich" / Züchter-Verzeichnis
- Scheduler: 50 Rassen beim Start, 20/Nacht

Training:
- Session-Logging (Erfolgsquote, Stimmung, Zufriedenheit)
- Virtueller KI-Trainer (6h-Cache)
- Trainingskalender (Habit-Tracker)
- Top-Training → automatischer Tagebucheintrag
- Gamification ohne Druck: Badges, Streak, Stats

Fortschritts-Lober:
- Jeden Montag 09:00: Claude schreibt Lob-Text pro Hund
- Push + Karte im Tagebuch

Monitoring:
- 4× täglich Status-Mail mit Scheduler-Status + Wiki-Fortschritt
2026-04-21 19:38:20 +02:00

614 lines
21 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
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 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: 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}