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:
parent
2b442ebd98
commit
44081a6b9d
16 changed files with 938 additions and 117 deletions
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue