refactor(stage-4): partition monolithic main.py into modular APIRouters
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user