Session 2026-04-22: Training, Fixes, KI-Cloud, Dark-Mode

Training-System:
- Einheit-Dialog Bugs behoben (UI.toast callable, _dogId via _appState, activeDog.id)
- Virtueller Trainer (rein statistisch): üben/festigen/entdecken/levelup
  Empfehlungen auf Basis exercise_progress + sessions, Prognose bis 80%
- Stand erfassen Modal: alle Übungen auf einmal setzen (onboarding)
- Erfolgsindikatoren auf Karten: Ø-Quote + Trend-Pfeil + Anzahl Sessions
- exercise_progress → synthetische Stats im Trainer (ohne Sessions nutzbar)
- Levelup: Tricks empfehlen wenn ≥4 Grundkommandos sitzen
- Kommandos & Fähigkeiten im Hundeprofil + öffentlichem Profil
- 2 neue Problemverhalten-Übungen: Bellen/Kläffen, Enttriggern

Mobile/UI-Fixes:
- Übungskarten: Name + Difficulty oben, Buttons eigene Zeile (kein Umbruch)
- Trainingsgrundlagen: Padding in allen Karten, Hinweis-Boxen Dark-Mode-sicher
- Tab-Sichtbarkeit: Trainer/Suggestions nur auf Übungs-Tabs
- Tagebuch FAB (Neu-Eintrag Button) + Quick-Add Eintrag
- FAB Abstand fix (nav-bottom-height + safe-bottom)
- Suggestion-Karten rgba (Dark-Mode)
- routes.js + uebungen.js: alle Hellfarben → rgba (Dark-Mode-sicher)
- ui.js: UI.toast als callable Function-Object (war nur plain Object)

KI & Backend:
- KI_MODE=cloud + ANTHROPIC_API_KEY gesetzt
- ki.py: Cloud-Fallback wenn local nicht erreichbar + KI_MODE=cloud
- KI-Trainer Tageslimit 10 Anfragen/User + ki_daily_calls Tabelle
- Admin-Panel: KI-Nutzung (heute/Monat/User)
- Status-Report Fix (lost-Tabelle) → 06:00 + 18:00 täglich
- Wiki-Anreicherung läuft jetzt (50 Rassen Startup, 20/Nacht)
- landing.html: Trainings-Features in JSON-LD + Feature-Karten
This commit is contained in:
rene 2026-04-22 19:41:22 +02:00
parent 2b442ebd98
commit 44081a6b9d
16 changed files with 938 additions and 117 deletions

View file

@ -490,13 +490,267 @@ async def get_stats(dog_id: int, user=Depends(get_current_user)):
}
# ------------------------------------------------------------------
# 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(
@ -523,7 +777,31 @@ async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)):
).fetchone()[0]
if age_hours < 6 and new_since == 0:
return {"feedback": cache_row["feedback"], "cached": True}
daily_used = conn.execute(
"SELECT COALESCE(count,0) FROM ki_daily_calls WHERE user_id=? AND date=?",
(uid, today)
).fetchone()
used = daily_used[0] if daily_used else 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,
PRIMARY KEY (user_id, date)
)
""")
row = conn.execute(
"SELECT count FROM ki_daily_calls WHERE user_id=? AND date=?",
(uid, today)
).fetchone()
daily_used = row[0] if row else 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"
@ -599,7 +877,7 @@ async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)):
except (ki.KIUnavailableError, ki.KIPremiumRequired) as e:
raise HTTPException(503, str(e))
# Cache speichern
# Cache speichern + Tageszähler erhöhen
with db() as conn:
conn.execute(
"""
@ -610,5 +888,14 @@ 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))
new_count = conn.execute(
"SELECT count FROM ki_daily_calls WHERE user_id=? AND date=?",
(uid, today)
).fetchone()[0]
return {"feedback": feedback_text, "cached": False}
return {"feedback": feedback_text, "cached": False,
"daily_used": new_count, "daily_limit": KI_DAILY_LIMIT}