import random from typing import Optional, Dict from datetime import datetime, timezone, date from fastapi import APIRouter, Depends, HTTPException, Request, Query from pydantic import BaseModel, Field from app.auth import require_user from app.database import get_db from app.srs import calculate_sm2, generate_quiz_options, build_activity_heatmap, calculate_cefr_level from app.tts import get_audio_hash router = APIRouter(tags=["SRS & Stats"]) class SrsReviewRequest(BaseModel): card_id: int rating: int = Field(..., ge=1, le=4) @router.get("/api/srs/queue") @router.get("/api/srs/due") async def api_srs_queue( collection: Optional[str] = None, tag: Optional[str] = None, limit: int = 25, mix: bool = False, exclude_grammar: bool = True, user: dict = Depends(require_user), ): """ Returns queue of cards ready for study. If mix=True (Smart Daily Mix): - up to 10 new cards - up to 10 review due cards - up to 5 leeches / problematic cards (error_count > 0 or low ease) Shuffled for interleaved recall. """ with get_db() as conn: now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") col_filter = "" col_params = [] if collection: col_filter += " AND collection = ?" col_params.append(collection.strip()) if tag: col_filter += " AND tags LIKE ?" col_params.append(f"%{tag.strip()}%") # Exclude grammar cards from standard SRS vocabulary training unless grammar collection/tag is specifically requested wants_grammar = (collection and ("грамматика" in collection.lower() or "grammar" in collection.lower())) or \ (tag and "grammar" in tag.lower()) if exclude_grammar and not wants_grammar: col_filter += " AND (collection IS NULL OR (collection != 'Грамматика Мерфи' AND collection NOT LIKE '%grammar%'))" col_filter += " AND (tags IS NULL OR tags NOT LIKE '%grammar%')" if mix: # 1. New cards (up to 10) new_rows = conn.execute( f""" SELECT * FROM vocabulary_cards WHERE user_id = ? AND srs_state = 'new'{col_filter} ORDER BY created_at DESC LIMIT 10 """, [user["id"]] + col_params, ).fetchall() # 2. Due review cards (up to 10) due_rows = conn.execute( f""" SELECT * FROM vocabulary_cards WHERE user_id = ? AND srs_state IN ('learning', 'review', 'mastered') AND (srs_next_review <= ? OR srs_next_review IS NULL){col_filter} ORDER BY srs_next_review ASC LIMIT 10 """, [user["id"], now_str] + col_params, ).fetchall() # 3. Problematic leeches (up to 5 with highest error count) used_ids = {r["id"] for r in new_rows} | {r["id"] for r in due_rows} leech_rows = conn.execute( f""" SELECT * FROM vocabulary_cards WHERE user_id = ? AND (error_count > 0 OR srs_ease < 2.0){col_filter} ORDER BY error_count DESC, srs_ease ASC LIMIT 10 """, [user["id"]] + col_params, ).fetchall() leech_filtered = [r for r in leech_rows if r["id"] not in used_ids][:5] combined = [dict(r) for r in new_rows] + [dict(r) for r in due_rows] + [dict(r) for r in leech_filtered] random.shuffle(combined) cards = combined[:limit] else: # Standard SRS due queue: Due cards first, then new cards query = f""" SELECT * FROM vocabulary_cards WHERE user_id = ?{col_filter} AND (srs_state = 'new' OR srs_next_review <= ? OR srs_next_review IS NULL) ORDER BY CASE WHEN srs_state != 'new' AND (srs_next_review <= ? OR srs_next_review IS NULL) THEN 0 ELSE 1 END, srs_next_review ASC, created_at DESC LIMIT ? """ params = [user["id"]] + col_params + [now_str, now_str, limit] rows = conn.execute(query, params).fetchall() cards = [dict(r) for r in rows] enhanced_cards = [] for c in cards: cd = dict(c) if not cd.get("audio_url"): cd["audio_url"] = f"/api/audio/{get_audio_hash(cd['term'], 'en', 1.0)}.wav" if cd.get("context_sentence"): cd["context_audio_url"] = f"/api/audio/{get_audio_hash(cd['context_sentence'], 'en', 1.0)}.wav" enhanced_cards.append(cd) return {"count": len(enhanced_cards), "cards": enhanced_cards} @router.get("/api/srs/quiz") async def api_srs_quiz( collection: Optional[str] = None, tag: Optional[str] = None, count: int = 10, exclude_grammar: bool = True, user: dict = Depends(require_user), ): """ Generates a Multiple-Choice Quiz (4 options per question). """ with get_db() as conn: col_filter = "" col_params = [] if collection: col_filter += " AND collection = ?" col_params.append(collection.strip()) if tag: col_filter += " AND tags LIKE ?" col_params.append(f"%{tag.strip()}%") wants_grammar = (collection and ("грамматика" in collection.lower() or "grammar" in collection.lower())) or \ (tag and "grammar" in tag.lower()) if exclude_grammar and not wants_grammar: col_filter += " AND (collection IS NULL OR (collection != 'Грамматика Мерфи' AND collection NOT LIKE '%grammar%'))" col_filter += " AND (tags IS NULL OR tags NOT LIKE '%grammar%')" rows = conn.execute( f"SELECT * FROM vocabulary_cards WHERE user_id = ?{col_filter} ORDER BY RANDOM() LIMIT ?", [user["id"]] + col_params + [count], ).fetchall() target_cards = [dict(r) for r in rows] # Fetch pool of user cards for generating smart distractors (exclude grammar) distractor_filter = " AND (collection IS NULL OR (collection != 'Грамматика Мерфи' AND collection NOT LIKE '%grammar%')) AND (tags IS NULL OR tags NOT LIKE '%grammar%')" if exclude_grammar and not wants_grammar else "" all_rows = conn.execute( f"SELECT id, term, translation, part_of_speech FROM vocabulary_cards WHERE user_id = ?{distractor_filter}", (user["id"],), ).fetchall() all_cards = [dict(r) for r in all_rows] quiz_items = [] for card in target_cards: options = generate_quiz_options(card, all_cards, count=4) quiz_items.append({ "card": card, "options": options, }) return {"count": len(quiz_items), "quiz": quiz_items} @router.post("/api/srs/review") @router.post("/api/srs/review/{card_id_path}") async def api_srs_review( req: Optional[SrsReviewRequest] = None, card_id_path: Optional[int] = None, rating_param: Optional[int] = Query(None, alias="rating"), request: Request = None, user: dict = Depends(require_user), ): """ Records a review rating (1..4) and updates SM-2 state & activity logs. """ target_card_id = req.card_id if req else card_id_path target_rating = req.rating if req else rating_param if target_card_id is None: try: body = await request.json() target_card_id = body.get("card_id") target_rating = body.get("rating") except Exception: pass if target_card_id is None or target_rating is None: raise HTTPException(status_code=400, detail="Missing card_id or rating") if target_rating not in (1, 2, 3, 4): raise HTTPException(status_code=422, detail="Rating must be between 1 and 4") with get_db() as conn: row = conn.execute( "SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?", (target_card_id, user["id"]), ).fetchone() if not row: raise HTTPException(status_code=404, detail="Card not found") current = dict(row) new_srs = calculate_sm2( rating=target_rating, reps=current.get("srs_reps", 0), interval=current.get("srs_interval", 0), ease=current.get("srs_ease", 2.5), state=current.get("srs_state", "new"), error_count=current.get("error_count", 0), ) conn.execute( """ UPDATE vocabulary_cards SET srs_reps = ?, srs_interval = ?, srs_ease = ?, srs_state = ?, srs_last_review = ?, srs_next_review = ?, error_count = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? """, ( new_srs["srs_reps"], new_srs["srs_interval"], new_srs["srs_ease"], new_srs["srs_state"], new_srs["srs_last_review"], new_srs["srs_next_review"], new_srs["error_count"], target_card_id, user["id"], ), ) # Record review log conn.execute( """ INSERT INTO srs_logs (user_id, card_id, rating, interval, ease, reviewed_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) """, (user["id"], target_card_id, target_rating, new_srs["srs_interval"], new_srs["srs_ease"]), ) updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (target_card_id,)).fetchone() return {"status": "ok", "card": dict(updated_row)} @router.get("/api/srs/stats") async def api_srs_stats(user: dict = Depends(require_user)): """ Returns full learning analytics, SRS states, retention rate, streaks. """ with get_db() as conn: now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") # Total cards total_cards = conn.execute( "SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ?", (user["id"],), ).fetchone()[0] # By state states = conn.execute( """ SELECT srs_state, COUNT(*) as count FROM vocabulary_cards WHERE user_id = ? GROUP BY srs_state """, (user["id"],), ).fetchall() state_counts = {r["srs_state"]: r["count"] for r in states} new_cards = state_counts.get("new", 0) learning = state_counts.get("learning", 0) review = state_counts.get("review", 0) mastered = state_counts.get("mastered", 0) # Due now due_cards = conn.execute( """ SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ? AND (srs_state = 'new' OR srs_next_review <= ? OR srs_next_review IS NULL) """, (user["id"], now_str), ).fetchone()[0] # Leeches leech_count = conn.execute( """ SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ? AND (error_count > 0 OR srs_ease < 2.0) """, (user["id"],), ).fetchone()[0] # Total reviews and retention review_logs = conn.execute( "SELECT rating, date(reviewed_at) as rev_date FROM srs_logs WHERE user_id = ?", (user["id"],), ).fetchall() total_reviews = len(review_logs) good_reviews = sum(1 for r in review_logs if r["rating"] in (3, 4)) retention_rate = round((good_reviews / total_reviews * 100), 1) if total_reviews > 0 else 100.0 # Daily review aggregation for streak date_counts: Dict[str, int] = {} for r in review_logs: d = r["rev_date"] if d: date_counts[d] = date_counts.get(d, 0) + 1 heatmap_info = build_activity_heatmap(date_counts, days=180) # Today's activity today_str = date.today().strftime("%Y-%m-%d") today_added = conn.execute( "SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ? AND date(created_at) = date('now')", (user["id"],), ).fetchone()[0] today_reviewed = date_counts.get(today_str, 0) cefr_info = calculate_cefr_level(mastered, review, retention_rate, total_cards) return { "total_cards": total_cards, "new_cards": new_cards, "learning": learning, "review": review, "mastered": mastered, "due_cards": due_cards, "leech_count": leech_count, "total_reviews": total_reviews, "retention_rate": retention_rate, "streak_days": heatmap_info["current_streak"], "longest_streak": heatmap_info["longest_streak"], "today_added": today_added, "today_reviewed": today_reviewed, "cefr": cefr_info, } @router.get("/api/srs/activity-heatmap") async def api_srs_activity_heatmap(days: int = 180, user: dict = Depends(require_user)): """ Returns GitHub-style daily activity heatmap data. """ with get_db() as conn: rows = conn.execute( "SELECT date(reviewed_at) as rev_date, COUNT(*) as cnt FROM srs_logs WHERE user_id = ? GROUP BY date(reviewed_at)", (user["id"],), ).fetchall() date_counts = {r["rev_date"]: r["cnt"] for r in rows if r["rev_date"]} heatmap_data = build_activity_heatmap(date_counts, days=days) return heatmap_data @router.get("/api/stats") async def api_stats(user: dict = Depends(require_user)): """Legacy summary stats endpoint.""" with get_db() as conn: total = conn.execute( "SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ?", (user["id"],), ).fetchone()[0] today_added = conn.execute( "SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ? AND date(created_at) = date('now')", (user["id"],), ).fetchone()[0] return {"total_cards": total, "today_added": today_added}