Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 37s
CI / test-go-edu-search (push) Successful in 45s
CI / test-python-agent-core (push) Has been cancelled
CI / test-nodejs-website (push) Has been cancelled
CI / test-python-klausur (push) Has started running
Phase 3.2 — MicrophoneInput.tsx: Browser Web Speech API for
speech-to-text recognition (EN+DE), integrated for pronunciation practice.
Phase 4.1 — Story Generator: LLM-powered mini-stories using vocabulary
words, with highlighted vocab in HTML output. Backend endpoint
POST /learning-units/{id}/generate-story + frontend /learn/[unitId]/story.
Phase 4.2 — SyllableBow.tsx: SVG arc component for syllable visualization
under words, clickable for per-syllable TTS.
Phase 4.3 — Gamification system:
- CoinAnimation.tsx: Floating coin rewards with accumulator
- CrownBadge.tsx: Crown/medal display for milestones
- ProgressRing.tsx: Circular progress indicator
- progress_api.py: Backend tracking coins, crowns, streaks per unit
Also adds "Geschichte" exercise type button to UnitCard.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
132 lines
3.7 KiB
Python
132 lines
3.7 KiB
Python
"""
|
|
Progress API — Tracks student learning progress per unit.
|
|
|
|
Stores coins, crowns, streak data, and exercise completion stats.
|
|
Uses JSON file storage (same pattern as learning_units.py).
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import logging
|
|
from datetime import datetime, date
|
|
from typing import Dict, Any, Optional, List
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(
|
|
prefix="/progress",
|
|
tags=["progress"],
|
|
)
|
|
|
|
PROGRESS_DIR = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten/progress")
|
|
|
|
|
|
def _ensure_dir():
|
|
os.makedirs(PROGRESS_DIR, exist_ok=True)
|
|
|
|
|
|
def _progress_path(unit_id: str) -> Path:
|
|
return Path(PROGRESS_DIR) / f"{unit_id}.json"
|
|
|
|
|
|
def _load_progress(unit_id: str) -> Dict[str, Any]:
|
|
path = _progress_path(unit_id)
|
|
if path.exists():
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
return {
|
|
"unit_id": unit_id,
|
|
"coins": 0,
|
|
"crowns": 0,
|
|
"streak_days": 0,
|
|
"last_activity": None,
|
|
"exercises": {
|
|
"flashcards": {"completed": 0, "correct": 0, "incorrect": 0},
|
|
"quiz": {"completed": 0, "correct": 0, "incorrect": 0},
|
|
"type": {"completed": 0, "correct": 0, "incorrect": 0},
|
|
"story": {"generated": 0},
|
|
},
|
|
"created_at": datetime.now().isoformat(),
|
|
}
|
|
|
|
|
|
def _save_progress(unit_id: str, data: Dict[str, Any]):
|
|
_ensure_dir()
|
|
path = _progress_path(unit_id)
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
|
|
|
|
class RewardPayload(BaseModel):
|
|
exercise_type: str # flashcards, quiz, type, story
|
|
correct: bool = True
|
|
first_try: bool = True
|
|
|
|
|
|
@router.get("/{unit_id}")
|
|
def get_progress(unit_id: str):
|
|
"""Get learning progress for a unit."""
|
|
return _load_progress(unit_id)
|
|
|
|
|
|
@router.post("/{unit_id}/reward")
|
|
def add_reward(unit_id: str, payload: RewardPayload):
|
|
"""Record an exercise result and award coins."""
|
|
progress = _load_progress(unit_id)
|
|
|
|
# Update exercise stats
|
|
ex = progress["exercises"].get(payload.exercise_type, {"completed": 0, "correct": 0, "incorrect": 0})
|
|
ex["completed"] = ex.get("completed", 0) + 1
|
|
if payload.correct:
|
|
ex["correct"] = ex.get("correct", 0) + 1
|
|
else:
|
|
ex["incorrect"] = ex.get("incorrect", 0) + 1
|
|
progress["exercises"][payload.exercise_type] = ex
|
|
|
|
# Award coins
|
|
if payload.correct:
|
|
coins = 3 if payload.first_try else 1
|
|
else:
|
|
coins = 0
|
|
progress["coins"] = progress.get("coins", 0) + coins
|
|
|
|
# Update streak
|
|
today = date.today().isoformat()
|
|
last = progress.get("last_activity")
|
|
if last != today:
|
|
if last == (date.today().replace(day=date.today().day - 1)).isoformat() if date.today().day > 1 else None:
|
|
progress["streak_days"] = progress.get("streak_days", 0) + 1
|
|
elif last != today:
|
|
progress["streak_days"] = 1
|
|
progress["last_activity"] = today
|
|
|
|
# Award crowns for milestones
|
|
total_correct = sum(
|
|
e.get("correct", 0) for e in progress["exercises"].values() if isinstance(e, dict)
|
|
)
|
|
progress["crowns"] = total_correct // 20 # 1 crown per 20 correct answers
|
|
|
|
_save_progress(unit_id, progress)
|
|
|
|
return {
|
|
"coins_awarded": coins,
|
|
"total_coins": progress["coins"],
|
|
"crowns": progress["crowns"],
|
|
"streak_days": progress["streak_days"],
|
|
}
|
|
|
|
|
|
@router.get("/")
|
|
def list_all_progress():
|
|
"""List progress for all units."""
|
|
_ensure_dir()
|
|
results = []
|
|
for f in Path(PROGRESS_DIR).glob("*.json"):
|
|
with open(f, "r", encoding="utf-8") as fh:
|
|
results.append(json.load(fh))
|
|
return results
|