364 lines
13 KiB
Python
364 lines
13 KiB
Python
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,
|
|
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()}%")
|
|
|
|
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,
|
|
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()}%")
|
|
|
|
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 all user cards for generating smart distractors
|
|
all_rows = conn.execute(
|
|
"SELECT id, term, translation, part_of_speech FROM vocabulary_cards WHERE user_id = ?",
|
|
(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}
|